From 22cdc2e84bc0d41a57d191c8cad0d43ccad57043 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Thu, 23 Jul 2026 17:55:57 +0700 Subject: [PATCH] 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. --- bun.lock | 7 + package.json | 1 + src/app/dashboard/page.tsx | 510 +++++++++++++--------- src/app/globals.css | 160 ++++++- src/app/page.tsx | 537 +++++++++++++++--------- src/components/dashboard/donut.tsx | 60 ++- src/components/dashboard/gauge.tsx | 36 +- src/components/dashboard/header.tsx | 119 ++++-- src/components/dashboard/no-data.tsx | 18 +- src/components/dashboard/sparkline.tsx | 63 ++- src/components/ui/badge.tsx | 2 +- src/components/ui/button.tsx | 3 +- src/components/ui/card.tsx | 2 +- src/components/ui/motion-primitives.tsx | 270 ++++++++++++ src/components/ui/skeleton.tsx | 5 +- src/components/ui/spinner.tsx | 4 +- src/lib/motion.ts | 189 +++++++++ 17 files changed, 1500 insertions(+), 486 deletions(-) create mode 100644 src/components/ui/motion-primitives.tsx create mode 100644 src/lib/motion.ts diff --git a/bun.lock b/bun.lock index 50d3a88..53e0378 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "cmdk": "^1.1.1", "date-fns": "^4.4.0", "embla-carousel-react": "^8.6.0", + "framer-motion": "^12.42.2", "input-otp": "^1.4.2", "lucide-react": "^1.26.0", "next": "16.2.11", @@ -542,6 +543,8 @@ "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=="], "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=="], + "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=="], "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], diff --git a/package.json b/package.json index edabb93..385321e 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "cmdk": "^1.1.1", "date-fns": "^4.4.0", "embla-carousel-react": "^8.6.0", + "framer-motion": "^12.42.2", "input-otp": "^1.4.2", "lucide-react": "^1.26.0", "next": "16.2.11", diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index ed5b68d..848e9a3 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -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(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 (
-
+ {/* Services */} - - - Services - - {data?.services.length ?? 0} - - - - {data?.services.length ? ( -
- {data.services.map((s) => ( - - - {s.name} - - ))} -
- ) : ( -

- No services detected -

- )} -
-
- - {/* Overview */} - - - Overview - - -
- - - - -
-
-
- - {/* Health */} - - - Health - - - - - - - {/* Links */} - - - Links - - -
- {data?.links.map((l) => ( - - {l.label} - - ))} -
-
-
- - {/* System Resources */} - {hasNode && ( - + + - System Resources - - {node?.load1?.toFixed(2)} {node?.load5?.toFixed(2)}{" "} - {node?.load15?.toFixed(2)} + Services + + {data?.services.length ?? 0} -
- + {data.services.map((s, i) => ( + + + + {s.name} + + + ))} +
+ ) : ( +

+ No services detected +

+ )} +
+
+
+ + {/* Overview */} + + + + Overview + + +
+ - - +
+
+ + {/* Health */} + + + + Health + + + + + + + + {/* Links */} + + + + Links + + +
+ {data?.links.map((l) => ( + + {l.label} + + ))} +
+
+
+
+ + {/* System Resources */} + {hasNode && ( + + + + System Resources + + {node?.load1?.toFixed(2)} {node?.load5?.toFixed(2)}{" "} + {node?.load15?.toFixed(2)} + + + +
+ + + +
+
+
+
)} {/* Request Rate */} {hasTraffik && ( - - - Request Rate - - {data?.rps?.length - ? `${data.rps[data.rps.length - 1].toFixed(1)}/s` - : "-"} - - - - - - + + + + Request Rate + + {data?.rps?.length + ? `${data.rps[data.rps.length - 1].toFixed(1)}/s` + : "-"} + + + + + + + )} {/* Latency */} {hasTraffik && ( - - - Latency - - {data?.latency?.length - ? `${data.latency[data.latency.length - 1].toFixed(0)}ms` - : "-"} - - - - - - + + + + Latency + + {data?.latency?.length + ? `${data.latency[data.latency.length - 1].toFixed(0)}ms` + : "-"} + + + + + + + )} {/* Error Rate */} {hasTraffik && ( - - - Error Rate - - {data?.errors?.length - ? `${data.errors[data.errors.length - 1].toFixed(1)}/s` - : "-"} - - - - - - + + + + Error Rate + + {data?.errors?.length + ? `${data.errors[data.errors.length - 1].toFixed(1)}/s` + : "-"} + + + + + + + )} {/* Trace Volume */} - - - Trace Volume - - {data?.traces.length ?? 0} traces - - - - - - + + + + Trace Volume + + {data?.traces.length ?? 0} traces + + + + + + + {/* Recent Traces */} - - - Recent Traces - - {data?.traces.length ?? 0} - - - - {data?.traces.length ? ( -
    - {data.traces.map((t, i) => ( -
  • -
    -
    {t.service}
    -
    - {t.operation} + + + + Recent Traces + + {data?.traces.length ?? 0} + + + + {data?.traces.length ? ( +
      + {data.traces.map((t, i) => ( + +
      +
      {t.service}
      +
      + {t.operation} +
      -
    -
    - - {safeDur(t.duration)} - - {t.spans} - {t.hasError && ( - - err +
    + + {safeDur(t.duration)} - )} -
    -
  • - ))} -
- ) : ( -

- No traces — data appears once services send OTel telemetry -

- )} -
-
-
+ {t.spans} + {t.hasError && ( + + err + + )} +
+ + ))} + + ) : ( +

+ No traces — data appears once services send OTel + telemetry +

+ )} + + + + ); } @@ -288,13 +374,21 @@ function StatBox({ color: string; }) { return ( -
+
- {value ?? "-"} + {typeof value === "number" ? ( + + ) : ( + (value ?? "-") + )}

{label}

-
+ ); } diff --git a/src/app/globals.css b/src/app/globals.css index b3a088a..db45716 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 82bb750..91e450b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 (
- {/* Hero — terminal-inspired */} + {/* ── Hero — terminal-inspired ── */}
-
+ {/* Animated gradient background */} + + +
-

+ ~ whoami -

-

- $ - Asep Haryana Saputra - -

-

+ + + + + + # Teknik Informatika student exploring backend systems, distributed infrastructure, and DevOps — still learning, still building. -

+
- + +
- {/* Content — staggered sections with alternating visual weight */} -
- {/* About — minimal, just text */} -
-

- ~ about -

-
-

- I’m a Teknik Informatika student passionate about backend - engineering, distributed systems, and infrastructure. I spend my - time building RESTful APIs, learning container orchestration with - Docker & Traefik, and exploring observability with - OpenTelemetry & Jaeger. + {/* ── Content — staggered sections ── */} +

+ {/* About */} + +
+

+ ~ about

-

- I was the Back-End lead on the ZeaVis Edu capstone team (“AI - for Smart Education” by Pijak × 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. -

-
-
- - {/* Skills & Tools — grid */} -
-

- ~ skills -

-
- {skills.map((s) => { - const Icon = s.icon; - return ( -
- - {s.label} -
- ); - })} -
-
- {techTags.map((t) => ( - - {t} - - ))} -
-
- - {/* Featured Project — elevated */} -
-

- ~ featured - project -

- - -
-
- ZeaVis Edu -

- Asisten Edukasi Interaktif untuk Deteksi Penyakit Daun - Jagung -

-
- -
-
- -

- A Computer Vision-powered educational platform that helps - farmers, agricultural students, and field extension officers - identify corn leaf diseases by uploading a photo. Built as a - capstone project for the Pijak × IBM SkillsBuild program. +

+

+ I’m a Teknik Informatika student passionate about backend + engineering, distributed systems, and infrastructure. I spend my + time building RESTful APIs, learning container orchestration + with Docker & Traefik, and exploring observability with + OpenTelemetry & Jaeger.

+

+ I contributed to the ZeaVis Edu capstone team (“AI + for Smart Education” by Pijak × IBM SkillsBuild), a + 5-person team where we divided tasks across Back-End, Front-End, + and Machine Learning. I worked on the system architecture, API + design, RESTful API development, and Docker deployment on VPS. +

+
+
+ -
-
-

- What it does -

-
    -
  • Upload a photo of a corn leaf
  • -
  • - AI classifies the disease — Blight, Rust, Leaf Spot, - or Healthy -
  • -
  • Get treatment & prevention recommendations
  • -
-
-
-

- My role -

-
    -
  • System architecture & API design
  • -
  • RESTful API (Bun + Elysia + Drizzle + PostgreSQL)
  • -
  • Secure upload handling
  • -
  • - Docker deployment on VPS with Traefik & Tailscale -
  • -
-
-
- -
- {[ - "React", - "TypeScript", - "Bun", - "Elysia", - "Drizzle", - "PostgreSQL", - "Rust", - "Axum", - "ONNX", - "TensorFlow", - "EfficientNetV2B0", - "Tauri 2", - "Docker", - "Traefik", - ].map((t) => ( - +
+

+ ~ skills +

+ + {skills.map((s) => { + const Icon = s.icon; + return ( + + + + {s.label} + + + ); + })} + + + {techTags.map((t) => ( + + - {t} - - ))} -
- - -
+ + {t} + + + + ))} + +
+ - {/* Infrastructure CTA — clean card */} - - -

- This site doubles as a live infrastructure monitor. + {/* Featured Project */} + +

+

+ ~ featured + project

-

- The dashboard tracks Docker services, Traefik request metrics, - Prometheus system resources, and Jaeger traces — all running on my - VPS deployment. -

- - Live Dashboard - - - - -
+ + + + {/* Gradient border accent */} +
+ + +
+
+ ZeaVis Edu +

+ Asisten Edukasi Interaktif untuk Deteksi Penyakit Daun + Jagung +

+
+
+ + + Live + + + + Source + +
+
+
+ +

+ A Computer Vision-powered educational platform that helps + farmers, agricultural students, and field extension officers + identify corn leaf diseases by uploading a photo. Built as a + capstone project for the Pijak × IBM SkillsBuild program. +

+ +
+ +

+ What it does +

+
    +
  • Upload a photo of a corn leaf
  • +
  • + AI classifies the disease — Blight, Rust, Leaf Spot, + or Healthy +
  • +
  • Get treatment & prevention recommendations
  • +
+
+ +

+ My role +

+
    +
  • System architecture & API design
  • +
  • RESTful API (Bun + Elysia + Drizzle + PostgreSQL)
  • +
  • Secure upload handling
  • +
  • + Docker deployment on VPS with Traefik & Tailscale +
  • +
+
+
+ + + {[ + "React", + "TypeScript", + "Bun", + "Elysia", + "Drizzle", + "PostgreSQL", + "Rust", + "Axum", + "ONNX", + "TensorFlow", + "EfficientNetV2B0", + "Tauri 2", + "Docker", + "Traefik", + ].map((t) => ( + + + + {t} + + + + ))} + +
+ + + +
+ + + {/* Infrastructure CTA */} + + + + +
+ +

+ This site doubles as a live infrastructure monitor. +

+

+ The dashboard tracks Docker services, Traefik request metrics, + Prometheus system resources, and Jaeger traces — all running on my + VPS deployment. +

+ + Live Dashboard + + +
+ + + + +
{/* Footer */} -
+

© {new Date().getFullYear()} Asep Haryana Saputra —{" "} - GitHub - +

-
+
); } diff --git a/src/components/dashboard/donut.tsx b/src/components/dashboard/donut.tsx index aa48ba0..818b301 100644 --- a/src/components/dashboard/donut.tsx +++ b/src/components/dashboard/donut.tsx @@ -1,3 +1,6 @@ +"use client"; + +import { motion } from "framer-motion"; import { NoData } from "./no-data"; export function Donut({ @@ -21,19 +24,24 @@ export function Donut({ let off = 0; return ( - {segs.map((s) => { if (!s.n) return null; const frac = s.n / total; const ln = frac * circ; - const el = ( - ); - off += ln; - return el; })} - {total} - + - total + services {segs .filter((s) => s.n) .map((s, i) => ( - - + + {s.l}: {s.n} - + ))} - + ); } diff --git a/src/components/dashboard/gauge.tsx b/src/components/dashboard/gauge.tsx index 6bd2ae5..194304b 100644 --- a/src/components/dashboard/gauge.tsx +++ b/src/components/dashboard/gauge.tsx @@ -1,3 +1,6 @@ +"use client"; + +import { motion } from "framer-motion"; import { NoData } from "./no-data"; const W = 220; @@ -22,16 +25,22 @@ export function Gauge({ const fw = BW * Math.min(pct / 100, 1); return ( - + {/* Track */} + + {/* Filled bar */} {fw > 0 && ( - )} - {label} - - + + {/* Value */} + {pct.toFixed(1)} {unit} - - + + ); } diff --git a/src/components/dashboard/header.tsx b/src/components/dashboard/header.tsx index 7719f1d..dd28b75 100644 --- a/src/components/dashboard/header.tsx +++ b/src/components/dashboard/header.tsx @@ -1,11 +1,13 @@ "use client"; +import { motion } from "framer-motion"; import { Moon, Sun } from "lucide-react"; import { useTheme } from "next-themes"; import { useEffect, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import type { DashboardData } from "@/lib/dashboard/types"; +import { springGentle } from "@/lib/motion"; export function DashboardHeader() { const [degraded, setDegraded] = useState(0); @@ -46,49 +48,96 @@ export function DashboardHeader() { return () => clearInterval(id); }, []); + const isHealthy = total > 0 && degraded === 0; + const hasIssues = degraded > 0; + return ( -
- + + AH

Asep Haryana

-
+
- 0 - ? "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 && ( - 0 ? "bg-amber-500" : "bg-emerald-400" - }`} - /> - )} - {total === 0 - ? "No services" - : degraded > 0 - ? `${degraded} degraded` - : "All Systems Operational"} - - - {time} + + + + {time} +
-
+ ); } diff --git a/src/components/dashboard/no-data.tsx b/src/components/dashboard/no-data.tsx index 109ec60..9fca1d2 100644 --- a/src/components/dashboard/no-data.tsx +++ b/src/components/dashboard/no-data.tsx @@ -1,22 +1,32 @@ +"use client"; + +import { motion } from "framer-motion"; + export function NoData() { return ( - - No data - - + + ); } diff --git a/src/components/dashboard/sparkline.tsx b/src/components/dashboard/sparkline.tsx index b2e042b..f0af036 100644 --- a/src/components/dashboard/sparkline.tsx +++ b/src/components/dashboard/sparkline.tsx @@ -1,3 +1,6 @@ +"use client"; + +import { motion } from "framer-motion"; import { NoData } from "./no-data"; 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 lv = data[data.length - 1]; const ly = PT + VH * (1 - lv / maxV); + const d = `M${pts}`; return ( - {gridLines(maxV)} - - - 1 && ( + + )} + + {/* Endpoint dot */} + + + {/* Endpoint value label */} + {lv.toFixed(1)} - + + {xAxisLabels(data.length)} - + ); } diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index a8cbbac..cf3ba3d 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; 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: { variant: { diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index e2ee793..c3cbc8a 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -8,7 +8,8 @@ const buttonVariants = cva( { variants: { 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: "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: diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index d4e4257..a160811 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -12,7 +12,7 @@ function Card({ data-slot="card" data-size={size} 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, )} {...props} diff --git a/src/components/ui/motion-primitives.tsx b/src/components/ui/motion-primitives.tsx new file mode 100644 index 0000000..e290d79 --- /dev/null +++ b/src/components/ui/motion-primitives.tsx @@ -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 ( + + {children} + + ); +} + +// ─── 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 ( + + {children} + + ); +} + +// ─── 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 ( + + {children} + + ); +} + +// ─── Scale reveal (for cards, dialogs) ─────────────────────── + +export function ScaleReveal({ + children, + className, + ...props +}: HTMLMotionProps<"div">) { + return ( + + {children} + + ); +} + +// ─── Animated presence wrapper ─────────────────────────────── + +/** + * Fades + scales content in/out for mount/unmount transitions. + */ +export function AnimatedPresence({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( + + {children} + + ); +} + +// ─── 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 ( + + {value.toFixed(decimals)} + + ); +} + +// ─── 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 ( + + {displayed} + {showCursor && !done && ( + + )} + + ); +} + +// ─── Blur-in reveal ────────────────────────────────────────── + +export function BlurReveal({ + children, + className, + delay = 0, +}: { + children: React.ReactNode; + className?: string; + delay?: number; +}) { + return ( + + {children} + + ); +} + +// ─── 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 ( + + {children} + + ); +} diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx index 26bc059..0934b81 100644 --- a/src/components/ui/skeleton.tsx +++ b/src/components/ui/skeleton.tsx @@ -4,7 +4,10 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) { return (
); diff --git a/src/components/ui/spinner.tsx b/src/components/ui/spinner.tsx index a1952d6..285ac0a 100644 --- a/src/components/ui/spinner.tsx +++ b/src/components/ui/spinner.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Loader2Icon } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -6,7 +8,7 @@ function Spinner({ className, ...props }: React.ComponentProps<"svg">) { ); diff --git a/src/lib/motion.ts b/src/lib/motion.ts new file mode 100644 index 0000000..9a81125 --- /dev/null +++ b/src/lib/motion.ts @@ -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, +};