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
+7
View File
@@ -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=="],
+1
View File
@@ -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
View File
@@ -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 &mdash; 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 &mdash; 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
View File
@@ -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
View File
@@ -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">$&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> <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&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>
<p> <div className="space-y-3 leading-relaxed text-muted-foreground">
I was the Back-End lead on the ZeaVis Edu capstone team (&ldquo;AI <p>
for Smart Education&rdquo; by Pijak &times; IBM SkillsBuild), a I&rsquo;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 &amp; Traefik, and exploring observability with
</p> OpenTelemetry &amp; 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 &times; IBM SkillsBuild program.
</p> </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"> {/* 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 &mdash; Blight, Rust, Leaf Spot, const Icon = s.icon;
or Healthy return (
</li> <MotionItem key={s.label}>
<li>Get treatment &amp; 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 &amp; 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 &amp; 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 &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 */}
<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>
&copy; {new Date().getFullYear()} Asep Haryana Saputra &mdash;{" "} &copy; {new Date().getFullYear()} Asep Haryana Saputra &mdash;{" "}
<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>
); );
} }
+46 -14
View File
@@ -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>
); );
} }
+29 -7
View File
@@ -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>
); );
} }
+84 -35
View File
@@ -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>
); );
} }
+14 -4
View File
@@ -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>
); );
} }
+52 -11
View File
@@ -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>
); );
} }
+1 -1
View File
@@ -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: {
+2 -1
View File
@@ -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:
+1 -1
View File
@@ -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}
+270
View File
@@ -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 -1
View File
@@ -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}
/> />
); );
+3 -1
View File
@@ -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}
/> />
); );
+189
View File
@@ -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,
};