design: glassmorphism, ambient orbs, bento grid, status dots, shimmer skeletons
All checks were successful
Build & Deploy Dashboard / deploy (push) Successful in 2m39s
All checks were successful
Build & Deploy Dashboard / deploy (push) Successful in 2m39s
This commit is contained in:
@@ -1,16 +1,3 @@
|
||||
import { Sidebar } from "@/components/layout/Sidebar";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -9,21 +9,29 @@ import { ClaudeUsageWidget } from "@/components/widgets/ClaudeUsageWidget";
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<main className="min-h-screen px-4 sm:px-6 lg:px-8 py-6 max-w-[1400px] mx-auto">
|
||||
<DashboardHeader />
|
||||
|
||||
{/* Row 1: Weather — full width hero */}
|
||||
<div className="mt-5">
|
||||
<WeatherWidget />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
</div>
|
||||
|
||||
{/* Row 2: Calendar (dominant) + Claude Usage */}
|
||||
<div className="mt-4 grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div className="lg:col-span-2">
|
||||
<CalendarWidget />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<ClaudeUsageWidget />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
|
||||
{/* Row 3: Git + Savings — equal split */}
|
||||
<div className="mt-4 grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<GitActivityWidget />
|
||||
<SavingsWidget />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextRequest } from "next/server";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const url = request.nextUrl.searchParams.get("url");
|
||||
if (!url) {
|
||||
return NextResponse.json({ status: "offline" }, { status: 400 });
|
||||
}
|
||||
export async function GET(req: NextRequest) {
|
||||
const target = req.nextUrl.searchParams.get("target");
|
||||
if (!target) return NextResponse.json({ ok: false }, { status: 400 });
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
const res = await fetch(url, {
|
||||
method: "HEAD",
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
return NextResponse.json({ status: res.ok ? "online" : "offline" });
|
||||
const res = await fetch(target, { signal: AbortSignal.timeout(4000), cache: "no-store" });
|
||||
return NextResponse.json({ ok: res.ok, status: res.status });
|
||||
} catch {
|
||||
return NextResponse.json({ status: "offline" });
|
||||
return NextResponse.json({ ok: false });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,36 +5,44 @@
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
background-color: #080810;
|
||||
background-color: #07070f;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
color: #f1f5f9;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(99,102,241,0.3); border-radius: 2px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(99,102,241,0.5); }
|
||||
|
||||
/* Base card — glassmorphism */
|
||||
.card {
|
||||
background: rgba(255,255,255,0.03);
|
||||
background: rgba(255,255,255,0.028);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
border-radius: 20px;
|
||||
transition: all 0.2s ease;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
.card:hover {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-color: rgba(255,255,255,0.12);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
||||
background: rgba(255,255,255,0.045);
|
||||
border-color: rgba(255,255,255,0.11);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 24px 64px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.04);
|
||||
}
|
||||
|
||||
.card-blue { border-top: 2px solid #3b82f6 !important; }
|
||||
.card-violet { border-top: 2px solid #8b5cf6 !important; }
|
||||
.card-emerald { border-top: 2px solid #10b981 !important; }
|
||||
.card-amber { border-top: 2px solid #f59e0b !important; }
|
||||
.card-cyan { border-top: 2px solid #06b6d4 !important; }
|
||||
.card-rose { border-top: 2px solid #f43f5e !important; }
|
||||
/* Color-coded top borders */
|
||||
.card-blue { border-top: 2px solid #3b82f6 !important; box-shadow: 0 -1px 20px rgba(59,130,246,0.08); }
|
||||
.card-violet { border-top: 2px solid #8b5cf6 !important; box-shadow: 0 -1px 20px rgba(139,92,246,0.08); }
|
||||
.card-emerald { border-top: 2px solid #10b981 !important; box-shadow: 0 -1px 20px rgba(16,185,129,0.08); }
|
||||
.card-amber { border-top: 2px solid #f59e0b !important; box-shadow: 0 -1px 20px rgba(245,158,11,0.08); }
|
||||
.card-cyan { border-top: 2px solid #06b6d4 !important; box-shadow: 0 -1px 20px rgba(6,182,212,0.08); }
|
||||
.card-rose { border-top: 2px solid #f43f5e !important; box-shadow: 0 -1px 20px rgba(244,63,94,0.08); }
|
||||
.card-orange { border-top: 2px solid #f97316 !important; box-shadow: 0 -1px 20px rgba(249,115,22,0.08); }
|
||||
|
||||
/* Gradient text */
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, #818cf8, #c084fc);
|
||||
-webkit-background-clip: text;
|
||||
@@ -42,14 +50,50 @@ body {
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Pulse animation for status dots */
|
||||
@keyframes ping-slow {
|
||||
0%, 100% { opacity: 0.8; transform: scale(1); }
|
||||
50% { opacity: 0.2; transform: scale(1.8); }
|
||||
50% { opacity: 0.2; transform: scale(2); }
|
||||
}
|
||||
.ping-slow { animation: ping-slow 2s ease-in-out infinite; }
|
||||
|
||||
.glass-card {
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
border-radius: 20px;
|
||||
/* Count-up number animation */
|
||||
@keyframes countUp {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.count-up { animation: countUp 0.6s ease-out forwards; }
|
||||
|
||||
/* Ambient background orbs */
|
||||
.bg-orb {
|
||||
position: fixed;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Shimmer skeleton */
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.04) 25%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.04) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.8s infinite;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Glass card variant */
|
||||
.glass-card {
|
||||
background: rgba(255,255,255,0.025);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* Glow effects */
|
||||
.glow-blue { box-shadow: 0 0 20px rgba(59,130,246,0.15); }
|
||||
.glow-violet { box-shadow: 0 0 20px rgba(139,92,246,0.15); }
|
||||
.glow-emerald { box-shadow: 0 0 20px rgba(16,185,129,0.15); }
|
||||
|
||||
@@ -5,20 +5,24 @@ import { Providers } from "./providers";
|
||||
export const metadata: Metadata = {
|
||||
title: "Digital Home",
|
||||
description: "Personal home dashboard",
|
||||
icons: {
|
||||
icon: "/favicon.svg",
|
||||
},
|
||||
icons: { icon: "/favicon.svg" },
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="ru" className="dark">
|
||||
<body style={{fontFamily: 'system-ui, -apple-system, sans-serif'}}>
|
||||
<body style={{ fontFamily: "'Inter', system-ui, sans-serif" }}>
|
||||
{/* Ambient background orbs */}
|
||||
<div className="bg-orb w-[600px] h-[600px] top-[-200px] left-[-100px] opacity-[0.06]"
|
||||
style={{ background: "radial-gradient(circle, #3b82f6, transparent 70%)" }} />
|
||||
<div className="bg-orb w-[500px] h-[500px] top-[30%] right-[-150px] opacity-[0.05]"
|
||||
style={{ background: "radial-gradient(circle, #8b5cf6, transparent 70%)" }} />
|
||||
<div className="bg-orb w-[400px] h-[400px] bottom-[10%] left-[30%] opacity-[0.04]"
|
||||
style={{ background: "radial-gradient(circle, #10b981, transparent 70%)" }} />
|
||||
|
||||
<div className="relative z-10">
|
||||
<Providers>{children}</Providers>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -4,13 +4,37 @@ import { useEffect, useState } from "react";
|
||||
const MONTHS = ["января","февраля","марта","апреля","мая","июня","июля","августа","сентября","октября","ноября","декабря"];
|
||||
const DAYS = ["воскресенье","понедельник","вторник","среда","четверг","пятница","суббота"];
|
||||
|
||||
const SERVICES = [
|
||||
{ name: "Pulse", url: "/api/ping?target=https://api.digital-home.site/health" },
|
||||
{ name: "Gitea", url: "/api/ping?target=https://git.digital-home.site" },
|
||||
{ name: "Coolify", url: "/api/ping?target=https://coolify.digital-home.site" },
|
||||
];
|
||||
|
||||
export function DashboardHeader() {
|
||||
const [now, setNow] = useState(new Date());
|
||||
const [statuses, setStatuses] = useState<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const i = setInterval(() => setNow(new Date()), 1000);
|
||||
return () => clearInterval(i);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const checkServices = async () => {
|
||||
const results = await Promise.allSettled(
|
||||
SERVICES.map(s => fetch(s.url, { signal: AbortSignal.timeout(4000) }).then(r => ({ name: s.name, ok: r.ok })))
|
||||
);
|
||||
const newStatuses: Record<string, boolean> = {};
|
||||
results.forEach((r, i) => {
|
||||
newStatuses[SERVICES[i].name] = r.status === "fulfilled" && r.value.ok;
|
||||
});
|
||||
setStatuses(newStatuses);
|
||||
};
|
||||
checkServices();
|
||||
const interval = setInterval(checkServices, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const h = now.getHours();
|
||||
const greeting = h < 6 ? "Доброй ночи" : h < 12 ? "Доброе утро" : h < 17 ? "Добрый день" : "Добрый вечер";
|
||||
|
||||
@@ -20,15 +44,33 @@ export function DashboardHeader() {
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
{greeting}, <span className="gradient-text">Daniil</span> 👋
|
||||
</h1>
|
||||
<p className="text-sm mt-0.5" style={{ color: "#475569" }}>
|
||||
<p className="text-sm mt-0.5 mb-3" style={{ color: "#475569" }}>
|
||||
{DAYS[now.getDay()]}, {now.getDate()} {MONTHS[now.getMonth()]} {now.getFullYear()}
|
||||
</p>
|
||||
{/* Service status dots */}
|
||||
<div className="flex items-center gap-3">
|
||||
{SERVICES.map(s => {
|
||||
const online = statuses[s.name];
|
||||
const unknown = statuses[s.name] === undefined;
|
||||
return (
|
||||
<div key={s.name} className="flex items-center gap-1.5">
|
||||
<div className="relative w-2 h-2">
|
||||
<div className={`w-2 h-2 rounded-full ${unknown ? "bg-slate-600" : online ? "bg-emerald-400" : "bg-red-500"}`} />
|
||||
{online && (
|
||||
<div className="absolute inset-0 w-2 h-2 rounded-full bg-emerald-400 ping-slow" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs" style={{ color: "#475569" }}>{s.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-light text-white tabular-nums">
|
||||
<div className="text-3xl font-light text-white tabular-nums tracking-tight">
|
||||
{now.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })}
|
||||
</div>
|
||||
<div className="text-xs tabular-nums" style={{ color: "#334155" }}>
|
||||
<div className="text-xs tabular-nums mt-0.5" style={{ color: "#334155" }}>
|
||||
{String(now.getSeconds()).padStart(2,"0")} сек
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user