-
-
+
+ {/* Row 3: Git + Savings — equal split */}
+
-
+
);
}
diff --git a/src/app/api/ping/route.ts b/src/app/api/ping/route.ts
index 558a88f..a344de2 100644
--- a/src/app/api/ping/route.ts
+++ b/src/app/api/ping/route.ts
@@ -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 });
}
}
diff --git a/src/app/globals.css b/src/app/globals.css
index 621ebc6..a5ffb50 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -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); }
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index fe0a35e..e3648ae 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -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 (
-
-
{children}
+
+ {/* Ambient background orbs */}
+
+
+
+
+
);
diff --git a/src/components/widgets/DashboardHeader.tsx b/src/components/widgets/DashboardHeader.tsx
index 9ab9972..4bf79a5 100644
--- a/src/components/widgets/DashboardHeader.tsx
+++ b/src/components/widgets/DashboardHeader.tsx
@@ -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
>({});
+
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 = {};
+ 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() {
{greeting}, Daniil 👋
-
+
{DAYS[now.getDay()]}, {now.getDate()} {MONTHS[now.getMonth()]} {now.getFullYear()}
+ {/* Service status dots */}
+
+ {SERVICES.map(s => {
+ const online = statuses[s.name];
+ const unknown = statuses[s.name] === undefined;
+ return (
+
+
+
+ {online && (
+
+ )}
+
+
{s.name}
+
+ );
+ })}
+