design: glassmorphism, ambient orbs, bento grid, status dots, shimmer skeletons
All checks were successful
Build & Deploy Dashboard / deploy (push) Successful in 2m39s

This commit is contained in:
Cosmo
2026-04-20 07:12:37 +00:00
parent 9b39deba7e
commit d4525996d2
6 changed files with 143 additions and 67 deletions

View File

@@ -1,16 +1,3 @@
import { Sidebar } from "@/components/layout/Sidebar";
export const dynamic = "force-dynamic";
export default function DashboardLayout({ children }: { children: React.ReactNode }) { export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return ( return <>{children}</>;
<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>
);
} }

View File

@@ -9,21 +9,29 @@ import { ClaudeUsageWidget } from "@/components/widgets/ClaudeUsageWidget";
export default function DashboardPage() { export default function DashboardPage() {
return ( 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 /> <DashboardHeader />
{/* Row 1: Weather — full width hero */}
<div className="mt-5">
<WeatherWidget /> <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"> <div className="lg:col-span-2">
<CalendarWidget /> <CalendarWidget />
</div> </div>
<div> <div className="flex flex-col gap-4">
<ClaudeUsageWidget /> <ClaudeUsageWidget />
</div> </div>
</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 /> <GitActivityWidget />
<SavingsWidget /> <SavingsWidget />
</div> </div>
</div> </main>
); );
} }

View File

@@ -1,22 +1,13 @@
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
import { NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { NextRequest } from "next/server";
export async function GET(request: NextRequest) { export async function GET(req: NextRequest) {
const url = request.nextUrl.searchParams.get("url"); const target = req.nextUrl.searchParams.get("target");
if (!url) { if (!target) return NextResponse.json({ ok: false }, { status: 400 });
return NextResponse.json({ status: "offline" }, { status: 400 });
}
try { try {
const controller = new AbortController(); const res = await fetch(target, { signal: AbortSignal.timeout(4000), cache: "no-store" });
const timeout = setTimeout(() => controller.abort(), 5000); return NextResponse.json({ ok: res.ok, status: res.status });
const res = await fetch(url, {
method: "HEAD",
signal: controller.signal,
});
clearTimeout(timeout);
return NextResponse.json({ status: res.ok ? "online" : "offline" });
} catch { } catch {
return NextResponse.json({ status: "offline" }); return NextResponse.json({ ok: false });
} }
} }

View File

@@ -5,36 +5,44 @@
@tailwind utilities; @tailwind utilities;
body { body {
background-color: #080810; background-color: #07070f;
font-family: 'Inter', system-ui, sans-serif; font-family: 'Inter', system-ui, sans-serif;
color: #f1f5f9; color: #f1f5f9;
min-height: 100vh;
} }
/* 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(99,102,241,0.3); border-radius: 2px; } ::-webkit-scrollbar-thumb { background: rgba(99,102,241,0.3); border-radius: 2px; }
::-webkit-scrollbar-thumb:hover { background: rgba(99,102,241,0.5); } ::-webkit-scrollbar-thumb:hover { background: rgba(99,102,241,0.5); }
/* Base card — glassmorphism */
.card { .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: 1px solid rgba(255,255,255,0.07);
border-radius: 20px; border-radius: 20px;
transition: all 0.2s ease; transition: all 0.25s ease;
} }
.card:hover { .card:hover {
background: rgba(255,255,255,0.05); background: rgba(255,255,255,0.045);
border-color: rgba(255,255,255,0.12); border-color: rgba(255,255,255,0.11);
transform: translateY(-1px); transform: translateY(-2px);
box-shadow: 0 20px 60px rgba(0,0,0,0.5); 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; } /* Color-coded top borders */
.card-violet { border-top: 2px solid #8b5cf6 !important; } .card-blue { border-top: 2px solid #3b82f6 !important; box-shadow: 0 -1px 20px rgba(59,130,246,0.08); }
.card-emerald { border-top: 2px solid #10b981 !important; } .card-violet { border-top: 2px solid #8b5cf6 !important; box-shadow: 0 -1px 20px rgba(139,92,246,0.08); }
.card-amber { border-top: 2px solid #f59e0b !important; } .card-emerald { border-top: 2px solid #10b981 !important; box-shadow: 0 -1px 20px rgba(16,185,129,0.08); }
.card-cyan { border-top: 2px solid #06b6d4 !important; } .card-amber { border-top: 2px solid #f59e0b !important; box-shadow: 0 -1px 20px rgba(245,158,11,0.08); }
.card-rose { border-top: 2px solid #f43f5e !important; } .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 { .gradient-text {
background: linear-gradient(135deg, #818cf8, #c084fc); background: linear-gradient(135deg, #818cf8, #c084fc);
-webkit-background-clip: text; -webkit-background-clip: text;
@@ -42,14 +50,50 @@ body {
background-clip: text; background-clip: text;
} }
/* Pulse animation for status dots */
@keyframes ping-slow { @keyframes ping-slow {
0%, 100% { opacity: 0.8; transform: scale(1); } 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; } .ping-slow { animation: ping-slow 2s ease-in-out infinite; }
.glass-card { /* Count-up number animation */
background: rgba(255,255,255,0.03); @keyframes countUp {
border: 1px solid rgba(255,255,255,0.07); from { opacity: 0; transform: translateY(8px); }
border-radius: 20px; 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); }

View File

@@ -5,20 +5,24 @@ import { Providers } from "./providers";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Digital Home", title: "Digital Home",
description: "Personal home dashboard", description: "Personal home dashboard",
icons: { icons: { icon: "/favicon.svg" },
icon: "/favicon.svg",
},
}; };
export default function RootLayout({ export default function RootLayout({ children }: { children: React.ReactNode }) {
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return ( return (
<html lang="ru" className="dark"> <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> <Providers>{children}</Providers>
</div>
</body> </body>
</html> </html>
); );

View File

@@ -4,13 +4,37 @@ import { useEffect, useState } from "react";
const MONTHS = ["января","февраля","марта","апреля","мая","июня","июля","августа","сентября","октября","ноября","декабря"]; const MONTHS = ["января","февраля","марта","апреля","мая","июня","июля","августа","сентября","октября","ноября","декабря"];
const DAYS = ["воскресенье","понедельник","вторник","среда","четверг","пятница","суббота"]; 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() { export function DashboardHeader() {
const [now, setNow] = useState(new Date()); const [now, setNow] = useState(new Date());
const [statuses, setStatuses] = useState<Record<string, boolean>>({});
useEffect(() => { useEffect(() => {
const i = setInterval(() => setNow(new Date()), 1000); const i = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(i); 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 h = now.getHours();
const greeting = h < 6 ? "Доброй ночи" : h < 12 ? "Доброе утро" : h < 17 ? "Добрый день" : "Добрый вечер"; const greeting = h < 6 ? "Доброй ночи" : h < 12 ? "Доброе утро" : h < 17 ? "Добрый день" : "Добрый вечер";
@@ -20,15 +44,33 @@ export function DashboardHeader() {
<h1 className="text-2xl font-bold text-white"> <h1 className="text-2xl font-bold text-white">
{greeting}, <span className="gradient-text">Daniil</span> 👋 {greeting}, <span className="gradient-text">Daniil</span> 👋
</h1> </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()} {DAYS[now.getDay()]}, {now.getDate()} {MONTHS[now.getMonth()]} {now.getFullYear()}
</p> </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>
<div className="text-right"> <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" })} {now.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })}
</div> </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")} сек {String(now.getSeconds()).padStart(2,"0")} сек
</div> </div>
</div> </div>