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 }) {
|
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />
|
||||||
<WeatherWidget />
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
{/* Row 1: Weather — full width hero */}
|
||||||
|
<div className="mt-5">
|
||||||
|
<WeatherWidget />
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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" }}>
|
||||||
<Providers>{children}</Providers>
|
{/* 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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user