feat: enhance dashboard components with framer-motion animations
- Added motion effects to Donut, Gauge, Header, NoData, Sparkline components for improved user experience. - Implemented initial and animate properties for SVG elements to create smooth transitions. - Introduced new motion primitives for reusable animations across components. - Updated UI elements like badges and buttons with hover effects and transitions. - Enhanced skeleton and spinner components for better loading states. - Created a motion library for consistent animation variants and utility constants.
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"framer-motion": "^12.42.2",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^1.26.0",
|
"lucide-react": "^1.26.0",
|
||||||
"next": "16.2.11",
|
"next": "16.2.11",
|
||||||
@@ -542,6 +543,8 @@
|
|||||||
|
|
||||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||||
|
|
||||||
|
"framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="],
|
||||||
|
|
||||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||||
|
|
||||||
"fs-extra": ["fs-extra@11.3.6", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA=="],
|
"fs-extra": ["fs-extra@11.3.6", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA=="],
|
||||||
@@ -712,6 +715,10 @@
|
|||||||
|
|
||||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||||
|
|
||||||
|
"motion-dom": ["motion-dom@12.42.2", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA=="],
|
||||||
|
|
||||||
|
"motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
"nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
|
"nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"framer-motion": "^12.42.2",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^1.26.0",
|
"lucide-react": "^1.26.0",
|
||||||
"next": "16.2.11",
|
"next": "16.2.11",
|
||||||
|
|||||||
+302
-208
@@ -1,11 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Donut } from "@/components/dashboard/donut";
|
import { Donut } from "@/components/dashboard/donut";
|
||||||
import { Gauge } from "@/components/dashboard/gauge";
|
import { Gauge } from "@/components/dashboard/gauge";
|
||||||
import { Sparkline } from "@/components/dashboard/sparkline";
|
import { Sparkline } from "@/components/dashboard/sparkline";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { AnimatedNumber } from "@/components/ui/motion-primitives";
|
||||||
import {
|
import {
|
||||||
type DashboardData,
|
type DashboardData,
|
||||||
gaugeColor,
|
gaugeColor,
|
||||||
@@ -13,6 +15,27 @@ import {
|
|||||||
serviceIndicator,
|
serviceIndicator,
|
||||||
} from "@/lib/dashboard/types";
|
} from "@/lib/dashboard/types";
|
||||||
|
|
||||||
|
const containerVariants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.06,
|
||||||
|
delayChildren: 0.08,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardVariants = {
|
||||||
|
hidden: { opacity: 0, y: 16, scale: 0.98 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
transition: { duration: 0.4, ease: [0.25, 0.1, 0, 1] as const },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [data, setData] = useState<DashboardData | null>(null);
|
const [data, setData] = useState<DashboardData | null>(null);
|
||||||
|
|
||||||
@@ -20,7 +43,9 @@ export default function Dashboard() {
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/dashboard");
|
const res = await fetch("/api/dashboard");
|
||||||
if (res.ok) setData(await res.json());
|
if (res.ok) {
|
||||||
|
setData(await res.json());
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
@@ -40,240 +65,301 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-[1440px] flex-1 px-4 py-3">
|
<div className="mx-auto w-full max-w-[1440px] flex-1 px-4 py-3">
|
||||||
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
<motion.div
|
||||||
|
className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
>
|
||||||
{/* Services */}
|
{/* Services */}
|
||||||
<Card>
|
<motion.div variants={cardVariants}>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<Card className="h-full transition-shadow duration-300 hover:shadow-md hover:shadow-border/20">
|
||||||
<CardTitle>Services</CardTitle>
|
|
||||||
<Badge variant="secondary" className="text-[10px] font-normal">
|
|
||||||
{data?.services.length ?? 0}
|
|
||||||
</Badge>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{data?.services.length ? (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{data.services.map((s) => (
|
|
||||||
<Badge
|
|
||||||
key={s.name}
|
|
||||||
variant="outline"
|
|
||||||
className="h-auto gap-1.5 px-2 py-1.5 text-xs font-normal"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`h-1.5 w-1.5 shrink-0 rounded-full ${serviceIndicator(s.state)}`}
|
|
||||||
/>
|
|
||||||
<span className="font-mono text-[10px]">{s.name}</span>
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="py-4 text-center text-[11px] text-muted-foreground">
|
|
||||||
No services detected
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Overview */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Overview</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-2 gap-1.5">
|
|
||||||
<StatBox
|
|
||||||
value={data?.services.length}
|
|
||||||
label="Total"
|
|
||||||
color="text-blue-400"
|
|
||||||
/>
|
|
||||||
<StatBox value={running} label="Healthy" color="text-blue-400" />
|
|
||||||
<StatBox
|
|
||||||
value={data?.traces.length ?? 0}
|
|
||||||
label="Traces"
|
|
||||||
color="text-green-400"
|
|
||||||
/>
|
|
||||||
<StatBox value={0} label="Errors" color="text-red-400" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Health */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Health</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex justify-center">
|
|
||||||
<Donut running={running} degraded={degraded} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Links */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Links</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{data?.links.map((l) => (
|
|
||||||
<a
|
|
||||||
key={l.url}
|
|
||||||
href={l.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="rounded-md border border-border px-2 py-1 text-[10px] text-blue-400 no-underline transition-colors hover:border-blue-400 hover:bg-muted"
|
|
||||||
>
|
|
||||||
{l.label}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* System Resources */}
|
|
||||||
{hasNode && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle>System Resources</CardTitle>
|
<CardTitle>Services</CardTitle>
|
||||||
<Badge
|
<Badge variant="secondary" className="text-[10px] font-normal">
|
||||||
variant="secondary"
|
{data?.services.length ?? 0}
|
||||||
className="font-mono text-[10px] font-normal"
|
|
||||||
>
|
|
||||||
{node?.load1?.toFixed(2)} {node?.load5?.toFixed(2)}{" "}
|
|
||||||
{node?.load15?.toFixed(2)}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-wrap justify-center gap-1.5">
|
{data?.services.length ? (
|
||||||
<Gauge
|
<div className="flex flex-wrap gap-1">
|
||||||
pct={node?.cpu ?? null}
|
{data.services.map((s, i) => (
|
||||||
color={gaugeColor(node?.cpu ?? null)}
|
<motion.div
|
||||||
label="CPU Usage"
|
key={s.name}
|
||||||
unit="%"
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.25, delay: 0.1 + i * 0.03 }}
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="h-auto gap-1.5 px-2 py-1.5 text-xs font-normal"
|
||||||
|
>
|
||||||
|
<motion.span
|
||||||
|
animate={
|
||||||
|
s.state === "running"
|
||||||
|
? {
|
||||||
|
opacity: [0.5, 1, 0.5],
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
transition={{
|
||||||
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
duration: 2.5,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
className={`h-1.5 w-1.5 shrink-0 rounded-full ${serviceIndicator(s.state)}`}
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-[10px]">{s.name}</span>
|
||||||
|
</Badge>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="py-4 text-center text-[11px] text-muted-foreground">
|
||||||
|
No services detected
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Overview */}
|
||||||
|
<motion.div variants={cardVariants}>
|
||||||
|
<Card className="h-full transition-shadow duration-300 hover:shadow-md hover:shadow-border/20">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Overview</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
<StatBox
|
||||||
|
value={data?.services.length}
|
||||||
|
label="Total"
|
||||||
|
color="text-blue-400"
|
||||||
/>
|
/>
|
||||||
<Gauge
|
<StatBox
|
||||||
pct={node?.ram ?? null}
|
value={running}
|
||||||
color={gaugeColor(node?.ram ?? null)}
|
label="Healthy"
|
||||||
label="Memory Usage"
|
color="text-green-400"
|
||||||
unit="%"
|
|
||||||
/>
|
/>
|
||||||
<Gauge
|
<StatBox
|
||||||
pct={node?.disk ?? null}
|
value={data?.traces.length ?? 0}
|
||||||
color={gaugeColor(node?.disk ?? null)}
|
label="Traces"
|
||||||
label="Disk Usage"
|
color="text-blue-400"
|
||||||
unit="%"
|
|
||||||
/>
|
/>
|
||||||
|
<StatBox value={0} label="Errors" color="text-red-400" />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Health */}
|
||||||
|
<motion.div variants={cardVariants}>
|
||||||
|
<Card className="h-full transition-shadow duration-300 hover:shadow-md hover:shadow-border/20">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Health</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex justify-center">
|
||||||
|
<Donut running={running} degraded={degraded} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Links */}
|
||||||
|
<motion.div variants={cardVariants}>
|
||||||
|
<Card className="h-full transition-shadow duration-300 hover:shadow-md hover:shadow-border/20">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Links</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{data?.links.map((l) => (
|
||||||
|
<motion.a
|
||||||
|
key={l.url}
|
||||||
|
href={l.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="rounded-md border border-border px-2 py-1 text-[10px] text-blue-400 no-underline transition-colors hover:border-blue-400 hover:bg-muted"
|
||||||
|
whileHover={{ scale: 1.05, y: -1 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
>
|
||||||
|
{l.label}
|
||||||
|
</motion.a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* System Resources */}
|
||||||
|
{hasNode && (
|
||||||
|
<motion.div variants={cardVariants}>
|
||||||
|
<Card className="transition-shadow duration-300 hover:shadow-md hover:shadow-border/20">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle>System Resources</CardTitle>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="font-mono text-[10px] font-normal"
|
||||||
|
>
|
||||||
|
{node?.load1?.toFixed(2)} {node?.load5?.toFixed(2)}{" "}
|
||||||
|
{node?.load15?.toFixed(2)}
|
||||||
|
</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap justify-center gap-1.5">
|
||||||
|
<Gauge
|
||||||
|
pct={node?.cpu ?? null}
|
||||||
|
color={gaugeColor(node?.cpu ?? null)}
|
||||||
|
label="CPU Usage"
|
||||||
|
unit="%"
|
||||||
|
/>
|
||||||
|
<Gauge
|
||||||
|
pct={node?.ram ?? null}
|
||||||
|
color={gaugeColor(node?.ram ?? null)}
|
||||||
|
label="Memory Usage"
|
||||||
|
unit="%"
|
||||||
|
/>
|
||||||
|
<Gauge
|
||||||
|
pct={node?.disk ?? null}
|
||||||
|
color={gaugeColor(node?.disk ?? null)}
|
||||||
|
label="Disk Usage"
|
||||||
|
unit="%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Request Rate */}
|
{/* Request Rate */}
|
||||||
{hasTraffik && (
|
{hasTraffik && (
|
||||||
<Card>
|
<motion.div variants={cardVariants}>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<Card className="transition-shadow duration-300 hover:shadow-md hover:shadow-border/20">
|
||||||
<CardTitle>Request Rate</CardTitle>
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<Badge variant="secondary" className="text-[10px] font-normal">
|
<CardTitle>Request Rate</CardTitle>
|
||||||
{data?.rps?.length
|
<Badge variant="secondary" className="text-[10px] font-normal">
|
||||||
? `${data.rps[data.rps.length - 1].toFixed(1)}/s`
|
{data?.rps?.length
|
||||||
: "-"}
|
? `${data.rps[data.rps.length - 1].toFixed(1)}/s`
|
||||||
</Badge>
|
: "-"}
|
||||||
</CardHeader>
|
</Badge>
|
||||||
<CardContent className="flex justify-center">
|
</CardHeader>
|
||||||
<Sparkline data={data?.rps ?? []} color="#58a6ff" />
|
<CardContent className="flex justify-center">
|
||||||
</CardContent>
|
<Sparkline data={data?.rps ?? []} color="#58a6ff" />
|
||||||
</Card>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Latency */}
|
{/* Latency */}
|
||||||
{hasTraffik && (
|
{hasTraffik && (
|
||||||
<Card>
|
<motion.div variants={cardVariants}>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<Card className="transition-shadow duration-300 hover:shadow-md hover:shadow-border/20">
|
||||||
<CardTitle>Latency</CardTitle>
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<Badge variant="secondary" className="text-[10px] font-normal">
|
<CardTitle>Latency</CardTitle>
|
||||||
{data?.latency?.length
|
<Badge variant="secondary" className="text-[10px] font-normal">
|
||||||
? `${data.latency[data.latency.length - 1].toFixed(0)}ms`
|
{data?.latency?.length
|
||||||
: "-"}
|
? `${data.latency[data.latency.length - 1].toFixed(0)}ms`
|
||||||
</Badge>
|
: "-"}
|
||||||
</CardHeader>
|
</Badge>
|
||||||
<CardContent className="flex justify-center">
|
</CardHeader>
|
||||||
<Sparkline data={data?.latency ?? []} color="#bc8cff" />
|
<CardContent className="flex justify-center">
|
||||||
</CardContent>
|
<Sparkline data={data?.latency ?? []} color="#bc8cff" />
|
||||||
</Card>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error Rate */}
|
{/* Error Rate */}
|
||||||
{hasTraffik && (
|
{hasTraffik && (
|
||||||
<Card>
|
<motion.div variants={cardVariants}>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<Card className="transition-shadow duration-300 hover:shadow-md hover:shadow-border/20">
|
||||||
<CardTitle>Error Rate</CardTitle>
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<Badge variant="secondary" className="text-[10px] font-normal">
|
<CardTitle>Error Rate</CardTitle>
|
||||||
{data?.errors?.length
|
<Badge variant="secondary" className="text-[10px] font-normal">
|
||||||
? `${data.errors[data.errors.length - 1].toFixed(1)}/s`
|
{data?.errors?.length
|
||||||
: "-"}
|
? `${data.errors[data.errors.length - 1].toFixed(1)}/s`
|
||||||
</Badge>
|
: "-"}
|
||||||
</CardHeader>
|
</Badge>
|
||||||
<CardContent className="flex justify-center">
|
</CardHeader>
|
||||||
<Sparkline data={data?.errors ?? []} color="#f85149" />
|
<CardContent className="flex justify-center">
|
||||||
</CardContent>
|
<Sparkline data={data?.errors ?? []} color="#f85149" />
|
||||||
</Card>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Trace Volume */}
|
{/* Trace Volume */}
|
||||||
<Card>
|
<motion.div variants={cardVariants}>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<Card className="transition-shadow duration-300 hover:shadow-md hover:shadow-border/20">
|
||||||
<CardTitle>Trace Volume</CardTitle>
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<Badge variant="secondary" className="text-[10px] font-normal">
|
<CardTitle>Trace Volume</CardTitle>
|
||||||
{data?.traces.length ?? 0} traces
|
<Badge variant="secondary" className="text-[10px] font-normal">
|
||||||
</Badge>
|
{data?.traces.length ?? 0} traces
|
||||||
</CardHeader>
|
</Badge>
|
||||||
<CardContent className="flex justify-center">
|
</CardHeader>
|
||||||
<Sparkline data={data?.traceVolume ?? []} color="#3fb950" />
|
<CardContent className="flex justify-center">
|
||||||
</CardContent>
|
<Sparkline data={data?.traceVolume ?? []} color="#3fb950" />
|
||||||
</Card>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
{/* Recent Traces */}
|
{/* Recent Traces */}
|
||||||
<Card className="2xl:col-span-2">
|
<motion.div variants={cardVariants} className="2xl:col-span-2">
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<Card className="transition-shadow duration-300 hover:shadow-md hover:shadow-border/20">
|
||||||
<CardTitle>Recent Traces</CardTitle>
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<Badge variant="secondary" className="text-[10px] font-normal">
|
<CardTitle>Recent Traces</CardTitle>
|
||||||
{data?.traces.length ?? 0}
|
<Badge variant="secondary" className="text-[10px] font-normal">
|
||||||
</Badge>
|
{data?.traces.length ?? 0}
|
||||||
</CardHeader>
|
</Badge>
|
||||||
<CardContent>
|
</CardHeader>
|
||||||
{data?.traces.length ? (
|
<CardContent>
|
||||||
<ul className="list-none">
|
{data?.traces.length ? (
|
||||||
{data.traces.map((t, i) => (
|
<ul className="list-none">
|
||||||
<li
|
{data.traces.map((t, i) => (
|
||||||
key={`${t.service}-${t.operation}-${i}`}
|
<motion.li
|
||||||
className="flex items-center justify-between gap-1.5 border-b border-border py-1.5 text-[11px] last:border-0"
|
// biome-ignore lint/suspicious/noArrayIndexKey: trace data has no unique id
|
||||||
>
|
key={`${t.service}-${t.operation}-${i}`}
|
||||||
<div className="min-w-0">
|
initial={{ opacity: 0, x: -8 }}
|
||||||
<div className="truncate font-medium">{t.service}</div>
|
animate={{ opacity: 1, x: 0 }}
|
||||||
<div className="max-w-[200px] truncate font-mono text-[10px] text-muted-foreground">
|
transition={{ duration: 0.3, delay: 0.1 + i * 0.04 }}
|
||||||
{t.operation}
|
className="flex items-center justify-between gap-1.5 border-b border-border py-1.5 text-[11px] last:border-0"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-medium">{t.service}</div>
|
||||||
|
<div className="max-w-[200px] truncate font-mono text-[10px] text-muted-foreground">
|
||||||
|
{t.operation}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex shrink-0 items-center gap-2 text-[10px] text-muted-foreground">
|
||||||
<div className="flex shrink-0 items-center gap-2 text-[10px] text-muted-foreground">
|
<span className="font-mono font-medium text-blue-400">
|
||||||
<span className="font-mono font-medium text-blue-400">
|
{safeDur(t.duration)}
|
||||||
{safeDur(t.duration)}
|
|
||||||
</span>
|
|
||||||
<span>{t.spans}</span>
|
|
||||||
{t.hasError && (
|
|
||||||
<span className="rounded-sm bg-red-500/10 px-1.5 py-0.5 text-[9px] text-red-500">
|
|
||||||
err
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
<span>{t.spans}</span>
|
||||||
</div>
|
{t.hasError && (
|
||||||
</li>
|
<motion.span
|
||||||
))}
|
className="rounded-sm bg-red-500/10 px-1.5 py-0.5 text-[9px] text-red-500"
|
||||||
</ul>
|
animate={{ opacity: [0.7, 1, 0.7] }}
|
||||||
) : (
|
transition={{
|
||||||
<p className="py-4 text-center text-[11px] text-muted-foreground">
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
No traces — data appears once services send OTel telemetry
|
duration: 2,
|
||||||
</p>
|
}}
|
||||||
)}
|
>
|
||||||
</CardContent>
|
err
|
||||||
</Card>
|
</motion.span>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="py-4 text-center text-[11px] text-muted-foreground">
|
||||||
|
No traces — data appears once services send OTel
|
||||||
|
telemetry
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -288,13 +374,21 @@ function StatBox({
|
|||||||
color: string;
|
color: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-muted/50 p-2.5 text-center">
|
<motion.div
|
||||||
|
className="rounded-lg bg-muted/50 p-2.5 text-center transition-colors duration-300 hover:bg-muted/80"
|
||||||
|
whileHover={{ y: -2 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
<div className={`font-mono text-lg font-bold leading-tight ${color}`}>
|
<div className={`font-mono text-lg font-bold leading-tight ${color}`}>
|
||||||
{value ?? "-"}
|
{typeof value === "number" ? (
|
||||||
|
<AnimatedNumber value={value} />
|
||||||
|
) : (
|
||||||
|
(value ?? "-")
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-0.5 text-[10px] uppercase tracking-wider text-muted-foreground">
|
<p className="mt-0.5 text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||||
{label}
|
{label}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+158
-2
@@ -46,6 +46,75 @@
|
|||||||
--radius-2xl: calc(var(--radius) * 1.8);
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
--radius-3xl: calc(var(--radius) * 2.2);
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
--radius-4xl: calc(var(--radius) * 2.6);
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
|
|
||||||
|
/* ── Custom keyframes ── */
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glow-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 6px oklch(0.7 0.15 75 / 0.15);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 18px oklch(0.7 0.15 75 / 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gradient-shift {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0px);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-6px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes breathe {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes border-glow {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
border-color: oklch(0.7 0.15 75 / 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
--animate-shimmer: shimmer 2.5s ease-in-out infinite;
|
||||||
|
--animate-glow-pulse: glow-pulse 3s ease-in-out infinite;
|
||||||
|
--animate-gradient-shift: gradient-shift 8s ease infinite;
|
||||||
|
--animate-float: float 4s ease-in-out infinite;
|
||||||
|
--animate-breathe: breathe 3s ease-in-out infinite;
|
||||||
|
--animate-border-glow: border-glow 3s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -125,11 +194,65 @@
|
|||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
html {
|
html {
|
||||||
@apply font-sans;
|
@apply font-sans scroll-smooth;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Signature: glowing "server LED" focus ring */
|
/* ── Custom utilities ── */
|
||||||
|
|
||||||
|
/* Glass card effect */
|
||||||
|
.glass {
|
||||||
|
@apply bg-background/60 backdrop-blur-2xl border border-border/50 shadow-lg shadow-black/5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient text */
|
||||||
|
.gradient-text {
|
||||||
|
@apply bg-gradient-to-r from-primary via-accent to-chart-3 bg-clip-text text-transparent bg-[length:200%_100%] animate-gradient-shift;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle grid background for hero */
|
||||||
|
.bg-grid {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
oklch(0.5 0.02 265 / 0.04) 1px,
|
||||||
|
transparent 1px
|
||||||
|
),
|
||||||
|
linear-gradient(90deg, oklch(0.5 0.02 265 / 0.04) 1px, transparent 1px);
|
||||||
|
background-size: 40px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bg-grid {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
oklch(0.9 0.02 85 / 0.03) 1px,
|
||||||
|
transparent 1px
|
||||||
|
),
|
||||||
|
linear-gradient(90deg, oklch(0.9 0.02 85 / 0.03) 1px, transparent 1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glow accent ring */
|
||||||
|
.glow-ring {
|
||||||
|
box-shadow: 0 0 12px oklch(0.7 0.15 75 / 0.15),
|
||||||
|
0 0 0 1px oklch(0.7 0.15 75 / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Terminal cursor blink ── */
|
||||||
|
.terminal-cursor::after {
|
||||||
|
content: "▊";
|
||||||
|
animation: blink 1s step-end infinite;
|
||||||
|
margin-left: 2px;
|
||||||
|
@apply text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Focus ring glow ── */
|
||||||
.dark *:focus-visible {
|
.dark *:focus-visible {
|
||||||
@apply ring-[3px];
|
@apply ring-[3px];
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -141,3 +264,36 @@ html:not(.dark) *:focus-visible {
|
|||||||
@apply ring-[3px];
|
@apply ring-[3px];
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Selection ── */
|
||||||
|
::selection {
|
||||||
|
background-color: oklch(0.7 0.15 75 / 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scrollbar ── */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: oklch(0.5 0.02 265 / 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: oklch(0.5 0.02 265 / 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Reduced motion ── */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms;
|
||||||
|
animation-iteration-count: 1;
|
||||||
|
transition-duration: 0.01ms;
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+337
-200
@@ -1,3 +1,6 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Container,
|
Container,
|
||||||
@@ -13,6 +16,13 @@ import {
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
MotionItem,
|
||||||
|
Reveal,
|
||||||
|
ScaleReveal,
|
||||||
|
StaggerContainer,
|
||||||
|
Typewriter,
|
||||||
|
} from "@/components/ui/motion-primitives";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
// ponytail: static data, fine for a personal portfolio. Move to CMS/content layer if > 50 items.
|
// ponytail: static data, fine for a personal portfolio. Move to CMS/content layer if > 50 items.
|
||||||
@@ -51,240 +61,367 @@ const techTags = [
|
|||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
{/* Hero — terminal-inspired */}
|
{/* ── Hero — terminal-inspired ── */}
|
||||||
<section className="relative flex flex-col items-center justify-center overflow-hidden px-6 py-28 text-center sm:py-36">
|
<section className="relative flex flex-col items-center justify-center overflow-hidden px-6 py-28 text-center sm:py-36">
|
||||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,oklch(0.45_0.13_265_/_0.06)_0%,transparent_70%)] dark:bg-[radial-gradient(ellipse_at_center,oklch(0.7_0.15_75_/_0.06)_0%,transparent_70%)]" />
|
{/* Animated gradient background */}
|
||||||
|
<motion.div
|
||||||
|
className="pointer-events-none absolute inset-0 bg-grid"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 1 }}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,oklch(0.45_0.13_265_/_0.06)_0%,transparent_70%)] dark:bg-[radial-gradient(ellipse_at_center,oklch(0.7_0.15_75_/_0.06)_0%,transparent_70%)]"
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 1.2, ease: [0.25, 0.1, 0, 1] as const }}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<p className="mb-2 font-mono text-xs text-muted-foreground">
|
<motion.p
|
||||||
|
className="mb-2 font-mono text-xs text-muted-foreground"
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.1 }}
|
||||||
|
>
|
||||||
<span className="text-primary dark:text-primary">~</span> whoami
|
<span className="text-primary dark:text-primary">~</span> whoami
|
||||||
</p>
|
</motion.p>
|
||||||
<h1 className="font-mono text-3xl font-bold tracking-tight sm:text-5xl">
|
<motion.h1
|
||||||
<span className="text-muted-foreground">$ </span>
|
className="font-mono text-3xl font-bold tracking-tight sm:text-5xl"
|
||||||
Asep Haryana Saputra
|
initial={{ y: 16 }}
|
||||||
<span className="inline-block size-2.5 animate-pulse bg-foreground ml-1 rounded-none" />
|
animate={{ y: 0 }}
|
||||||
</h1>
|
transition={{ duration: 0.5, delay: 0.2, ease: [0.25, 0.1, 0, 1] as const }}
|
||||||
<p className="mx-auto mt-3 max-w-lg font-mono text-xs text-muted-foreground sm:text-sm">
|
>
|
||||||
|
<span className="text-muted-foreground">$ </span>
|
||||||
|
<Typewriter
|
||||||
|
text="Asep Haryana Saputra"
|
||||||
|
className="gradient-text font-mono"
|
||||||
|
speed={0.045}
|
||||||
|
startDelay={200}
|
||||||
|
/>
|
||||||
|
</motion.h1>
|
||||||
|
<motion.p
|
||||||
|
className="mx-auto mt-3 max-w-lg font-mono text-xs text-muted-foreground sm:text-sm"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.4 }}
|
||||||
|
>
|
||||||
<span className="text-muted-foreground/50"># </span>
|
<span className="text-muted-foreground/50"># </span>
|
||||||
Teknik Informatika student exploring backend systems, distributed
|
Teknik Informatika student exploring backend systems, distributed
|
||||||
infrastructure, and DevOps — still learning, still building.
|
infrastructure, and DevOps — still learning, still building.
|
||||||
</p>
|
</motion.p>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative mt-10 flex flex-wrap items-center justify-center gap-3">
|
|
||||||
<a href="#projects" className={cn(buttonVariants({ size: "lg" }))}>
|
<motion.div
|
||||||
|
className="relative mt-10 flex flex-wrap items-center justify-center gap-3"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.6 }}
|
||||||
|
>
|
||||||
|
<motion.a
|
||||||
|
href="#projects"
|
||||||
|
className={cn(buttonVariants({ size: "lg" }))}
|
||||||
|
whileHover={{ scale: 1.03 }}
|
||||||
|
whileTap={{ scale: 0.97 }}
|
||||||
|
>
|
||||||
View Projects
|
View Projects
|
||||||
<ArrowRight className="size-4" />
|
<motion.span
|
||||||
</a>
|
animate={{ x: [0, 4, 0] }}
|
||||||
<a
|
transition={{ repeat: Number.POSITIVE_INFINITY, duration: 2 }}
|
||||||
|
>
|
||||||
|
<ArrowRight className="size-4" />
|
||||||
|
</motion.span>
|
||||||
|
</motion.a>
|
||||||
|
<motion.a
|
||||||
href="https://github.com/asepharyana"
|
href="https://github.com/asepharyana"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
|
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
|
||||||
|
whileHover={{ scale: 1.03 }}
|
||||||
|
whileTap={{ scale: 0.97 }}
|
||||||
>
|
>
|
||||||
GitHub
|
GitHub
|
||||||
<ExternalLink className="size-4" />
|
<ExternalLink className="size-4" />
|
||||||
</a>
|
</motion.a>
|
||||||
</div>
|
</motion.div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Content — staggered sections with alternating visual weight */}
|
{/* ── Content — staggered sections ── */}
|
||||||
<section className="mx-auto flex w-full max-w-3xl flex-col gap-12 px-6 pb-28">
|
<div className="mx-auto flex w-full max-w-3xl flex-col gap-12 px-6 pb-28">
|
||||||
{/* About — minimal, just text */}
|
{/* About */}
|
||||||
<div>
|
<Reveal>
|
||||||
<p className="mb-2 font-mono text-xs text-muted-foreground">
|
<div>
|
||||||
<span className="text-primary dark:text-primary">~</span> about
|
<p className="mb-2 font-mono text-xs text-muted-foreground">
|
||||||
</p>
|
<span className="text-primary dark:text-primary">~</span> about
|
||||||
<div className="space-y-3 leading-relaxed text-muted-foreground">
|
|
||||||
<p>
|
|
||||||
I’m a Teknik Informatika student passionate about backend
|
|
||||||
engineering, distributed systems, and infrastructure. I spend my
|
|
||||||
time building RESTful APIs, learning container orchestration with
|
|
||||||
Docker & Traefik, and exploring observability with
|
|
||||||
OpenTelemetry & Jaeger.
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<div className="space-y-3 leading-relaxed text-muted-foreground">
|
||||||
I was the Back-End lead on the ZeaVis Edu capstone team (“AI
|
<p>
|
||||||
for Smart Education” by Pijak × IBM SkillsBuild), a
|
I’m a Teknik Informatika student passionate about backend
|
||||||
5-person team with task division across Back-End, Front-End, and
|
engineering, distributed systems, and infrastructure. I spend my
|
||||||
Machine Learning. I handled the system architecture, API design,
|
time building RESTful APIs, learning container orchestration
|
||||||
RESTful API development, and Docker deployment on VPS.
|
with Docker & Traefik, and exploring observability with
|
||||||
</p>
|
OpenTelemetry & Jaeger.
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Skills & Tools — grid */}
|
|
||||||
<div>
|
|
||||||
<p className="mb-2 font-mono text-xs text-muted-foreground">
|
|
||||||
<span className="text-primary dark:text-primary">~</span> skills
|
|
||||||
</p>
|
|
||||||
<div className="mb-4 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
||||||
{skills.map((s) => {
|
|
||||||
const Icon = s.icon;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={s.label}
|
|
||||||
className="flex items-center gap-3 rounded-lg border border-border bg-card px-3.5 py-2.5 text-sm"
|
|
||||||
>
|
|
||||||
<Icon className="size-4 shrink-0 text-primary dark:text-primary" />
|
|
||||||
<span>{s.label}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{techTags.map((t) => (
|
|
||||||
<Badge
|
|
||||||
key={t}
|
|
||||||
variant="outline"
|
|
||||||
className="text-[11px] font-normal"
|
|
||||||
>
|
|
||||||
{t}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Featured Project — elevated */}
|
|
||||||
<div id="projects" className="scroll-mt-24">
|
|
||||||
<p className="mb-2 font-mono text-xs text-muted-foreground">
|
|
||||||
<span className="text-primary dark:text-primary">~</span> featured
|
|
||||||
project
|
|
||||||
</p>
|
|
||||||
<Card className="relative overflow-hidden">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-lg">ZeaVis Edu</CardTitle>
|
|
||||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
|
||||||
Asisten Edukasi Interaktif untuk Deteksi Penyakit Daun
|
|
||||||
Jagung
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 gap-2">
|
|
||||||
<a
|
|
||||||
href="https://zeavisedu.asepharyana.my.id/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className={cn(
|
|
||||||
buttonVariants({ variant: "outline", size: "sm" }),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ExternalLink className="size-3.5" />
|
|
||||||
Live
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://github.com/ATLAS-PJK-GM007/ZeaVis-Edu"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className={cn(
|
|
||||||
buttonVariants({ variant: "outline", size: "sm" }),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<GitFork className="size-3.5" />
|
|
||||||
Source
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="text-sm text-muted-foreground">
|
|
||||||
<p className="mb-4">
|
|
||||||
A Computer Vision-powered educational platform that helps
|
|
||||||
farmers, agricultural students, and field extension officers
|
|
||||||
identify corn leaf diseases by uploading a photo. Built as a
|
|
||||||
capstone project for the Pijak × IBM SkillsBuild program.
|
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
I contributed to the ZeaVis Edu capstone team (“AI
|
||||||
|
for Smart Education” by Pijak × IBM SkillsBuild), a
|
||||||
|
5-person team where we divided tasks across Back-End, Front-End,
|
||||||
|
and Machine Learning. I worked on the system architecture, API
|
||||||
|
design, RESTful API development, and Docker deployment on VPS.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
{/* Skills & Tools */}
|
||||||
<div className="rounded-lg border border-border bg-muted/30 p-3">
|
<Reveal>
|
||||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-foreground">
|
<div>
|
||||||
What it does
|
<p className="mb-2 font-mono text-xs text-muted-foreground">
|
||||||
</p>
|
<span className="text-primary dark:text-primary">~</span> skills
|
||||||
<ul className="list-inside list-disc space-y-1 text-xs">
|
</p>
|
||||||
<li>Upload a photo of a corn leaf</li>
|
<StaggerContainer className="mb-4 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
<li>
|
{skills.map((s) => {
|
||||||
AI classifies the disease — Blight, Rust, Leaf Spot,
|
const Icon = s.icon;
|
||||||
or Healthy
|
return (
|
||||||
</li>
|
<MotionItem key={s.label}>
|
||||||
<li>Get treatment & prevention recommendations</li>
|
<motion.div
|
||||||
</ul>
|
className="flex items-center gap-3 rounded-lg border border-border bg-card px-3.5 py-2.5 text-sm transition-all duration-300"
|
||||||
</div>
|
whileHover={{
|
||||||
<div className="rounded-lg border border-border bg-muted/30 p-3">
|
y: -2,
|
||||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-foreground">
|
borderColor: "var(--ring)",
|
||||||
My role
|
boxShadow: "0 4px 14px oklch(0 0 0 / 0.08)",
|
||||||
</p>
|
}}
|
||||||
<ul className="list-inside list-disc space-y-1 text-xs">
|
>
|
||||||
<li>System architecture & API design</li>
|
<Icon className="size-4 shrink-0 text-primary dark:text-primary" />
|
||||||
<li>RESTful API (Bun + Elysia + Drizzle + PostgreSQL)</li>
|
<span>{s.label}</span>
|
||||||
<li>Secure upload handling</li>
|
</motion.div>
|
||||||
<li>
|
</MotionItem>
|
||||||
Docker deployment on VPS with Traefik & Tailscale
|
);
|
||||||
</li>
|
})}
|
||||||
</ul>
|
</StaggerContainer>
|
||||||
</div>
|
<StaggerContainer staggerVariants={{ hidden: { opacity: 0 }, visible: { opacity: 1, transition: { staggerChildren: 0.03, delayChildren: 0.1 } } }} className="flex flex-wrap gap-1.5">
|
||||||
</div>
|
{techTags.map((t) => (
|
||||||
|
<MotionItem key={t}>
|
||||||
<div className="mt-4 flex flex-wrap gap-1.5">
|
<motion.span
|
||||||
{[
|
whileHover={{ scale: 1.05, y: -1 }}
|
||||||
"React",
|
whileTap={{ scale: 0.95 }}
|
||||||
"TypeScript",
|
|
||||||
"Bun",
|
|
||||||
"Elysia",
|
|
||||||
"Drizzle",
|
|
||||||
"PostgreSQL",
|
|
||||||
"Rust",
|
|
||||||
"Axum",
|
|
||||||
"ONNX",
|
|
||||||
"TensorFlow",
|
|
||||||
"EfficientNetV2B0",
|
|
||||||
"Tauri 2",
|
|
||||||
"Docker",
|
|
||||||
"Traefik",
|
|
||||||
].map((t) => (
|
|
||||||
<Badge
|
|
||||||
key={t}
|
|
||||||
variant="secondary"
|
|
||||||
className="text-[10px] font-normal"
|
|
||||||
>
|
>
|
||||||
{t}
|
<Badge
|
||||||
</Badge>
|
variant="outline"
|
||||||
))}
|
className="text-[11px] font-normal transition-all duration-300 hover:border-primary/30 hover:bg-primary/5"
|
||||||
</div>
|
>
|
||||||
</CardContent>
|
{t}
|
||||||
</Card>
|
</Badge>
|
||||||
</div>
|
</motion.span>
|
||||||
|
</MotionItem>
|
||||||
|
))}
|
||||||
|
</StaggerContainer>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
{/* Infrastructure CTA — clean card */}
|
{/* Featured Project */}
|
||||||
<Card className="text-center">
|
<Reveal>
|
||||||
<CardContent className="flex flex-col items-center gap-4 py-10">
|
<div id="projects" className="scroll-mt-24">
|
||||||
<p className="text-base font-medium">
|
<p className="mb-2 font-mono text-xs text-muted-foreground">
|
||||||
This site doubles as a live infrastructure monitor.
|
<span className="text-primary dark:text-primary">~</span> featured
|
||||||
|
project
|
||||||
</p>
|
</p>
|
||||||
<p className="max-w-md text-sm text-muted-foreground">
|
<ScaleReveal>
|
||||||
The dashboard tracks Docker services, Traefik request metrics,
|
<motion.div
|
||||||
Prometheus system resources, and Jaeger traces — all running on my
|
whileHover={{ y: -4 }}
|
||||||
VPS deployment.
|
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||||
</p>
|
>
|
||||||
<a href="/dashboard" className={cn(buttonVariants())}>
|
<Card className="relative overflow-hidden border-primary/10 transition-all duration-300 hover:border-primary/30 hover:shadow-lg hover:shadow-primary/5">
|
||||||
Live Dashboard
|
{/* Gradient border accent */}
|
||||||
<ArrowRight className="size-4" />
|
<div className="pointer-events-none absolute inset-x-0 top-0 h-[2px] bg-gradient-to-r from-primary/0 via-primary/50 to-primary/0" />
|
||||||
</a>
|
|
||||||
</CardContent>
|
<CardHeader>
|
||||||
</Card>
|
<div className="flex items-start justify-between gap-4">
|
||||||
</section>
|
<div>
|
||||||
|
<CardTitle className="text-lg">ZeaVis Edu</CardTitle>
|
||||||
|
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||||
|
Asisten Edukasi Interaktif untuk Deteksi Penyakit Daun
|
||||||
|
Jagung
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 gap-2">
|
||||||
|
<motion.a
|
||||||
|
href="https://zeavisedu.asepharyana.my.id/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "outline", size: "sm" }),
|
||||||
|
)}
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-3.5" />
|
||||||
|
Live
|
||||||
|
</motion.a>
|
||||||
|
<motion.a
|
||||||
|
href="https://github.com/ATLAS-PJK-GM007/ZeaVis-Edu"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "outline", size: "sm" }),
|
||||||
|
)}
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
>
|
||||||
|
<GitFork className="size-3.5" />
|
||||||
|
Source
|
||||||
|
</motion.a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm text-muted-foreground">
|
||||||
|
<p className="mb-4">
|
||||||
|
A Computer Vision-powered educational platform that helps
|
||||||
|
farmers, agricultural students, and field extension officers
|
||||||
|
identify corn leaf diseases by uploading a photo. Built as a
|
||||||
|
capstone project for the Pijak × IBM SkillsBuild program.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<motion.div
|
||||||
|
className="rounded-lg border border-border bg-muted/30 p-3 transition-all duration-300"
|
||||||
|
whileHover={{
|
||||||
|
y: -2,
|
||||||
|
borderColor: "var(--ring)",
|
||||||
|
backgroundColor: "oklch(0.7 0.15 75 / 0.04)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-foreground">
|
||||||
|
What it does
|
||||||
|
</p>
|
||||||
|
<ul className="list-inside list-disc space-y-1 text-xs">
|
||||||
|
<li>Upload a photo of a corn leaf</li>
|
||||||
|
<li>
|
||||||
|
AI classifies the disease — Blight, Rust, Leaf Spot,
|
||||||
|
or Healthy
|
||||||
|
</li>
|
||||||
|
<li>Get treatment & prevention recommendations</li>
|
||||||
|
</ul>
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
className="rounded-lg border border-border bg-muted/30 p-3 transition-all duration-300"
|
||||||
|
whileHover={{
|
||||||
|
y: -2,
|
||||||
|
borderColor: "var(--ring)",
|
||||||
|
backgroundColor: "oklch(0.7 0.15 75 / 0.04)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-foreground">
|
||||||
|
My role
|
||||||
|
</p>
|
||||||
|
<ul className="list-inside list-disc space-y-1 text-xs">
|
||||||
|
<li>System architecture & API design</li>
|
||||||
|
<li>RESTful API (Bun + Elysia + Drizzle + PostgreSQL)</li>
|
||||||
|
<li>Secure upload handling</li>
|
||||||
|
<li>
|
||||||
|
Docker deployment on VPS with Traefik & Tailscale
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StaggerContainer
|
||||||
|
staggerVariants={{ hidden: { opacity: 0 }, visible: { opacity: 1, transition: { staggerChildren: 0.03 } } }}
|
||||||
|
className="mt-4 flex flex-wrap gap-1.5"
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
"React",
|
||||||
|
"TypeScript",
|
||||||
|
"Bun",
|
||||||
|
"Elysia",
|
||||||
|
"Drizzle",
|
||||||
|
"PostgreSQL",
|
||||||
|
"Rust",
|
||||||
|
"Axum",
|
||||||
|
"ONNX",
|
||||||
|
"TensorFlow",
|
||||||
|
"EfficientNetV2B0",
|
||||||
|
"Tauri 2",
|
||||||
|
"Docker",
|
||||||
|
"Traefik",
|
||||||
|
].map((t) => (
|
||||||
|
<MotionItem key={t}>
|
||||||
|
<motion.span
|
||||||
|
whileHover={{ scale: 1.05, y: -1 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="text-[10px] font-normal"
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</Badge>
|
||||||
|
</motion.span>
|
||||||
|
</MotionItem>
|
||||||
|
))}
|
||||||
|
</StaggerContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
</ScaleReveal>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
{/* Infrastructure CTA */}
|
||||||
|
<Reveal>
|
||||||
|
<ScaleReveal>
|
||||||
|
<motion.div
|
||||||
|
whileHover={{ y: -3 }}
|
||||||
|
transition={{ duration: 0.25 }}
|
||||||
|
>
|
||||||
|
<Card className="relative overflow-hidden border-primary/10 text-center transition-all duration-300 hover:border-primary/30 hover:shadow-lg hover:shadow-primary/5">
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 top-0 h-[2px] bg-gradient-to-r from-primary/0 via-primary/50 to-primary/0" />
|
||||||
|
<CardContent className="flex flex-col items-center gap-4 py-10">
|
||||||
|
<p className="text-base font-medium">
|
||||||
|
This site doubles as a live infrastructure monitor.
|
||||||
|
</p>
|
||||||
|
<p className="max-w-md text-sm text-muted-foreground">
|
||||||
|
The dashboard tracks Docker services, Traefik request metrics,
|
||||||
|
Prometheus system resources, and Jaeger traces — all running on my
|
||||||
|
VPS deployment.
|
||||||
|
</p>
|
||||||
|
<motion.a
|
||||||
|
href="/dashboard"
|
||||||
|
className={cn(buttonVariants())}
|
||||||
|
whileHover={{ scale: 1.04 }}
|
||||||
|
whileTap={{ scale: 0.96 }}
|
||||||
|
>
|
||||||
|
Live Dashboard
|
||||||
|
<ArrowRight className="size-4" />
|
||||||
|
</motion.a>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
</ScaleReveal>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="mt-auto border-t py-6 text-center text-xs text-muted-foreground">
|
<motion.footer
|
||||||
|
className="mt-auto border-t py-6 text-center text-xs text-muted-foreground"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
whileInView={{ opacity: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
<p>
|
<p>
|
||||||
© {new Date().getFullYear()} Asep Haryana Saputra —{" "}
|
© {new Date().getFullYear()} Asep Haryana Saputra —{" "}
|
||||||
<a
|
<motion.a
|
||||||
href="https://github.com/asepharyana"
|
href="https://github.com/asepharyana"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="underline underline-offset-2 hover:text-foreground"
|
className="underline underline-offset-2 hover:text-foreground"
|
||||||
|
whileHover={{ color: "var(--primary)" }}
|
||||||
>
|
>
|
||||||
GitHub
|
GitHub
|
||||||
</a>
|
</motion.a>
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</motion.footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
import { NoData } from "./no-data";
|
import { NoData } from "./no-data";
|
||||||
|
|
||||||
export function Donut({
|
export function Donut({
|
||||||
@@ -21,19 +24,24 @@ export function Donut({
|
|||||||
|
|
||||||
let off = 0;
|
let off = 0;
|
||||||
return (
|
return (
|
||||||
<svg
|
<motion.svg
|
||||||
width={200}
|
width={200}
|
||||||
height={210}
|
height={210}
|
||||||
viewBox="0 0 200 210"
|
viewBox="0 0 200 210"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Service health donut chart"
|
aria-label="Service health donut chart"
|
||||||
|
initial={{ opacity: 0, scale: 0.85 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.5, ease: [0.25, 0.1, 0, 1] }}
|
||||||
>
|
>
|
||||||
{segs.map((s) => {
|
{segs.map((s) => {
|
||||||
if (!s.n) return null;
|
if (!s.n) return null;
|
||||||
const frac = s.n / total;
|
const frac = s.n / total;
|
||||||
const ln = frac * circ;
|
const ln = frac * circ;
|
||||||
const el = (
|
const dashOffset = -off;
|
||||||
<circle
|
off += ln;
|
||||||
|
return (
|
||||||
|
<motion.circle
|
||||||
key={s.l}
|
key={s.l}
|
||||||
cx={cx}
|
cx={cx}
|
||||||
cy={cy}
|
cy={cy}
|
||||||
@@ -42,24 +50,31 @@ export function Donut({
|
|||||||
stroke={s.c}
|
stroke={s.c}
|
||||||
strokeWidth={14}
|
strokeWidth={14}
|
||||||
strokeDasharray={`${ln} ${circ - ln}`}
|
strokeDasharray={`${ln} ${circ - ln}`}
|
||||||
strokeDashoffset={-off}
|
strokeDashoffset={dashOffset}
|
||||||
transform={`rotate(-90 ${cx} ${cy})`}
|
transform={`rotate(-90 ${cx} ${cy})`}
|
||||||
|
initial={{ pathLength: 0 }}
|
||||||
|
animate={{ pathLength: 1 }}
|
||||||
|
transition={{ duration: 1, delay: 0.2, ease: [0.25, 0.1, 0, 1] }}
|
||||||
|
strokeLinecap="round"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
off += ln;
|
|
||||||
return el;
|
|
||||||
})}
|
})}
|
||||||
<text
|
{/* Center total number */}
|
||||||
|
<motion.text
|
||||||
x={cx}
|
x={cx}
|
||||||
y={cy - 4}
|
y={cy - 4}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
fill="#e6edf3"
|
fill="currentColor"
|
||||||
|
className="fill-foreground"
|
||||||
fontSize={26}
|
fontSize={26}
|
||||||
fontWeight={700}
|
fontWeight={700}
|
||||||
fontFamily="system-ui,sans-serif"
|
fontFamily="system-ui,sans-serif"
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.6 }}
|
||||||
>
|
>
|
||||||
{total}
|
{total}
|
||||||
</text>
|
</motion.text>
|
||||||
<text
|
<text
|
||||||
x={cx}
|
x={cx}
|
||||||
y={cy + 14}
|
y={cy + 14}
|
||||||
@@ -68,13 +83,30 @@ export function Donut({
|
|||||||
fontSize={10}
|
fontSize={10}
|
||||||
fontFamily="system-ui,sans-serif"
|
fontFamily="system-ui,sans-serif"
|
||||||
>
|
>
|
||||||
total
|
services
|
||||||
</text>
|
</text>
|
||||||
{segs
|
{segs
|
||||||
.filter((s) => s.n)
|
.filter((s) => s.n)
|
||||||
.map((s, i) => (
|
.map((s, i) => (
|
||||||
<g key={s.l}>
|
<motion.g
|
||||||
<circle cx={16} cy={165 + i * 16} r={4} fill={s.c} />
|
key={s.l}
|
||||||
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.8 + i * 0.15 }}
|
||||||
|
>
|
||||||
|
<motion.circle
|
||||||
|
cx={16}
|
||||||
|
cy={165 + i * 16}
|
||||||
|
r={4}
|
||||||
|
fill={s.c}
|
||||||
|
animate={{ scale: [1, 1.2, 1] }}
|
||||||
|
transition={{
|
||||||
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
duration: 2,
|
||||||
|
delay: i * 0.5,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<text
|
<text
|
||||||
x={26}
|
x={26}
|
||||||
y={168 + i * 16}
|
y={168 + i * 16}
|
||||||
@@ -84,8 +116,8 @@ export function Donut({
|
|||||||
>
|
>
|
||||||
{s.l}: {s.n}
|
{s.l}: {s.n}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</motion.g>
|
||||||
))}
|
))}
|
||||||
</svg>
|
</motion.svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
import { NoData } from "./no-data";
|
import { NoData } from "./no-data";
|
||||||
|
|
||||||
const W = 220;
|
const W = 220;
|
||||||
@@ -22,16 +25,22 @@ export function Gauge({
|
|||||||
const fw = BW * Math.min(pct / 100, 1);
|
const fw = BW * Math.min(pct / 100, 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<motion.svg
|
||||||
width={W}
|
width={W}
|
||||||
height={H}
|
height={H}
|
||||||
viewBox={`0 0 ${W} ${H}`}
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={`${label}: ${pct.toFixed(1)}${unit}`}
|
aria-label={`${label}: ${pct.toFixed(1)}${unit}`}
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4, ease: [0.25, 0.1, 0, 1] }}
|
||||||
>
|
>
|
||||||
|
{/* Track */}
|
||||||
<rect x={BX} y={BY} width={BW} height={BH} rx={7} ry={7} fill="#1c2333" />
|
<rect x={BX} y={BY} width={BW} height={BH} rx={7} ry={7} fill="#1c2333" />
|
||||||
|
|
||||||
|
{/* Filled bar */}
|
||||||
{fw > 0 && (
|
{fw > 0 && (
|
||||||
<rect
|
<motion.rect
|
||||||
x={BX}
|
x={BX}
|
||||||
y={BY}
|
y={BY}
|
||||||
width={fw}
|
width={fw}
|
||||||
@@ -40,9 +49,14 @@ export function Gauge({
|
|||||||
ry={7}
|
ry={7}
|
||||||
fill={color}
|
fill={color}
|
||||||
opacity={0.85}
|
opacity={0.85}
|
||||||
|
initial={{ scaleX: 0, transformOrigin: "left" }}
|
||||||
|
animate={{ scaleX: 1, transformOrigin: "left" }}
|
||||||
|
transition={{ duration: 0.8, delay: 0.15, ease: [0.25, 0.1, 0, 1] }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<text
|
|
||||||
|
{/* Label */}
|
||||||
|
<motion.text
|
||||||
x={W / 2}
|
x={W / 2}
|
||||||
y={14}
|
y={14}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
@@ -50,10 +64,15 @@ export function Gauge({
|
|||||||
fontSize={11}
|
fontSize={11}
|
||||||
fontWeight={600}
|
fontWeight={600}
|
||||||
fill={color}
|
fill={color}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.3 }}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</text>
|
</motion.text>
|
||||||
<text
|
|
||||||
|
{/* Value */}
|
||||||
|
<motion.text
|
||||||
x={W / 2}
|
x={W / 2}
|
||||||
y={BY + BH + 24}
|
y={BY + BH + 24}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
@@ -61,10 +80,13 @@ export function Gauge({
|
|||||||
fontSize={14}
|
fontSize={14}
|
||||||
fontWeight={700}
|
fontWeight={700}
|
||||||
fill="#e6edf3"
|
fill="#e6edf3"
|
||||||
|
initial={{ opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.5 }}
|
||||||
>
|
>
|
||||||
{pct.toFixed(1)}
|
{pct.toFixed(1)}
|
||||||
{unit}
|
{unit}
|
||||||
</text>
|
</motion.text>
|
||||||
</svg>
|
</motion.svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
import { Moon, Sun } from "lucide-react";
|
import { Moon, Sun } from "lucide-react";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import type { DashboardData } from "@/lib/dashboard/types";
|
import type { DashboardData } from "@/lib/dashboard/types";
|
||||||
|
import { springGentle } from "@/lib/motion";
|
||||||
|
|
||||||
export function DashboardHeader() {
|
export function DashboardHeader() {
|
||||||
const [degraded, setDegraded] = useState(0);
|
const [degraded, setDegraded] = useState(0);
|
||||||
@@ -46,49 +48,96 @@ export function DashboardHeader() {
|
|||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const isHealthy = total > 0 && degraded === 0;
|
||||||
|
const hasIssues = degraded > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 flex items-center justify-between gap-2 border-b bg-background/90 px-5 py-3 backdrop-blur-xl">
|
<motion.header
|
||||||
<a href="/" className="flex items-center gap-2.5">
|
initial={{ y: -20, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
transition={{ duration: 0.4, ease: [0.25, 0.1, 0, 1] }}
|
||||||
|
className="sticky top-0 z-50 flex items-center justify-between gap-2 border-b bg-background/80 px-5 py-3 backdrop-blur-2xl supports-backdrop-filter:bg-background/60"
|
||||||
|
>
|
||||||
|
<motion.a
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-2.5"
|
||||||
|
whileHover={{ x: 2 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-foreground text-xs font-bold text-background">
|
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-foreground text-xs font-bold text-background">
|
||||||
AH
|
AH
|
||||||
</span>
|
</span>
|
||||||
<h1 className="text-base font-semibold">Asep Haryana</h1>
|
<h1 className="text-base font-semibold">Asep Haryana</h1>
|
||||||
</a>
|
</motion.a>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<Badge
|
<motion.div
|
||||||
variant="outline"
|
animate={
|
||||||
className={`h-auto gap-1.5 rounded-full px-3 py-1 text-xs font-medium ${
|
hasIssues
|
||||||
degraded > 0
|
? {
|
||||||
? "border-amber-500/30 bg-amber-900/20 text-amber-500"
|
scale: [1, 1.02, 1],
|
||||||
: total > 0
|
transition: { repeat: Number.POSITIVE_INFINITY, duration: 3 },
|
||||||
? "border-emerald-500/30 bg-emerald-900/20 text-emerald-400"
|
}
|
||||||
: ""
|
: {}
|
||||||
}`}
|
}
|
||||||
>
|
>
|
||||||
{total > 0 && (
|
<Badge
|
||||||
<span
|
variant="outline"
|
||||||
className={`h-1.5 w-1.5 rounded-full ${
|
className={`h-auto gap-1.5 rounded-full px-3 py-1 text-xs font-medium ${
|
||||||
degraded > 0 ? "bg-amber-500" : "bg-emerald-400"
|
hasIssues
|
||||||
}`}
|
? "border-amber-500/30 bg-amber-900/20 text-amber-500"
|
||||||
/>
|
: total > 0
|
||||||
)}
|
? "border-emerald-500/30 bg-emerald-900/20 text-emerald-400"
|
||||||
{total === 0
|
: ""
|
||||||
? "No services"
|
}`}
|
||||||
: degraded > 0
|
>
|
||||||
? `${degraded} degraded`
|
{total > 0 && (
|
||||||
: "All Systems Operational"}
|
<motion.span
|
||||||
</Badge>
|
animate={{
|
||||||
<Button
|
scale: [1, 1.3, 1],
|
||||||
variant="ghost"
|
opacity: [0.7, 1, 0.7],
|
||||||
size="icon-xs"
|
}}
|
||||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
transition={{
|
||||||
aria-label="Toggle theme"
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
duration: isHealthy ? 2.5 : 1.5,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
className={`h-1.5 w-1.5 rounded-full ${
|
||||||
|
hasIssues ? "bg-amber-500" : "bg-emerald-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{total === 0
|
||||||
|
? "No services"
|
||||||
|
: hasIssues
|
||||||
|
? `${degraded} degraded`
|
||||||
|
: "All Systems Operational"}
|
||||||
|
</Badge>
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
whileHover={{ rotate: 30 }}
|
||||||
|
whileTap={{ rotate: 60, scale: 0.9 }}
|
||||||
|
transition={springGentle}
|
||||||
>
|
>
|
||||||
<Sun className="hidden dark:block!" />
|
<Button
|
||||||
<Moon className="block dark:hidden!" />
|
variant="ghost"
|
||||||
</Button>
|
size="icon-xs"
|
||||||
<span className="text-[10px] text-muted-foreground">{time}</span>
|
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
>
|
||||||
|
<Sun className="hidden dark:block!" />
|
||||||
|
<Moon className="block dark:hidden!" />
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
<motion.span
|
||||||
|
key={time}
|
||||||
|
initial={{ opacity: 0.6, y: -2 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="text-[10px] tabular-nums text-muted-foreground"
|
||||||
|
>
|
||||||
|
{time}
|
||||||
|
</motion.span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</motion.header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
export function NoData() {
|
export function NoData() {
|
||||||
return (
|
return (
|
||||||
<svg
|
<motion.svg
|
||||||
width={200}
|
width={200}
|
||||||
height={100}
|
height={100}
|
||||||
viewBox="0 0 200 100"
|
viewBox="0 0 200 100"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="No data available"
|
aria-label="No data available"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
>
|
>
|
||||||
<text
|
<motion.text
|
||||||
x={100}
|
x={100}
|
||||||
y={50}
|
y={50}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
fill="#6e7681"
|
fill="#6e7681"
|
||||||
fontSize={12}
|
fontSize={12}
|
||||||
fontFamily="system-ui,sans-serif"
|
fontFamily="system-ui,sans-serif"
|
||||||
|
initial={{ opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
>
|
>
|
||||||
No data
|
No data
|
||||||
</text>
|
</motion.text>
|
||||||
</svg>
|
</motion.svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
import { NoData } from "./no-data";
|
import { NoData } from "./no-data";
|
||||||
|
|
||||||
const W = 300;
|
const W = 300;
|
||||||
@@ -73,25 +76,59 @@ export function Sparkline({ data, color }: { data: number[]; color: string }) {
|
|||||||
const area = `M${PL},${PT + VH} L${pts} L${PL + VW},${PT + VH} Z`;
|
const area = `M${PL},${PT + VH} L${pts} L${PL + VW},${PT + VH} Z`;
|
||||||
const lv = data[data.length - 1];
|
const lv = data[data.length - 1];
|
||||||
const ly = PT + VH * (1 - lv / maxV);
|
const ly = PT + VH * (1 - lv / maxV);
|
||||||
|
const d = `M${pts}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<motion.svg
|
||||||
width={W}
|
width={W}
|
||||||
height={H}
|
height={H}
|
||||||
viewBox={`0 0 ${W} ${H}`}
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Sparkline chart"
|
aria-label="Sparkline chart"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
>
|
>
|
||||||
{gridLines(maxV)}
|
{gridLines(maxV)}
|
||||||
<path d={area} fill={color} opacity={0.15} />
|
|
||||||
<polyline
|
{/* Area fill */}
|
||||||
points={pts}
|
<motion.path
|
||||||
fill="none"
|
d={area}
|
||||||
stroke={color}
|
fill={color}
|
||||||
strokeWidth={2}
|
opacity={0.15}
|
||||||
strokeLinejoin="round"
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 0.15 }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.1 }}
|
||||||
/>
|
/>
|
||||||
<text
|
|
||||||
|
{/* Animated line path */}
|
||||||
|
{data.length > 1 && (
|
||||||
|
<motion.path
|
||||||
|
d={d}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
initial={{ pathLength: 0 }}
|
||||||
|
animate={{ pathLength: 1 }}
|
||||||
|
transition={{ duration: 1.2, ease: [0.25, 0.1, 0, 1] }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Endpoint dot */}
|
||||||
|
<motion.circle
|
||||||
|
cx={PL + VW}
|
||||||
|
cy={ly}
|
||||||
|
r={3}
|
||||||
|
fill={color}
|
||||||
|
initial={{ scale: 0, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
transition={{ duration: 0.3, delay: 1.0 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Endpoint value label */}
|
||||||
|
<motion.text
|
||||||
x={PL + VW}
|
x={PL + VW}
|
||||||
y={ly - 10}
|
y={ly - 10}
|
||||||
textAnchor="end"
|
textAnchor="end"
|
||||||
@@ -99,10 +136,14 @@ export function Sparkline({ data, color }: { data: number[]; color: string }) {
|
|||||||
fontSize={11}
|
fontSize={11}
|
||||||
fontWeight={600}
|
fontWeight={600}
|
||||||
fontFamily="system-ui,sans-serif"
|
fontFamily="system-ui,sans-serif"
|
||||||
|
initial={{ opacity: 0, y: 4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: 1.1 }}
|
||||||
>
|
>
|
||||||
{lv.toFixed(1)}
|
{lv.toFixed(1)}
|
||||||
</text>
|
</motion.text>
|
||||||
|
|
||||||
{xAxisLabels(data.length)}
|
{xAxisLabels(data.length)}
|
||||||
</svg>
|
</motion.svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all duration-300 ease-out focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ const buttonVariants = cva(
|
|||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
default:
|
||||||
|
"bg-primary text-primary-foreground hover:bg-primary/80 shadow-xs hover:shadow-sm hover:shadow-primary/20",
|
||||||
outline:
|
outline:
|
||||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
secondary:
|
secondary:
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function Card({
|
|||||||
data-slot="card"
|
data-slot="card"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl border border-border bg-card py-(--card-spacing) text-sm text-card-foreground [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl border border-border bg-card py-(--card-spacing) text-sm text-card-foreground shadow-xs transition-all duration-300 ease-out hover:shadow-md hover:shadow-border/30 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type HTMLMotionProps, motion, type Variants } from "framer-motion";
|
||||||
|
import type React from "react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { fadeInUp, scaleInSpring, stagger } from "@/lib/motion";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
// ─── Viewport-based reveal wrapper ───────────────────────────
|
||||||
|
|
||||||
|
type RevealProps = HTMLMotionProps<"div"> & {
|
||||||
|
variants?: Variants;
|
||||||
|
once?: boolean;
|
||||||
|
amount?: number | "some" | "all";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps children in a fade-in-up animation triggered when they enter the viewport.
|
||||||
|
* Use `asChild` composition or nest directly — children receive no special bindings.
|
||||||
|
*/
|
||||||
|
export function Reveal({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
variants = fadeInUp,
|
||||||
|
once = true,
|
||||||
|
amount = 0.2,
|
||||||
|
...props
|
||||||
|
}: RevealProps) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once, amount }}
|
||||||
|
variants={variants}
|
||||||
|
className={className}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Stagger container ───────────────────────────────────────
|
||||||
|
|
||||||
|
type StaggerContainerProps = HTMLMotionProps<"div"> & {
|
||||||
|
staggerVariants?: Variants;
|
||||||
|
once?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parent container that staggers child `motion` elements.
|
||||||
|
* Children should have matching `variants` (e.g. `fadeInUp`).
|
||||||
|
*/
|
||||||
|
export function StaggerContainer({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
staggerVariants = stagger,
|
||||||
|
once = true,
|
||||||
|
...props
|
||||||
|
}: StaggerContainerProps) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once, amount: 0.1 }}
|
||||||
|
variants={staggerVariants}
|
||||||
|
className={className}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Single-item child with fadeInUp ─────────────────────────
|
||||||
|
|
||||||
|
type MotionItemProps = HTMLMotionProps<"div"> & { index?: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single motion item meant to be used as a child of StaggerContainer.
|
||||||
|
* Wraps content in a `motion.div` with fadeInUp variants.
|
||||||
|
*/
|
||||||
|
export function MotionItem({ children, className, ...props }: MotionItemProps) {
|
||||||
|
return (
|
||||||
|
<motion.div variants={fadeInUp} className={className} {...props}>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Scale reveal (for cards, dialogs) ───────────────────────
|
||||||
|
|
||||||
|
export function ScaleReveal({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: HTMLMotionProps<"div">) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once: true, amount: 0.15 }}
|
||||||
|
variants={scaleInSpring}
|
||||||
|
className={className}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Animated presence wrapper ───────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fades + scales content in/out for mount/unmount transitions.
|
||||||
|
*/
|
||||||
|
export function AnimatedPresence({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.96 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.96 }}
|
||||||
|
transition={{ duration: 0.25, ease: [0.25, 0.1, 0, 1] as const }}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Animated number counter ─────────────────────────────────
|
||||||
|
|
||||||
|
type AnimatedNumberProps = {
|
||||||
|
value: number;
|
||||||
|
decimals?: number;
|
||||||
|
duration?: number;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animates from 0 to `value` on mount and when `value` changes.
|
||||||
|
*/
|
||||||
|
export function AnimatedNumber({
|
||||||
|
value,
|
||||||
|
decimals = 0,
|
||||||
|
duration: dur = 0.6,
|
||||||
|
className,
|
||||||
|
}: AnimatedNumberProps) {
|
||||||
|
return (
|
||||||
|
<motion.span
|
||||||
|
key={value}
|
||||||
|
initial={{ opacity: 0, y: 12, filter: "blur(4px)" }}
|
||||||
|
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
||||||
|
transition={{ duration: dur, ease: [0.25, 0.1, 0, 1] as const }}
|
||||||
|
className={cn("inline-block tabular-nums", className)}
|
||||||
|
>
|
||||||
|
{value.toFixed(decimals)}
|
||||||
|
</motion.span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Animated text reveal (letter by letter) ─────────────────
|
||||||
|
|
||||||
|
type TypewriterProps = {
|
||||||
|
text: string;
|
||||||
|
className?: string;
|
||||||
|
speed?: number;
|
||||||
|
/** Delay in ms before the first character appears */
|
||||||
|
startDelay?: number;
|
||||||
|
/** When true, shows a blinking cursor block while typing */
|
||||||
|
cursor?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reveals text one character at a time using progressive `setTimeout`.
|
||||||
|
* Characters are appended to a single text node so CSS gradient
|
||||||
|
* (`background-clip: text`) works seamlessly across the full string.
|
||||||
|
*/
|
||||||
|
export function Typewriter({
|
||||||
|
text,
|
||||||
|
className,
|
||||||
|
speed = 0.03,
|
||||||
|
startDelay = 250,
|
||||||
|
cursor: showCursor = true,
|
||||||
|
}: TypewriterProps) {
|
||||||
|
const [displayed, setDisplayed] = useState("");
|
||||||
|
const done = displayed.length >= text.length;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (done) return;
|
||||||
|
const delay =
|
||||||
|
displayed.length === 0 ? startDelay : Math.round(speed * 1000);
|
||||||
|
const timer = setTimeout(
|
||||||
|
() => setDisplayed(text.slice(0, displayed.length + 1)),
|
||||||
|
delay,
|
||||||
|
);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [displayed, text, speed, startDelay, done]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={className}>
|
||||||
|
{displayed}
|
||||||
|
{showCursor && !done && (
|
||||||
|
<span className="inline-block size-2.5 bg-primary ml-0.5 rounded-none align-text-bottom animate-pulse" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Blur-in reveal ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export function BlurReveal({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
delay = 0,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
delay?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, filter: "blur(8px)" }}
|
||||||
|
whileInView={{ opacity: 1, filter: "blur(0px)" }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6, delay, ease: [0.25, 0.1, 0, 1] as const }}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Interactive card wrapper ────────────────────────────────
|
||||||
|
|
||||||
|
type InteractiveCardProps = HTMLMotionProps<"div"> & {
|
||||||
|
hoverScale?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card wrapper with subtle hover lift and glow.
|
||||||
|
*/
|
||||||
|
export function InteractiveCard({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
hoverScale = 1.01,
|
||||||
|
...props
|
||||||
|
}: InteractiveCardProps) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
whileHover={{
|
||||||
|
y: -3,
|
||||||
|
scale: hoverScale,
|
||||||
|
transition: { duration: 0.2, ease: "easeOut" },
|
||||||
|
}}
|
||||||
|
whileTap={{ y: 0, scale: 0.99 }}
|
||||||
|
className={cn("cursor-default transition-shadow duration-300", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,10 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="skeleton"
|
data-slot="skeleton"
|
||||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
className={cn(
|
||||||
|
"animate-pulse rounded-md bg-muted/60 bg-gradient-to-r from-muted/60 via-muted/80 to-muted/60 bg-[length:200%_100%] animate-shimmer",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { Loader2Icon } from "lucide-react";
|
import { Loader2Icon } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -6,7 +8,7 @@ function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
|||||||
<Loader2Icon
|
<Loader2Icon
|
||||||
data-slot="spinner"
|
data-slot="spinner"
|
||||||
aria-label="Loading"
|
aria-label="Loading"
|
||||||
className={cn("size-4 animate-spin", className)}
|
className={cn("size-4 animate-spin text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* Reusable Framer Motion animation variants and utility constants
|
||||||
|
* for consistent, performant animations across the hub.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── DOM variants ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const fadeIn = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: { opacity: 1, transition: { duration: 0.5, ease: "easeOut" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fadeInUp = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: { duration: 0.5, ease: [0.25, 0.1, 0, 1] as const },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fadeInDown = {
|
||||||
|
hidden: { opacity: 0, y: -16 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: { duration: 0.4, ease: [0.25, 0.1, 0, 1] as const },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fadeInLeft = {
|
||||||
|
hidden: { opacity: 0, x: -20 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
x: 0,
|
||||||
|
transition: { duration: 0.5, ease: [0.25, 0.1, 0, 1] as const },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fadeInRight = {
|
||||||
|
hidden: { opacity: 0, x: 20 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
x: 0,
|
||||||
|
transition: { duration: 0.5, ease: [0.25, 0.1, 0, 1] as const },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const scaleIn = {
|
||||||
|
hidden: { opacity: 0, scale: 0.9 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1,
|
||||||
|
transition: { duration: 0.4, ease: [0.25, 0.1, 0, 1] as const },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const scaleInSpring = {
|
||||||
|
hidden: { opacity: 0, scale: 0.85 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1,
|
||||||
|
transition: {
|
||||||
|
type: "spring" as const,
|
||||||
|
stiffness: 200,
|
||||||
|
damping: 20,
|
||||||
|
mass: 0.8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Stagger container ───────────────────────────────────────
|
||||||
|
|
||||||
|
export const stagger = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.06,
|
||||||
|
delayChildren: 0.1,
|
||||||
|
ease: [0.25, 0.1, 0, 1] as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const staggerFast = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.04,
|
||||||
|
delayChildren: 0.05,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const staggerSlow = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.1,
|
||||||
|
delayChildren: 0.15,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── SVG / Path animation ────────────────────────────────────
|
||||||
|
|
||||||
|
export const drawPath = {
|
||||||
|
hidden: { pathLength: 0, opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
pathLength: 1,
|
||||||
|
opacity: 1,
|
||||||
|
transition: { duration: 1.2, ease: [0.25, 0.1, 0, 1] as const },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const growWidth = {
|
||||||
|
hidden: { scaleX: 0, transformOrigin: "left" },
|
||||||
|
visible: {
|
||||||
|
scaleX: 1,
|
||||||
|
transformOrigin: "left",
|
||||||
|
transition: { duration: 0.8, ease: [0.25, 0.1, 0, 1] as const },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Hover / Tap presets ─────────────────────────────────────
|
||||||
|
|
||||||
|
export const hoverLift = {
|
||||||
|
whileHover: { y: -2, transition: { duration: 0.2 } },
|
||||||
|
whileTap: { y: 0, transition: { duration: 0.1 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hoverGlow = {
|
||||||
|
whileHover: {
|
||||||
|
boxShadow: "0 0 20px oklch(0.7 0.15 75 / 0.25)",
|
||||||
|
transition: { duration: 0.25 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const tapScale = {
|
||||||
|
whileTap: { scale: 0.97, transition: { duration: 0.1 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Counter / number animation ──────────────────────────────
|
||||||
|
|
||||||
|
export const counterProps = {
|
||||||
|
initial: { opacity: 0, y: 8 },
|
||||||
|
animate: { opacity: 1, y: 0 },
|
||||||
|
transition: { duration: 0.4, ease: [0.25, 0.1, 0, 1] as const },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Duration presets ────────────────────────────────────────
|
||||||
|
|
||||||
|
export const duration = {
|
||||||
|
fast: 0.2,
|
||||||
|
normal: 0.4,
|
||||||
|
slow: 0.6,
|
||||||
|
slower: 0.8,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ─── Transition presets ──────────────────────────────────────
|
||||||
|
|
||||||
|
export const spring = {
|
||||||
|
type: "spring" as const,
|
||||||
|
stiffness: 200,
|
||||||
|
damping: 20,
|
||||||
|
mass: 0.8,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const springBouncy = {
|
||||||
|
type: "spring" as const,
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 15,
|
||||||
|
mass: 0.7,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const springGentle = {
|
||||||
|
type: "spring" as const,
|
||||||
|
stiffness: 120,
|
||||||
|
damping: 14,
|
||||||
|
mass: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const smoothEase = {
|
||||||
|
ease: [0.25, 0.1, 0, 1] as [number, number, number, number],
|
||||||
|
duration: 0.5,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user