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:
asepharyana
2026-07-23 17:55:57 +07:00
parent e528b6a726
commit 22cdc2e84b
17 changed files with 1500 additions and 486 deletions
+302 -208
View File
@@ -1,11 +1,13 @@
"use client";
import { motion } from "framer-motion";
import { useEffect, useState } from "react";
import { Donut } from "@/components/dashboard/donut";
import { Gauge } from "@/components/dashboard/gauge";
import { Sparkline } from "@/components/dashboard/sparkline";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AnimatedNumber } from "@/components/ui/motion-primitives";
import {
type DashboardData,
gaugeColor,
@@ -13,6 +15,27 @@ import {
serviceIndicator,
} 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() {
const [data, setData] = useState<DashboardData | null>(null);
@@ -20,7 +43,9 @@ export default function Dashboard() {
const fetchData = async () => {
try {
const res = await fetch("/api/dashboard");
if (res.ok) setData(await res.json());
if (res.ok) {
setData(await res.json());
}
} catch {
/* ignore */
}
@@ -40,240 +65,301 @@ export default function Dashboard() {
return (
<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 */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<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>
<motion.div variants={cardVariants}>
<Card className="h-full 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)}
<CardTitle>Services</CardTitle>
<Badge variant="secondary" className="text-[10px] font-normal">
{data?.services.length ?? 0}
</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="%"
{data?.services.length ? (
<div className="flex flex-wrap gap-1">
{data.services.map((s, i) => (
<motion.div
key={s.name}
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
pct={node?.ram ?? null}
color={gaugeColor(node?.ram ?? null)}
label="Memory Usage"
unit="%"
<StatBox
value={running}
label="Healthy"
color="text-green-400"
/>
<Gauge
pct={node?.disk ?? null}
color={gaugeColor(node?.disk ?? null)}
label="Disk Usage"
unit="%"
<StatBox
value={data?.traces.length ?? 0}
label="Traces"
color="text-blue-400"
/>
<StatBox value={0} label="Errors" color="text-red-400" />
</div>
</CardContent>
</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 */}
{hasTraffik && (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Request Rate</CardTitle>
<Badge variant="secondary" className="text-[10px] font-normal">
{data?.rps?.length
? `${data.rps[data.rps.length - 1].toFixed(1)}/s`
: "-"}
</Badge>
</CardHeader>
<CardContent className="flex justify-center">
<Sparkline data={data?.rps ?? []} color="#58a6ff" />
</CardContent>
</Card>
<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>Request Rate</CardTitle>
<Badge variant="secondary" className="text-[10px] font-normal">
{data?.rps?.length
? `${data.rps[data.rps.length - 1].toFixed(1)}/s`
: "-"}
</Badge>
</CardHeader>
<CardContent className="flex justify-center">
<Sparkline data={data?.rps ?? []} color="#58a6ff" />
</CardContent>
</Card>
</motion.div>
)}
{/* Latency */}
{hasTraffik && (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Latency</CardTitle>
<Badge variant="secondary" className="text-[10px] font-normal">
{data?.latency?.length
? `${data.latency[data.latency.length - 1].toFixed(0)}ms`
: "-"}
</Badge>
</CardHeader>
<CardContent className="flex justify-center">
<Sparkline data={data?.latency ?? []} color="#bc8cff" />
</CardContent>
</Card>
<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>Latency</CardTitle>
<Badge variant="secondary" className="text-[10px] font-normal">
{data?.latency?.length
? `${data.latency[data.latency.length - 1].toFixed(0)}ms`
: "-"}
</Badge>
</CardHeader>
<CardContent className="flex justify-center">
<Sparkline data={data?.latency ?? []} color="#bc8cff" />
</CardContent>
</Card>
</motion.div>
)}
{/* Error Rate */}
{hasTraffik && (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Error Rate</CardTitle>
<Badge variant="secondary" className="text-[10px] font-normal">
{data?.errors?.length
? `${data.errors[data.errors.length - 1].toFixed(1)}/s`
: "-"}
</Badge>
</CardHeader>
<CardContent className="flex justify-center">
<Sparkline data={data?.errors ?? []} color="#f85149" />
</CardContent>
</Card>
<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>Error Rate</CardTitle>
<Badge variant="secondary" className="text-[10px] font-normal">
{data?.errors?.length
? `${data.errors[data.errors.length - 1].toFixed(1)}/s`
: "-"}
</Badge>
</CardHeader>
<CardContent className="flex justify-center">
<Sparkline data={data?.errors ?? []} color="#f85149" />
</CardContent>
</Card>
</motion.div>
)}
{/* Trace Volume */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Trace Volume</CardTitle>
<Badge variant="secondary" className="text-[10px] font-normal">
{data?.traces.length ?? 0} traces
</Badge>
</CardHeader>
<CardContent className="flex justify-center">
<Sparkline data={data?.traceVolume ?? []} color="#3fb950" />
</CardContent>
</Card>
<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>Trace Volume</CardTitle>
<Badge variant="secondary" className="text-[10px] font-normal">
{data?.traces.length ?? 0} traces
</Badge>
</CardHeader>
<CardContent className="flex justify-center">
<Sparkline data={data?.traceVolume ?? []} color="#3fb950" />
</CardContent>
</Card>
</motion.div>
{/* Recent Traces */}
<Card className="2xl:col-span-2">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Recent Traces</CardTitle>
<Badge variant="secondary" className="text-[10px] font-normal">
{data?.traces.length ?? 0}
</Badge>
</CardHeader>
<CardContent>
{data?.traces.length ? (
<ul className="list-none">
{data.traces.map((t, i) => (
<li
key={`${t.service}-${t.operation}-${i}`}
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}
<motion.div variants={cardVariants} className="2xl:col-span-2">
<Card className="transition-shadow duration-300 hover:shadow-md hover:shadow-border/20">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Recent Traces</CardTitle>
<Badge variant="secondary" className="text-[10px] font-normal">
{data?.traces.length ?? 0}
</Badge>
</CardHeader>
<CardContent>
{data?.traces.length ? (
<ul className="list-none">
{data.traces.map((t, i) => (
<motion.li
// biome-ignore lint/suspicious/noArrayIndexKey: trace data has no unique id
key={`${t.service}-${t.operation}-${i}`}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: 0.1 + i * 0.04 }}
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 className="flex shrink-0 items-center gap-2 text-[10px] text-muted-foreground">
<span className="font-mono font-medium text-blue-400">
{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
<div className="flex shrink-0 items-center gap-2 text-[10px] text-muted-foreground">
<span className="font-mono font-medium text-blue-400">
{safeDur(t.duration)}
</span>
)}
</div>
</li>
))}
</ul>
) : (
<p className="py-4 text-center text-[11px] text-muted-foreground">
No traces &mdash; data appears once services send OTel telemetry
</p>
)}
</CardContent>
</Card>
</div>
<span>{t.spans}</span>
{t.hasError && (
<motion.span
className="rounded-sm bg-red-500/10 px-1.5 py-0.5 text-[9px] text-red-500"
animate={{ opacity: [0.7, 1, 0.7] }}
transition={{
repeat: Number.POSITIVE_INFINITY,
duration: 2,
}}
>
err
</motion.span>
)}
</div>
</motion.li>
))}
</ul>
) : (
<p className="py-4 text-center text-[11px] text-muted-foreground">
No traces &mdash; data appears once services send OTel
telemetry
</p>
)}
</CardContent>
</Card>
</motion.div>
</motion.div>
</div>
);
}
@@ -288,13 +374,21 @@ function StatBox({
color: string;
}) {
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}`}>
{value ?? "-"}
{typeof value === "number" ? (
<AnimatedNumber value={value} />
) : (
(value ?? "-")
)}
</div>
<p className="mt-0.5 text-[10px] uppercase tracking-wider text-muted-foreground">
{label}
</p>
</div>
</motion.div>
);
}
+158 -2
View File
@@ -46,6 +46,75 @@
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--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 {
@@ -125,11 +194,65 @@
@apply bg-background text-foreground;
}
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 {
@apply ring-[3px];
outline: none;
@@ -141,3 +264,36 @@ html:not(.dark) *:focus-visible {
@apply ring-[3px];
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
View File
@@ -1,3 +1,6 @@
"use client";
import { motion } from "framer-motion";
import {
ArrowRight,
Container,
@@ -13,6 +16,13 @@ import {
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
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";
// 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() {
return (
<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">
<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">
<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
</p>
<h1 className="font-mono text-3xl font-bold tracking-tight sm:text-5xl">
<span className="text-muted-foreground">$ </span>
Asep Haryana Saputra
<span className="inline-block size-2.5 animate-pulse bg-foreground ml-1 rounded-none" />
</h1>
<p className="mx-auto mt-3 max-w-lg font-mono text-xs text-muted-foreground sm:text-sm">
</motion.p>
<motion.h1
className="font-mono text-3xl font-bold tracking-tight sm:text-5xl"
initial={{ y: 16 }}
animate={{ y: 0 }}
transition={{ duration: 0.5, delay: 0.2, ease: [0.25, 0.1, 0, 1] as const }}
>
<span className="text-muted-foreground">$&nbsp;</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>
Teknik Informatika student exploring backend systems, distributed
infrastructure, and DevOps still learning, still building.
</p>
</motion.p>
</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
<ArrowRight className="size-4" />
</a>
<a
<motion.span
animate={{ x: [0, 4, 0] }}
transition={{ repeat: Number.POSITIVE_INFINITY, duration: 2 }}
>
<ArrowRight className="size-4" />
</motion.span>
</motion.a>
<motion.a
href="https://github.com/asepharyana"
target="_blank"
rel="noopener noreferrer"
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
>
GitHub
<ExternalLink className="size-4" />
</a>
</div>
</motion.a>
</motion.div>
</section>
{/* Content — staggered sections with alternating visual weight */}
<section className="mx-auto flex w-full max-w-3xl flex-col gap-12 px-6 pb-28">
{/* About — minimal, just text */}
<div>
<p className="mb-2 font-mono text-xs text-muted-foreground">
<span className="text-primary dark:text-primary">~</span> about
</p>
<div className="space-y-3 leading-relaxed text-muted-foreground">
<p>
I&rsquo;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 &amp; Traefik, and exploring observability with
OpenTelemetry &amp; Jaeger.
{/* ── Content — staggered sections ── */}
<div className="mx-auto flex w-full max-w-3xl flex-col gap-12 px-6 pb-28">
{/* About */}
<Reveal>
<div>
<p className="mb-2 font-mono text-xs text-muted-foreground">
<span className="text-primary dark:text-primary">~</span> about
</p>
<p>
I was the Back-End lead on the ZeaVis Edu capstone team (&ldquo;AI
for Smart Education&rdquo; by Pijak &times; IBM SkillsBuild), a
5-person team with task division across Back-End, Front-End, and
Machine Learning. I handled the system architecture, API design,
RESTful API development, and Docker deployment on VPS.
</p>
</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 &times; IBM SkillsBuild program.
<div className="space-y-3 leading-relaxed text-muted-foreground">
<p>
I&rsquo;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 &amp; Traefik, and exploring observability with
OpenTelemetry &amp; Jaeger.
</p>
<p>
I contributed to the ZeaVis Edu capstone team (&ldquo;AI
for Smart Education&rdquo; by Pijak &times; 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">
<div className="rounded-lg border border-border bg-muted/30 p-3">
<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 &mdash; Blight, Rust, Leaf Spot,
or Healthy
</li>
<li>Get treatment &amp; prevention recommendations</li>
</ul>
</div>
<div className="rounded-lg border border-border bg-muted/30 p-3">
<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 &amp; API design</li>
<li>RESTful API (Bun + Elysia + Drizzle + PostgreSQL)</li>
<li>Secure upload handling</li>
<li>
Docker deployment on VPS with Traefik &amp; Tailscale
</li>
</ul>
</div>
</div>
<div 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) => (
<Badge
key={t}
variant="secondary"
className="text-[10px] font-normal"
{/* Skills & Tools */}
<Reveal>
<div>
<p className="mb-2 font-mono text-xs text-muted-foreground">
<span className="text-primary dark:text-primary">~</span> skills
</p>
<StaggerContainer className="mb-4 grid grid-cols-1 gap-2 sm:grid-cols-2">
{skills.map((s) => {
const Icon = s.icon;
return (
<MotionItem key={s.label}>
<motion.div
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"
whileHover={{
y: -2,
borderColor: "var(--ring)",
boxShadow: "0 4px 14px oklch(0 0 0 / 0.08)",
}}
>
<Icon className="size-4 shrink-0 text-primary dark:text-primary" />
<span>{s.label}</span>
</motion.div>
</MotionItem>
);
})}
</StaggerContainer>
<StaggerContainer staggerVariants={{ hidden: { opacity: 0 }, visible: { opacity: 1, transition: { staggerChildren: 0.03, delayChildren: 0.1 } } }} className="flex flex-wrap gap-1.5">
{techTags.map((t) => (
<MotionItem key={t}>
<motion.span
whileHover={{ scale: 1.05, y: -1 }}
whileTap={{ scale: 0.95 }}
>
{t}
</Badge>
))}
</div>
</CardContent>
</Card>
</div>
<Badge
variant="outline"
className="text-[11px] font-normal transition-all duration-300 hover:border-primary/30 hover:bg-primary/5"
>
{t}
</Badge>
</motion.span>
</MotionItem>
))}
</StaggerContainer>
</div>
</Reveal>
{/* Infrastructure CTA — clean card */}
<Card className="text-center">
<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.
{/* Featured Project */}
<Reveal>
<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>
<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>
<a href="/dashboard" className={cn(buttonVariants())}>
Live Dashboard
<ArrowRight className="size-4" />
</a>
</CardContent>
</Card>
</section>
<ScaleReveal>
<motion.div
whileHover={{ y: -4 }}
transition={{ duration: 0.25, ease: "easeOut" }}
>
<Card className="relative overflow-hidden border-primary/10 transition-all duration-300 hover:border-primary/30 hover:shadow-lg hover:shadow-primary/5">
{/* Gradient border accent */}
<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" />
<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">
<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 &times; 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 &mdash; Blight, Rust, Leaf Spot,
or Healthy
</li>
<li>Get treatment &amp; 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 &amp; API design</li>
<li>RESTful API (Bun + Elysia + Drizzle + PostgreSQL)</li>
<li>Secure upload handling</li>
<li>
Docker deployment on VPS with Traefik &amp; 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 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>
&copy; {new Date().getFullYear()} Asep Haryana Saputra &mdash;{" "}
<a
<motion.a
href="https://github.com/asepharyana"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-foreground"
whileHover={{ color: "var(--primary)" }}
>
GitHub
</a>
</motion.a>
</p>
</footer>
</motion.footer>
</div>
);
}