From 90e99c0c51addfa43d4b66e2a0aff4751d7c3cd5 Mon Sep 17 00:00:00 2001 From: grtsinry43 Date: Wed, 7 May 2025 13:19:10 +0800 Subject: [PATCH] feat: add new animated components including particle container, typing effect, and scroll reveal --- src/app/page.tsx | 3 + src/components/sections/final-section.tsx | 660 +++++++++++++++++++ src/components/sections/personal-intro.tsx | 382 +++++++---- src/components/sections/skill-tree.tsx | 304 +++++---- src/components/ui/3d-card.tsx | 108 +++ src/components/ui/animated-counter.tsx | 60 ++ src/components/ui/animated-divider.tsx | 128 ++++ src/components/ui/animated-gradient-text.tsx | 59 ++ src/components/ui/cursor-spotlight.tsx | 94 +++ src/components/ui/floating-icons.tsx | 70 ++ src/components/ui/magnetic-button.tsx | 91 +++ src/components/ui/particle-container.tsx | 266 ++++++++ src/components/ui/scroll-reveal.tsx | 132 ++++ src/components/ui/text-reveal-effect.tsx | 89 +++ src/components/ui/typing-effect.tsx | 132 ++++ 15 files changed, 2320 insertions(+), 258 deletions(-) create mode 100644 src/components/sections/final-section.tsx create mode 100644 src/components/ui/3d-card.tsx create mode 100644 src/components/ui/animated-counter.tsx create mode 100644 src/components/ui/animated-divider.tsx create mode 100644 src/components/ui/animated-gradient-text.tsx create mode 100644 src/components/ui/cursor-spotlight.tsx create mode 100644 src/components/ui/floating-icons.tsx create mode 100644 src/components/ui/magnetic-button.tsx create mode 100644 src/components/ui/particle-container.tsx create mode 100644 src/components/ui/scroll-reveal.tsx create mode 100644 src/components/ui/text-reveal-effect.tsx create mode 100644 src/components/ui/typing-effect.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx index f51e507..ba9e2e8 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -23,6 +23,7 @@ import GsapProjectsShowcase from "@/components/sections/project-showcase"; import GsapPersonalIntro from "@/components/sections/personal-intro"; import GsapPhotographySection from "@/components/sections/photography-section"; import GsapRhythmGamesSection from "@/components/sections/rhythm-games-section"; +import FinalSection from "@/components/sections/final-section"; export default function HomePage() { const {theme} = useTheme() @@ -183,6 +184,8 @@ export default function HomePage() { {/* Contact Section with glass morphism */} + + {/* Footer with subtle animation */} diff --git a/src/components/sections/final-section.tsx b/src/components/sections/final-section.tsx new file mode 100644 index 0000000..ec2e4b3 --- /dev/null +++ b/src/components/sections/final-section.tsx @@ -0,0 +1,660 @@ +"use client" + +import {useRef, useEffect, useState} from "react" +import {motion, useInView, useScroll, useTransform} from "framer-motion" +import {Github, Mail, ExternalLink, Heart, Star, Download, ArrowRight, Sparkles} from "lucide-react" +import {Button} from "@/components/ui/button" +import {useTheme} from "next-themes" +import ParticleContainer from "@/components/ui/particle-container" +import AnimatedDivider from "@/components/ui/animated-divider" +import TypingEffect from "@/components/ui/typing-effect" +import MagneticButton from "@/components/ui/magnetic-button" +import ThreeDCard from "@/components/ui/3d-card" + +export default function FinalSection() { + const containerRef = useRef(null) + const textRef = useRef(null) + const svgRef = useRef(null) + const cardsRef = useRef(null) + const ctaRef = useRef(null) + const isInView = useInView(containerRef, {once: true, amount: 0.2}) + const {theme} = useTheme() + const [gsapLoaded, setGsapLoaded] = useState(false) + const [mousePosition, setMousePosition] = useState({x: 0, y: 0}) + + const {scrollYProgress} = useScroll({ + target: containerRef, + offset: ["start end", "end start"], + }) + + const y = useTransform(scrollYProgress, [0, 1], ["0%", "20%"]) + const opacity = useTransform(scrollYProgress, [0.8, 1], [1, 0]) + + // Handle mouse movement for interactive effects + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!containerRef.current) return + const rect = containerRef.current.getBoundingClientRect() + setMousePosition({ + x: ((e.clientX - rect.left) / rect.width) * 2 - 1, + y: ((e.clientY - rect.top) / rect.height) * 2 - 1, + }) + } + + window.addEventListener("mousemove", handleMouseMove) + return () => window.removeEventListener("mousemove", handleMouseMove) + }, []) + + // Load GSAP and create advanced animations + useEffect(() => { + if (!isInView || gsapLoaded) return + + const loadGsapAndAnimate = async () => { + try { + // Dynamically import GSAP and plugins + const {gsap} = await import("gsap") + const {ScrollTrigger} = await import("gsap/ScrollTrigger") + const {TextPlugin} = await import("gsap/TextPlugin") + const {MotionPathPlugin} = await import("gsap/MotionPathPlugin") + const {CustomEase} = await import("gsap/CustomEase") + + // Register plugins + gsap.registerPlugin(ScrollTrigger, TextPlugin, MotionPathPlugin, CustomEase) + + if (!containerRef.current) return + + // Create a master timeline + const masterTl = gsap.timeline({ + defaults: {ease: "power3.out"}, + onComplete: () => console.log("All animations complete"), + }) + + // 1. Animate stars with staggered entrance and continuous rotation + if (containerRef.current.querySelectorAll(".star").length > 0) { + const starsTl = gsap.timeline() + + // Initial entrance animation + starsTl.fromTo( + ".star", + { + scale: 0, + opacity: 0, + rotation: -30, + y: -50, + }, + { + scale: 1, + opacity: 1, + rotation: 0, + y: 0, + duration: 1.2, + stagger: 0.1, + ease: "elastic.out(1, 0.5)", + }, + ) + + // Add continuous floating and twinkling animations + gsap.to(".star", { + y: "random(-15, 15)", + rotation: "random(-15, 15)", + scale: "random(0.9, 1.1)", + opacity: "random(0.7, 1)", + duration: "random(2, 4)", + repeat: -1, + yoyo: true, + ease: "sine.inOut", + stagger: { + each: 0.2, + from: "random", + }, + }) + + masterTl.add(starsTl, 0) + } + + // 2. Create an impressive text reveal for the thank you message + if (textRef.current) { + const textTl = gsap.timeline() + + // Split text animation + const titleElement = textRef.current.querySelector("h2") + if (titleElement) { + // First clear the text content but save it + const originalText = titleElement.textContent || "感谢您的浏览" + titleElement.innerHTML = "" + + // Create wrapper spans for each character + const wrappedText = originalText + .split("") + .map((char) => `${char}`) + .join("") + + titleElement.innerHTML = wrappedText + + // Animate each character + textTl.fromTo( + ".thank-you-char", + { + opacity: 0, + y: 100, + rotationX: 90, + scale: 0.5, + }, + { + opacity: 1, + y: 0, + rotationX: 0, + scale: 1, + duration: 1, + stagger: 0.08, + ease: "back.out(1.7)", + }, + ) + + // Add a highlight effect that moves across the text + textTl.fromTo( + ".thank-you-char", + {color: "currentColor"}, + { + color: theme === "dark" ? "#60a5fa" : "#3b82f6", + stagger: 0.05, + duration: 0.3, + repeat: 1, + yoyo: true, + ease: "sine.inOut", + }, + "-=0.5", + ) + } + + masterTl.add(textTl, 0.5) + } + + // 3. Create an SVG path animation + if (svgRef.current) { + const svgTl = gsap.timeline() + + const paths = svgRef.current.querySelectorAll("path") + + // Draw each path + svgTl.fromTo( + paths, + { + strokeDasharray: (i, target) => target.getTotalLength(), + strokeDashoffset: (i, target) => target.getTotalLength(), + }, + { + strokeDashoffset: 0, + duration: 1.5, + stagger: 0.2, + ease: "power2.inOut", + }, + ) + + // Fill paths after drawing + svgTl.to( + paths, + { + fill: (i) => + i % 2 === 0 ? (theme === "dark" ? "#60a5fa" : "#3b82f6") : theme === "dark" ? "#a78bfa" : "#8b5cf6", + duration: 0.5, + stagger: 0.1, + }, + "-=0.5", + ) + + masterTl.add(svgTl, 1) + } + + // 4. Animate the cards with 3D effects + if (cardsRef.current) { + const cardsTl = gsap.timeline() + + const cards = cardsRef.current.querySelectorAll(".card-item") + + // Staggered entrance with 3D rotation + cardsTl.fromTo( + cards, + { + opacity: 0, + y: 100, + rotationY: 45, + rotationX: 20, + z: -100, + }, + { + opacity: 1, + y: 0, + rotationY: 0, + rotationX: 0, + z: 0, + duration: 1.2, + stagger: 0.15, + ease: "power4.out", + }, + ) + + // Add hover effect to cards + cards.forEach((card) => { + card.addEventListener("mouseenter", () => { + gsap.to(card, { + y: -15, + rotationY: 5, + rotationX: -5, + scale: 1.05, + boxShadow: "0 30px 30px rgba(0,0,0,0.2)", + duration: 0.4, + ease: "power2.out", + }) + }) + + card.addEventListener("mouseleave", () => { + gsap.to(card, { + y: 0, + rotationY: 0, + rotationX: 0, + scale: 1, + boxShadow: "0 10px 20px rgba(0,0,0,0.1)", + duration: 0.4, + ease: "power2.out", + }) + }) + }) + + masterTl.add(cardsTl, 1.5) + } + + // 5. Animate the CTA section + if (ctaRef.current) { + const ctaTl = gsap.timeline() + + // Heading and paragraph reveal + ctaTl.fromTo( + ctaRef.current.querySelectorAll("h3, p"), + { + opacity: 0, + y: 30, + }, + { + opacity: 1, + y: 0, + duration: 0.8, + stagger: 0.2, + ease: "power2.out", + }, + ) + + // Button reveal with bounce + ctaTl.fromTo( + ctaRef.current.querySelectorAll(".social-link"), + { + opacity: 0, + scale: 0, + }, + { + opacity: 1, + scale: 1, + duration: 0.6, + stagger: 0.2, + ease: "back.out(1.7)", + }, + "-=0.4", + ) + + // Add pulse animation to primary button + ctaTl.to( + ctaRef.current.querySelector(".social-link:first-child"), + { + boxShadow: "0 0 0 8px rgba(59, 130, 246, 0.2)", + repeat: 3, + yoyo: true, + duration: 0.8, + ease: "sine.inOut", + }, + "+=0.5", + ) + + masterTl.add(ctaTl, 2) + } + + // 6. Final heart animation + const heartTl = gsap.timeline() + + heartTl.fromTo( + ".heart-icon", + { + scale: 0, + opacity: 0, + }, + { + scale: 1, + opacity: 1, + duration: 0.6, + ease: "back.out(1.7)", + }, + ) + + // Pulsing heart animation + heartTl.to(".heart-icon", { + scale: 1.2, + repeat: -1, + yoyo: true, + duration: 0.8, + ease: "sine.inOut", + }) + + masterTl.add(heartTl, 2.5) + + // 7. Create floating particles that follow mouse movement + const createFloatingParticle = () => { + const particle = document.createElement("div") + particle.className = "absolute w-2 h-2 rounded-full pointer-events-none" + particle.style.backgroundColor = theme === "dark" ? "#60a5fa" : "#3b82f6" + particle.style.opacity = "0" + containerRef.current?.appendChild(particle) + + // Random position near mouse + const x = mousePosition.x * 100 + Math.random() * 100 - 50 + const y = mousePosition.y * 100 + Math.random() * 100 - 50 + + gsap.set(particle, {x, y, opacity: 0, scale: 0}) + + // Animate particle + gsap.to(particle, { + x: x + (Math.random() - 0.5) * 200, + y: y - Math.random() * 200, + opacity: 0.7, + scale: Math.random() * 2 + 0.5, + duration: Math.random() * 2 + 1, + ease: "power1.out", + onComplete: () => { + gsap.to(particle, { + opacity: 0, + duration: 0.5, + onComplete: () => { + if (particle.parentNode) { + particle.parentNode.removeChild(particle) + } + }, + }) + }, + }) + } + + // Create particles on interval + const particleInterval = setInterval(() => { + if (isInView && containerRef.current) { + createFloatingParticle() + } + }, 300) + + // Clean up interval + setTimeout(() => { + clearInterval(particleInterval) + }, 10000) // Stop creating particles after 10 seconds + + setGsapLoaded(true) + console.log("GSAP animations initialized successfully") + } catch (error) { + console.error("Failed to load GSAP or initialize animations:", error) + } + } + + // Delay loading GSAP to ensure DOM is fully rendered + const timer = setTimeout(() => { + loadGsapAndAnimate() + }, 500) + + return () => clearTimeout(timer) + }, [isInView, gsapLoaded, theme, mousePosition]) + + return ( +
+ + +
+
+ + {/* Stars animation */} +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + {/* Thank you text with character animation */} +
+

+ 感谢您的浏览 +

+
+ + {/* SVG decoration */} +
+ + + + + + +
+ + + + + + + + {/* Cards with 3D effect */} +
+ +
+
+ +
+

联系我

+

随时通过邮件联系我,我会尽快回复

+ +
+
+ + +
+
+ +
+

查看代码

+

访问我的GitHub查看更多项目和代码

+ +
+
+ + +
+
+ +
+

访问博客

+

阅读我的技术博客,了解更多内容

+ +
+
+
+ + {/* CTA section with advanced animations */} +
+
+
+
+ +

让我们保持联系

+

+ 无论您是想讨论项目合作,还是只是想打个招呼,我都很乐意听到您的声音。 +

+ + +
+ + + + + + 制作 + + + + {/* Interactive elements that respond to mouse movement */} +
+
+
+
+
+
+
+ ) +} diff --git a/src/components/sections/personal-intro.tsx b/src/components/sections/personal-intro.tsx index 718e587..b7009a7 100644 --- a/src/components/sections/personal-intro.tsx +++ b/src/components/sections/personal-intro.tsx @@ -2,17 +2,18 @@ import {useRef, useEffect, useState} from "react" import {AnimatePresence, motion, useScroll, useTransform} from "framer-motion" -import gsap from "gsap" -import {ScrollTrigger} from "gsap/ScrollTrigger" -import {TextPlugin} from "gsap/TextPlugin" import {Github, Mail, ExternalLink, Code, Heart, Coffee} from "lucide-react" import {Button} from "@/components/ui/button" import {useTheme} from "next-themes" -import dynamic from "next/dynamic"; +import dynamic from "next/dynamic" +import MagneticButton from "@/components/ui/magnetic-button" +import TextRevealEffect from "@/components/ui/text-reveal-effect" +import AnimatedGradientText from "@/components/ui/animated-gradient-text" +import FloatingIcons from "@/components/ui/floating-icons" -const RandomItems = dynamic(() => (import('@/components/ui/random-items')), { +const RandomItems = dynamic(() => import("@/components/ui/random-items"), { ssr: false, -}); +}) export default function GsapPersonalIntro() { const containerRef = useRef(null) @@ -28,7 +29,7 @@ export default function GsapPersonalIntro() { } else { setIsDark(false) } - }, [resolvedTheme]); + }, [resolvedTheme]) const {scrollYProgress} = useScroll({ target: containerRef, @@ -40,82 +41,130 @@ export default function GsapPersonalIntro() { const opacity = useTransform(scrollYProgress, [0, 0.8], [1, 0]) useEffect(() => { - gsap.registerPlugin(ScrollTrigger, TextPlugin); + const loadGsap = async () => { + try { + const {gsap} = await import("gsap") + const {ScrollTrigger} = await import("gsap/ScrollTrigger") + const {TextPlugin} = await import("gsap/TextPlugin") - if (!containerRef.current) return; + gsap.registerPlugin(ScrollTrigger, TextPlugin) - const container = containerRef.current; + if (!containerRef.current) return - // 清理所有 ScrollTrigger 实例 - ScrollTrigger.getAll().forEach((trigger) => trigger.kill()); + // Clean up any existing ScrollTrigger instances + ScrollTrigger.getAll().forEach((trigger) => trigger.kill()) - const tl = gsap.timeline({ - scrollTrigger: { - trigger: container, - start: "top 80%", - end: "bottom 20%", - toggleActions: "play none none reverse", - }, - }); + const container = containerRef.current - if (textRef.current) { - const headings = textRef.current.querySelectorAll("h3, h4"); - const paragraphs = textRef.current.querySelectorAll("p"); - const buttons = textRef.current.querySelectorAll("button, a"); - const icons = textRef.current.querySelectorAll(".icon-item"); + const tl = gsap.timeline({ + scrollTrigger: { + trigger: container, + start: "top 80%", + end: "bottom 20%", + toggleActions: "play none none reverse", + }, + }) - tl.from(headings, {opacity: 0, y: 50, duration: 0.8, stagger: 0.2, ease: "power3.out"}) - .from(paragraphs, {opacity: 0, y: 30, duration: 0.6, stagger: 0.2, ease: "power2.out"}, "-=0.4") - .from(buttons, { - opacity: 0, - y: 20, - scale: 0.9, - duration: 0.5, - stagger: 0.1, - ease: "back.out(1.7)" - }, "-=0.3") - .from(icons, { - opacity: 0, - scale: 0, - rotation: -30, - duration: 0.6, - stagger: 0.1, - ease: "back.out(2)" - }, "-=0.4"); + if (textRef.current) { + const headings = textRef.current.querySelectorAll("h3, h4") + const paragraphs = textRef.current.querySelectorAll("p") + const buttons = textRef.current.querySelectorAll("button, a") + const icons = textRef.current.querySelectorAll(".icon-item") + + tl.from(headings, { + opacity: 0, + y: 50, + duration: 0.8, + stagger: 0.2, + ease: "power3.out", + }) + .from( + paragraphs, + { + opacity: 0, + y: 30, + duration: 0.6, + stagger: 0.2, + ease: "power2.out", + }, + "-=0.4", + ) + .from( + buttons, + { + opacity: 0, + y: 20, + scale: 0.9, + duration: 0.5, + stagger: 0.1, + ease: "back.out(1.7)", + }, + "-=0.3", + ) + .from( + icons, + { + opacity: 0, + scale: 0, + rotation: -30, + duration: 0.6, + stagger: 0.1, + ease: "back.out(2)", + }, + "-=0.4", + ) + } + + if (imageRef.current) { + const shapes = imageRef.current.querySelectorAll(".shape") + const profileImage = imageRef.current.querySelector(".profile-image") + + // Add a subtle floating animation to the profile image + gsap.to(profileImage, { + y: 10, + duration: 2, + repeat: -1, + yoyo: true, + ease: "sine.inOut", + }) + + // Animate shapes with staggered entrances + tl.from( + shapes, + { + opacity: 0, + scale: 0, + rotation: -60, + transformOrigin: "center", + duration: 0.8, + stagger: 0.1, + ease: "back.out(2)", + }, + "-=0.8", + ) + + // Add continuous rotation to shapes + shapes.forEach((shape, index) => { + gsap.to(shape, { + rotation: index % 2 === 0 ? "+=360" : "-=360", + duration: 15 + index * 5, + repeat: -1, + ease: "linear", + }) + }) + } + + return () => { + // Clean up all ScrollTrigger instances + ScrollTrigger.getAll().forEach((trigger) => trigger.kill()) + } + } catch (error) { + console.error("Failed to load GSAP:", error) + } } - if (imageRef.current) { - const shapes = imageRef.current.querySelectorAll(".shape"); - const particles = imageRef.current.querySelectorAll(".particle"); - - tl.from(shapes, { - opacity: 0, - scale: 0, - rotation: -60, - transformOrigin: "center", - duration: 0.8, - stagger: 0.1, - ease: "back.out(2)" - }, "-=0.8"); - - particles.forEach((particle) => { - gsap.to(particle, { - y: "random(-30, 30)", - x: "random(-30, 30)", - rotation: "random(-360, 360)", - duration: "random(3, 6)", - repeat: -1, - yoyo: true, - ease: "sine.inOut", - }); - }); - } - - return () => { - // 确保清理所有 ScrollTrigger 实例 - ScrollTrigger.getAll().forEach((trigger) => trigger.kill()); - }; - }, [theme, resolvedTheme, isDark]); + loadGsap() + }, [theme, resolvedTheme]) const words = ["全栈开发者", "设计者", "创造者"] const [currentWord, setCurrentWord] = useState(0) @@ -127,22 +176,58 @@ export default function GsapPersonalIntro() { return () => clearInterval(interval) }, []) + // Icons for floating animation + const floatingIcons = [ + , + , + , + , + , + ] + return ( - + {/* Background elements */}
-
-
-
+ + +
@@ -150,10 +235,18 @@ export default function GsapPersonalIntro() {
-

- 你好,我是

grtsinry43

-

-

+ + +

热爱生活的{" "}
-
+
开发者 -
-
+ +
热爱创造 -
-
+ +
咖啡爱好者 -
+
-
-
+ grtsinry43 )} -
-

grtsinry43

-

全栈开发者 & 设计爱好者

-
-
+ + + +
-
@@ -263,4 +379,4 @@ export default function GsapPersonalIntro() {
) -} \ No newline at end of file +} diff --git a/src/components/sections/skill-tree.tsx b/src/components/sections/skill-tree.tsx index 6018fc2..2d8083d 100644 --- a/src/components/sections/skill-tree.tsx +++ b/src/components/sections/skill-tree.tsx @@ -3,13 +3,16 @@ import {useRef, useEffect, useState} from "react" import {motion} from "framer-motion" import {useTheme} from "next-themes" +import AnimatedCounter from "@/components/ui/animated-counter" +import ThreeDCard from "@/components/ui/3d-card" export default function GsapSkillsTree() { const containerRef = useRef(null) const canvasRef = useRef(null) const {theme} = useTheme() - const [activeSkill, setActiveSkill] = useState(null) // 状态已声明,但更新逻辑未在原始代码中提供 + const [activeSkill, setActiveSkill] = useState(null) const [isInView, setIsInView] = useState(false) + const [hoveredGroup, setHoveredGroup] = useState(null) // 技能数据 const skills = [ @@ -38,9 +41,9 @@ export default function GsapSkillsTree() { // 技能分组 const skillGroups = [ - {id: "web", name: "Web开发", color: "from-blue-500 to-violet-500"}, - {id: "backend", name: "后端开发", color: "from-green-500 to-emerald-500"}, - {id: "android", name: "Android开发", color: "from-amber-500 to-orange-500"}, + {id: "web", name: "Web开发", color: "from-blue-500 to-violet-500", icon: "🌐"}, + {id: "backend", name: "后端开发", color: "from-green-500 to-emerald-500", icon: "🖥️"}, + {id: "android", name: "Android开发", color: "from-amber-500 to-orange-500", icon: "📱"}, ] useEffect(() => { @@ -48,8 +51,6 @@ export default function GsapSkillsTree() { (entries) => { if (entries[0].isIntersecting) { setIsInView(true) - // 可选:确保只触发一次 - if (containerRef.current) observer.unobserve(containerRef.current); } }, {threshold: 0.2}, @@ -67,52 +68,50 @@ export default function GsapSkillsTree() { }, []) useEffect(() => { - if (!isInView) return; + if (!isInView) return - let animationFrameId: number; - let autoRotateTimeout: NodeJS.Timeout; - let resizeListener: () => void; - let mouseMoveListener: (e: MouseEvent) => void; + let animationFrameId: number + let autoRotateTimeout: NodeJS.Timeout + let resizeListener: () => void + let mouseMoveListener: (e: MouseEvent) => void const loadGsapAndAnimate = async () => { try { - const gsapModule = await import("gsap") - const ScrollTriggerModule = await import("gsap/ScrollTrigger") - const gsap = gsapModule.default - const ScrollTrigger = ScrollTriggerModule.default + // Import GSAP dynamically + const {gsap} = await import("gsap") + const {ScrollTrigger} = await import("gsap/ScrollTrigger") gsap.registerPlugin(ScrollTrigger) - if (!canvasRef.current) return; + if (!canvasRef.current) return const canvas = canvasRef.current const ctx = canvas.getContext("2d") - if (!ctx) return; + if (!ctx) return - const dpr = window.devicePixelRatio || 1; - let cssWidth = 0; - let cssHeight = 0; + const dpr = window.devicePixelRatio || 1 + let cssWidth = 0 + let cssHeight = 0 const setCanvasSize = () => { - const rect = canvas.getBoundingClientRect(); - cssWidth = rect.width; - cssHeight = rect.height; + const rect = canvas.getBoundingClientRect() + cssWidth = rect.width + cssHeight = rect.height - canvas.width = cssWidth * dpr; - canvas.height = cssHeight * dpr; + canvas.width = cssWidth * dpr + canvas.height = cssHeight * dpr - ctx.resetTransform(); - ctx.scale(dpr, dpr); - }; + ctx.resetTransform() + ctx.scale(dpr, dpr) + } - setCanvasSize(); // Initial size - resizeListener = setCanvasSize; // Store for cleanup - window.addEventListener("resize", resizeListener); + setCanvasSize() // Initial size + resizeListener = setCanvasSize // Store for cleanup + window.addEventListener("resize", resizeListener) - - const modelSpaceRadius = 100; // 抽象的模型空间半径 + const modelSpaceRadius = 100 // 抽象的模型空间半径 const skillNodes = skills.map((skill, index) => { - const phi = Math.acos(-1 + (2 * index) / skills.length); - const theta = Math.sqrt(skills.length * Math.PI) * phi; + const phi = Math.acos(-1 + (2 * index) / skills.length) + const theta = Math.sqrt(skills.length * Math.PI) * phi return { ...skill, x3d: modelSpaceRadius * Math.cos(theta) * Math.sin(phi), @@ -122,112 +121,148 @@ export default function GsapSkillsTree() { y2d: 0, scale: 0, opacity: 0, - }; - }); + } + }) - const rotation = {x: 0, y: 0}; - const targetRotation = {x: 0, y: 0}; - let autoRotate = true; - let mouseX = 0; - let mouseY = 0; + const rotation = {x: 0, y: 0} + const targetRotation = {x: 0, y: 0} + let autoRotate = true + let mouseX = 0 + let mouseY = 0 mouseMoveListener = (e: MouseEvent) => { - const rect = canvas.getBoundingClientRect(); - if (!rect.width || !rect.height) return; // Avoid division by zero if canvas not rendered - mouseX = ((e.clientX - rect.left) / rect.width - 0.5) * 2; - mouseY = ((e.clientY - rect.top) / rect.height - 0.5) * 2; + const rect = canvas.getBoundingClientRect() + if (!rect.width || !rect.height) return // Avoid division by zero if canvas not rendered + mouseX = ((e.clientX - rect.left) / rect.width - 0.5) * 2 + mouseY = ((e.clientY - rect.top) / rect.height - 0.5) * 2 - if (Math.abs(mouseX) > 0.05 || Math.abs(mouseY) > 0.05) { // Reduced sensitivity - autoRotate = false; - targetRotation.x = mouseY * 0.3; // Reduced rotation speed - targetRotation.y = mouseX * 0.3; // Reduced rotation speed - clearTimeout(autoRotateTimeout); + if (Math.abs(mouseX) > 0.05 || Math.abs(mouseY) > 0.05) { + // Reduced sensitivity + autoRotate = false + targetRotation.x = mouseY * 0.3 // Reduced rotation speed + targetRotation.y = mouseX * 0.3 // Reduced rotation speed + clearTimeout(autoRotateTimeout) autoRotateTimeout = setTimeout(() => { - autoRotate = true; - }, 7000); // Longer timeout + autoRotate = true + }, 7000) // Longer timeout } - }; - canvas.addEventListener("mousemove", mouseMoveListener); + + // Find active skill based on mouse position + let closestNode = null + let closestDistance = Number.POSITIVE_INFINITY + + for (const node of skillNodes) { + const dx = node.x2d - e.clientX + const dy = node.y2d - e.clientY + const distance = Math.sqrt(dx * dx + dy * dy) + + if (distance < 30 && distance < closestDistance) { + closestDistance = distance + closestNode = node + } + } + + setActiveSkill(closestNode ? closestNode.name : null) + } + canvas.addEventListener("mousemove", mouseMoveListener) const draw = () => { - ctx.clearRect(0, 0, cssWidth, cssHeight); // 使用CSS尺寸清空 + ctx.clearRect(0, 0, cssWidth, cssHeight) // 使用CSS尺寸清空 if (autoRotate) { - targetRotation.y += 0.002; // 自动旋转速度 + targetRotation.y += 0.002 // 自动旋转速度 } - rotation.x += (targetRotation.x - rotation.x) * 0.05; // 平滑过渡 - rotation.y += (targetRotation.y - rotation.y) * 0.05; // 平滑过渡 + rotation.x += (targetRotation.x - rotation.x) * 0.05 // 平滑过渡 + rotation.y += (targetRotation.y - rotation.y) * 0.05 // 平滑过渡 - const cosX = Math.cos(rotation.x); - const sinX = Math.sin(rotation.x); - const cosY = Math.cos(rotation.y); - const sinY = Math.sin(rotation.y); + const cosX = Math.cos(rotation.x) + const sinX = Math.sin(rotation.x) + const cosY = Math.cos(rotation.y) + const sinY = Math.sin(rotation.y) - const currentCssCenter = {x: cssWidth / 2, y: cssHeight / 2}; - const cssSphereDisplayRadius = Math.min(cssWidth, cssHeight) * 0.35; // 球体在屏幕上的显示半径 + const currentCssCenter = {x: cssWidth / 2, y: cssHeight / 2} + const cssSphereDisplayRadius = Math.min(cssWidth, cssHeight) * 0.35 // 球体在屏幕上的显示半径 skillNodes.forEach((node) => { // 3D Rotation - const y_rotated = node.y3d * cosX - node.z3d * sinX; - const z_intermediate = node.y3d * sinX + node.z3d * cosX; - const x_final_model = node.x3d * cosY - z_intermediate * sinY; - const z_final_model = node.x3d * sinY + z_intermediate * cosY; - const y_final_model = y_rotated; + const y_rotated = node.y3d * cosX - node.z3d * sinX + const z_intermediate = node.y3d * sinX + node.z3d * cosX + const x_final_model = node.x3d * cosY - z_intermediate * sinY + const z_final_model = node.x3d * sinY + z_intermediate * cosY + const y_final_model = y_rotated // Perspective scale and opacity // z_final_model / modelSpaceRadius 将z值归一化到大约[-1, 1]范围 - node.scale = (z_final_model / modelSpaceRadius + 2.5) / 3.5; // 调整分母和加值改变透视和大小范围 - node.opacity = Math.max(0, (node.scale - 0.5) * 2); // 调整阈值使更多/更少的点可见 + node.scale = (z_final_model / modelSpaceRadius + 2.5) / 3.5 // 调整分母和加值改变透视和大小范围 + node.opacity = Math.max(0, (node.scale - 0.5) * 2) // 调整阈值使更多/更少的点可见 if (node.opacity > 0) { // Project to 2D CSS coordinates // (coord / modelRadius) * displayRadius: 将模型坐标按比例映射到显示半径 // * node.scale: 可选,应用透视缩放进一步影响位置(使远处的点更靠近中心) - node.x2d = currentCssCenter.x + (x_final_model / modelSpaceRadius) * cssSphereDisplayRadius * node.scale; - node.y2d = currentCssCenter.y + (y_final_model / modelSpaceRadius) * cssSphereDisplayRadius * node.scale; + node.x2d = currentCssCenter.x + (x_final_model / modelSpaceRadius) * cssSphereDisplayRadius * node.scale + node.y2d = currentCssCenter.y + (y_final_model / modelSpaceRadius) * cssSphereDisplayRadius * node.scale - ctx.save(); - ctx.globalAlpha = node.opacity; + ctx.save() + ctx.globalAlpha = node.opacity - const nodeSize = (8 + node.level / 15) * node.scale; // 调整基础大小和等级影响因子 + const nodeSize = (8 + node.level / 15) * node.scale // 调整基础大小和等级影响因子 - ctx.fillStyle = node.color; - ctx.beginPath(); - ctx.arc(node.x2d, node.y2d, nodeSize, 0, Math.PI * 2); - ctx.fill(); + // Draw glow effect + const gradient = ctx.createRadialGradient(node.x2d, node.y2d, 0, node.x2d, node.y2d, nodeSize * 2) + gradient.addColorStop(0, node.color + "80") // Semi-transparent + gradient.addColorStop(1, node.color + "00") // Transparent + + ctx.fillStyle = gradient + ctx.beginPath() + ctx.arc(node.x2d, node.y2d, nodeSize * 1.5, 0, Math.PI * 2) + ctx.fill() + + // Draw node + ctx.fillStyle = node.color + ctx.beginPath() + ctx.arc(node.x2d, node.y2d, nodeSize, 0, Math.PI * 2) + ctx.fill() + + // Add highlight + ctx.fillStyle = "#ffffff" + ctx.globalAlpha = 0.3 * node.opacity + ctx.beginPath() + ctx.arc(node.x2d - nodeSize * 0.3, node.y2d - nodeSize * 0.3, nodeSize * 0.3, 0, Math.PI * 2) + ctx.fill() // 仅当节点足够大时显示文字,避免拥挤 if (nodeSize > 5) { - ctx.font = `${Math.min(14, nodeSize * 1) * node.scale}px Arial`; // 字体大小也受透视影响 - ctx.fillStyle = theme === "dark" ? "#ffffff" : "#000000"; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillText(node.name, node.x2d, node.y2d + nodeSize + 5 * node.scale); // 文字略微向下偏移 + ctx.font = `${Math.min(14, nodeSize * 1) * node.scale}px Arial` // 字体大小也受透视影响 + ctx.fillStyle = theme === "dark" ? "#ffffff" : "#000000" + ctx.globalAlpha = node.opacity + ctx.textAlign = "center" + ctx.textBaseline = "middle" + ctx.fillText(node.name, node.x2d, node.y2d + nodeSize + 5 * node.scale) // 文字略微向下偏移 } - ctx.restore(); + ctx.restore() } - }); - animationFrameId = requestAnimationFrame(draw); - }; - - draw(); + }) + animationFrameId = requestAnimationFrame(draw) + } + draw() } catch (error) { - console.error("Failed to load GSAP or run animation:", error); + console.error("Failed to load GSAP or run animation:", error) } - }; + } - loadGsapAndAnimate(); + loadGsapAndAnimate() return () => { - cancelAnimationFrame(animationFrameId); - clearTimeout(autoRotateTimeout); - if (resizeListener) window.removeEventListener("resize", resizeListener); + cancelAnimationFrame(animationFrameId) + clearTimeout(autoRotateTimeout) + if (resizeListener) window.removeEventListener("resize", resizeListener) if (canvasRef.current && mouseMoveListener) { - canvasRef.current.removeEventListener("mousemove", mouseMoveListener); + canvasRef.current.removeEventListener("mousemove", mouseMoveListener) } - }; - }, [isInView, theme, skills]); // skills 作为依赖,尽管在此处是常量 + } + }, [isInView, theme, skills]) // skills 作为依赖,尽管在此处是常量 return (
@@ -281,46 +316,65 @@ export default function GsapSkillsTree() { {/* 技能分组说明 */}
{skillGroups.map((group, idx) => ( - setHoveredGroup(group.id)} + onMouseLeave={() => setHoveredGroup(null)} >
-

{group.name}

-
+
+ {group.icon} +

{group.name}

+
+
{skills .filter((skill) => skill.group === group.id) .slice(0, 4) // 显示前4个 .map((skill) => ( -
- {skill.name} -
-
-
-
- {skill.level}% +
+
+ {skill.name} + + `${Math.round(value)}%`} + /> + +
+
+
))} {skills.filter((skill) => skill.group === group.id).length > 4 && ( -
+ +{skills.filter((skill) => skill.group === group.id).length - 4} 更多技能 -
+ )}
- + ))}
) -} \ No newline at end of file +} diff --git a/src/components/ui/3d-card.tsx b/src/components/ui/3d-card.tsx new file mode 100644 index 0000000..9f4c491 --- /dev/null +++ b/src/components/ui/3d-card.tsx @@ -0,0 +1,108 @@ +"use client" + +import type React from "react" + +import { useState, useRef, type ReactNode } from "react" +import { motion, useMotionValue, useSpring, useTransform } from "framer-motion" +import { cn } from "@/lib/utils" + +interface ThreeDCardProps { + children: ReactNode + className?: string + glareColor?: string + borderColor?: string + backgroundGradient?: string + rotationIntensity?: number + glareIntensity?: number + shadowColor?: string +} + +export default function ThreeDCard({ + children, + className, + glareColor = "rgba(255, 255, 255, 0.4)", + borderColor = "rgba(255, 255, 255, 0.1)", + backgroundGradient = "radial-gradient(circle at center, rgba(255, 255, 255, 0.05) 0%, rgba(0, 0, 0, 0) 70%)", + rotationIntensity = 15, + glareIntensity = 0.5, + shadowColor = "rgba(0, 0, 0, 0.3)", + }: ThreeDCardProps) { + const cardRef = useRef(null) + const [isHovered, setIsHovered] = useState(false) + + // Motion values for rotation and glare effect + const mouseX = useMotionValue(0) + const mouseY = useMotionValue(0) + + // Smooth spring physics for rotation + const springConfig = { damping: 20, stiffness: 300 } + const rotateX = useSpring(useTransform(mouseY, [0, 1], [rotationIntensity, -rotationIntensity]), springConfig) + const rotateY = useSpring(useTransform(mouseX, [0, 1], [-rotationIntensity, rotationIntensity]), springConfig) + + // Glare effect position + const glareX = useSpring(mouseX, springConfig) + const glareY = useSpring(mouseY, springConfig) + const glareOpacity = useSpring(useMotionValue(0), { damping: 25, stiffness: 200 }) + + function handleMouseMove(e: React.MouseEvent) { + if (!cardRef.current) return + + const rect = cardRef.current.getBoundingClientRect() + const width = rect.width + const height = rect.height + + // Calculate normalized mouse position (0 to 1) + const normalizedX = Math.max(0, Math.min(1, (e.clientX - rect.left) / width)) + const normalizedY = Math.max(0, Math.min(1, (e.clientY - rect.top) / height)) + + mouseX.set(normalizedX) + mouseY.set(normalizedY) + } + + function handleMouseEnter() { + setIsHovered(true) + glareOpacity.set(glareIntensity) + } + + function handleMouseLeave() { + setIsHovered(false) + mouseX.set(0.5) + mouseY.set(0.5) + glareOpacity.set(0) + } + + return ( + + {/* Background gradient */} +
+ + {/* Glare effect */} + + + {/* Content */} +
{children}
+
+ ) +} diff --git a/src/components/ui/animated-counter.tsx b/src/components/ui/animated-counter.tsx new file mode 100644 index 0000000..2fd3baf --- /dev/null +++ b/src/components/ui/animated-counter.tsx @@ -0,0 +1,60 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import { useInView } from "framer-motion" + +interface AnimatedCounterProps { + from: number + to: number + duration?: number + delay?: number + formatter?: (value: number) => string + className?: string +} + +export default function AnimatedCounter({ + from, + to, + duration = 2, + delay = 0, + formatter = (value) => Math.round(value).toString(), + className, + }: AnimatedCounterProps) { + const [count, setCount] = useState(from) + const nodeRef = useRef(null) + const isInView = useInView(nodeRef, { once: true, amount: 0.5 }) + const [hasAnimated, setHasAnimated] = useState(false) + + useEffect(() => { + if (!isInView || hasAnimated) return + + let startTimestamp: number | null = null + const step = (timestamp: number) => { + if (!startTimestamp) startTimestamp = timestamp + + const progress = Math.min((timestamp - startTimestamp) / (duration * 1000), 1) + const currentCount = from + progress * (to - from) + + setCount(currentCount) + + if (progress < 1) { + window.requestAnimationFrame(step) + } else { + setHasAnimated(true) + } + } + + // Add delay before starting animation + const timer = setTimeout(() => { + window.requestAnimationFrame(step) + }, delay * 1000) + + return () => clearTimeout(timer) + }, [from, to, duration, delay, isInView, hasAnimated]) + + return ( + + {formatter(count)} + + ) +} diff --git a/src/components/ui/animated-divider.tsx b/src/components/ui/animated-divider.tsx new file mode 100644 index 0000000..48a6009 --- /dev/null +++ b/src/components/ui/animated-divider.tsx @@ -0,0 +1,128 @@ +"use client" + +import {useRef} from "react" +import {motion, useInView} from "framer-motion" +import {cn} from "@/lib/utils" + +interface AnimatedDividerProps { + className?: string + width?: string + height?: string + color?: string + animationDuration?: number + delay?: number + direction?: "horizontal" | "vertical" + pattern?: "solid" | "dashed" | "dotted" | "zigzag" | "wave" +} + +export default function AnimatedDivider({ + className, + width = "100%", + height = "2px", + color = "var(--primary)", + animationDuration = 1.5, + delay = 0, + direction = "horizontal", + pattern = "solid", + }: AnimatedDividerProps) { + const ref = useRef(null) + const isInView = useInView(ref, {once: true, amount: 0.5}) + + // Generate SVG path for patterns + const generatePath = () => { + if (direction === "horizontal") { + switch (pattern) { + case "zigzag": + return "M0,10 L10,0 L20,10 L30,0 L40,10 L50,0 L60,10 L70,0 L80,10 L90,0 L100,10" + case "wave": + return "M0,5 C25,-5 25,15 50,5 C75,-5 75,15 100,5" + default: + return "" + } + } else { + switch (pattern) { + case "zigzag": + return "M5,0 L0,10 L5,20 L0,30 L5,40 L0,50 L5,60 L0,70 L5,80 L0,90 L5,100" + case "wave": + return "M5,0 C-5,25 15,25 5,50 C-5,75 15,75 5,100" + default: + return "" + } + } + } + + // Render SVG for zigzag and wave patterns + if (pattern === "zigzag" || pattern === "wave") { + const svgWidth = direction === "horizontal" ? "100%" : height + const svgHeight = direction === "horizontal" ? height : "100%" + const viewBox = direction === "horizontal" ? "0 0 100 10" : "0 0 10 100" + const pathLength = 100 + + return ( +
+ + + +
+ ) + } + + // Render standard divider for solid, dashed, dotted + return ( +
+ +
+ ) +} diff --git a/src/components/ui/animated-gradient-text.tsx b/src/components/ui/animated-gradient-text.tsx new file mode 100644 index 0000000..f9e29ae --- /dev/null +++ b/src/components/ui/animated-gradient-text.tsx @@ -0,0 +1,59 @@ +"use client" + +import { useEffect, useRef } from "react" +import { motion } from "framer-motion" +import { cn } from "@/lib/utils" + +interface AnimatedGradientTextProps { + text: string + className?: string + gradient?: string + animate?: boolean + delay?: number +} + +export default function AnimatedGradientText({ + text, + className, + gradient = "from-blue-600 via-purple-600 to-blue-600", + animate = true, + delay = 0, + }: AnimatedGradientTextProps) { + const textRef = useRef(null) + + useEffect(() => { + if (!animate || !textRef.current) return + + const loadGsap = async () => { + const { gsap } = await import("gsap") + + gsap.to(textRef.current, { + backgroundPosition: "-200% center", + ease: "linear", + duration: 15, + repeat: -1, + delay, + }) + } + + loadGsap() + }, [animate, delay]) + + return ( + + {text} + + ) +} diff --git a/src/components/ui/cursor-spotlight.tsx b/src/components/ui/cursor-spotlight.tsx new file mode 100644 index 0000000..2f23c38 --- /dev/null +++ b/src/components/ui/cursor-spotlight.tsx @@ -0,0 +1,94 @@ +"use client" + +import type React from "react" + +import {useRef, useEffect} from "react" +import {motion, useMotionValue, useSpring} from "framer-motion" + +interface CursorSpotlightProps { + children: React.ReactNode + className?: string + size?: number + color?: string + blur?: number + opacity?: number + delay?: number +} + +export default function CursorSpotlight({ + children, + className = "", + size = 400, + color = "rgba(100, 150, 255, 0.15)", + blur = 100, + opacity = 0.8, + delay = 0.1, + }: CursorSpotlightProps) { + const containerRef = useRef(null) + + // Motion values for cursor position + const mouseX = useMotionValue(0) + const mouseY = useMotionValue(0) + + // Spring physics for smooth movement + const springConfig = {damping: 20, stiffness: 200, mass: 0.5} + const spotlightX = useSpring(mouseX, springConfig) + const spotlightY = useSpring(mouseY, springConfig) + + useEffect(() => { + if (!containerRef.current) return + + const container = containerRef.current + + const handleMouseMove = (e: MouseEvent) => { + const rect = container.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top + + mouseX.set(x) + mouseY.set(y) + } + + const handleMouseLeave = () => { + // Center the spotlight when mouse leaves + const rect = container.getBoundingClientRect() + mouseX.set(rect.width / 2) + mouseY.set(rect.height / 2) + } + + // Initialize position to center + handleMouseLeave() + + container.addEventListener("mousemove", handleMouseMove) + container.addEventListener("mouseleave", handleMouseLeave) + + return () => { + container.removeEventListener("mousemove", handleMouseMove) + container.removeEventListener("mouseleave", handleMouseLeave) + } + }, [mouseX, mouseY]) + + return ( +
+ {/* Spotlight effect */} + + + {/* Content */} +
{children}
+
+ ) +} diff --git a/src/components/ui/floating-icons.tsx b/src/components/ui/floating-icons.tsx new file mode 100644 index 0000000..b73696e --- /dev/null +++ b/src/components/ui/floating-icons.tsx @@ -0,0 +1,70 @@ +"use client" + +import type React from "react" + +import { useEffect, useRef } from "react" +import { motion } from "framer-motion" +import { useTheme } from "next-themes" + +interface FloatingIconsProps { + icons: React.ReactNode[] + className?: string +} + +export default function FloatingIcons({ icons, className }: FloatingIconsProps) { + const containerRef = useRef(null) + const { theme } = useTheme() + + useEffect(() => { + if (!containerRef.current) return + + const loadGsap = async () => { + const { gsap } = await import("gsap") + + const iconElements = containerRef.current?.querySelectorAll(".floating-icon") + + iconElements?.forEach((icon, index) => { + // Random initial position + gsap.set(icon, { + x: Math.random() * 100 - 50, + y: Math.random() * 100 - 50, + rotation: Math.random() * 20 - 10, + opacity: 0.7 + Math.random() * 0.3, + scale: 0.8 + Math.random() * 0.4, + }) + + // Floating animation + gsap.to(icon, { + x: `+=${Math.random() * 40 - 20}`, + y: `+=${Math.random() * 40 - 20}`, + rotation: `+=${Math.random() * 20 - 10}`, + opacity: 0.7 + Math.random() * 0.3, + scale: 0.8 + Math.random() * 0.4, + duration: 3 + Math.random() * 4, + ease: "sine.inOut", + repeat: -1, + yoyo: true, + delay: Math.random() * 2, + }) + }) + } + + loadGsap() + }, [icons.length, theme]) + + return ( +
+ {icons.map((icon, index) => ( + + {icon} + + ))} +
+ ) +} diff --git a/src/components/ui/magnetic-button.tsx b/src/components/ui/magnetic-button.tsx new file mode 100644 index 0000000..9acffcd --- /dev/null +++ b/src/components/ui/magnetic-button.tsx @@ -0,0 +1,91 @@ +"use client" + +import type React from "react" + +import { useState, useRef, type ReactNode } from "react" +import { motion, useMotionValue, useSpring, useTransform } from "framer-motion" +import { cn } from "@/lib/utils" + +interface MagneticButtonProps { + children: ReactNode + className?: string + strength?: number + onClick?: () => void + disabled?: boolean +} + +export default function MagneticButton({ + children, + className, + strength = 40, + onClick, + disabled = false, + }: MagneticButtonProps) { + const buttonRef = useRef(null) + const [isMagnetic, setIsMagnetic] = useState(true) + + // Motion values + const mouseX = useMotionValue(0) + const mouseY = useMotionValue(0) + + // Spring physics for smooth movement + const springConfig = { damping: 15, stiffness: 150 } + const x = useSpring(useTransform(mouseX, [0, 1], [-strength, strength]), springConfig) + const y = useSpring(useTransform(mouseY, [0, 1], [-strength, strength]), springConfig) + + // Handle mouse move + function handleMouseMove(e: React.MouseEvent) { + if (!buttonRef.current || disabled || !isMagnetic) return + + const rect = buttonRef.current.getBoundingClientRect() + const centerX = rect.left + rect.width / 2 + const centerY = rect.top + rect.height / 2 + + // Calculate distance from center + const distanceX = e.clientX - centerX + const distanceY = e.clientY - centerY + + // Normalize to -1 to 1 range + mouseX.set(distanceX / (rect.width / 2)) + mouseY.set(distanceY / (rect.height / 2)) + } + + // Reset position when mouse leaves + function handleMouseLeave() { + mouseX.set(0) + mouseY.set(0) + } + + // Temporarily disable magnetic effect on click + function handleClick() { + if (disabled) return + + setIsMagnetic(false) + mouseX.set(0) + mouseY.set(0) + + // Re-enable after animation completes + setTimeout(() => setIsMagnetic(true), 500) + + if (onClick) onClick() + } + + return ( + + {children} + + ) +} diff --git a/src/components/ui/particle-container.tsx b/src/components/ui/particle-container.tsx new file mode 100644 index 0000000..ceffeb9 --- /dev/null +++ b/src/components/ui/particle-container.tsx @@ -0,0 +1,266 @@ +"use client" + +import type React from "react" + +import {useRef, useEffect, useState, useMemo} from "react" +import {useTheme} from "next-themes" + +interface Particle { + x: number + y: number + size: number + speedX: number + speedY: number + color: string + opacity: number + life: number + maxLife: number +} + +interface ParticleContainerProps { + className?: string + particleCount?: number + particleSize?: [number, number] + particleSpeed?: number + particleLife?: [number, number] + colorPalette?: string[] + interactive?: boolean + interactionRadius?: number + interactionForce?: number + children?: React.ReactNode +} + +export default function ParticleContainer({ + className = "", + particleCount = 50, + particleSize = [1, 3], + particleSpeed = 0.5, + particleLife = [5, 10], + colorPalette, + interactive = true, + interactionRadius = 100, + interactionForce = 1, + children, + }: ParticleContainerProps) { + const containerRef = useRef(null) + const canvasRef = useRef(null) + const particlesRef = useRef([]) + const animationRef = useRef(0) + const mouseRef = useRef<{ x: number; y: number; active: boolean }>({x: 0, y: 0, active: false}) + const {theme} = useTheme() + const [dimensions, setDimensions] = useState({width: 0, height: 0}) + const [isInitialized, setIsInitialized] = useState(false) + + // Set default color palette based on theme - memoize to prevent recreation on every render + const defaultLightPalette = ["#3b82f6", "#8b5cf6", "#ec4899", "#10b981", "#f59e0b"] + const defaultDarkPalette = ["#60a5fa", "#a78bfa", "#f472b6", "#34d399", "#fbbf24"] + + const effectiveColorPalette = useMemo(() => { + return colorPalette || (theme === "dark" ? defaultDarkPalette : defaultLightPalette) + }, [colorPalette, theme, defaultLightPalette, defaultDarkPalette]) + + // Create a single particle + const createParticle = (width: number, height: number): Particle => { + const size = Math.random() * (particleSize[1] - particleSize[0]) + particleSize[0] + const maxLife = Math.random() * (particleLife[1] - particleLife[0]) + particleLife[0] + + return { + x: Math.random() * width, + y: Math.random() * height, + size, + speedX: (Math.random() - 0.5) * particleSpeed, + speedY: (Math.random() - 0.5) * particleSpeed, + color: effectiveColorPalette[Math.floor(Math.random() * effectiveColorPalette.length)], + opacity: Math.random() * 0.5 + 0.2, + life: 0, + maxLife, + } + } + + // Initialize particles + useEffect(() => { + if (!containerRef.current || isInitialized) return + + const {width, height} = containerRef.current.getBoundingClientRect() + + if (width === 0 || height === 0) return // Skip if container has no dimensions yet + + setDimensions({width, height}) + + // Initialize particles + particlesRef.current = Array.from({length: particleCount}, () => createParticle(width, height)) + + // Set canvas dimensions + if (canvasRef.current) { + canvasRef.current.width = width + canvasRef.current.height = height + } + + setIsInitialized(true) + }, [particleCount, isInitialized, particleSize, particleSpeed, particleLife, effectiveColorPalette]) + + // Animation loop + const animate = () => { + if (!canvasRef.current || !containerRef.current) return + + const canvas = canvasRef.current + const ctx = canvas.getContext("2d") + if (!ctx) return + + const {width, height} = dimensions + + // Clear canvas + ctx.clearRect(0, 0, width, height) + + // Performance optimization: only do full animation when in viewport + const rect = canvas.getBoundingClientRect() + const isVisible = + rect.bottom >= 0 && + rect.top <= (window.innerHeight || document.documentElement.clientHeight) && + rect.right >= 0 && + rect.left <= (window.innerWidth || document.documentElement.clientWidth) + + // If not in viewport, reduce frame rate + if (!isVisible) { + animationRef.current = requestAnimationFrame(() => { + setTimeout(animate, 100) // Reduce frame rate + }) + return + } + + // Update and draw particles + particlesRef.current.forEach((particle, index) => { + // Update position + particle.x += particle.speedX + particle.y += particle.speedY + + // Apply mouse interaction + if (interactive && mouseRef.current.active) { + const dx = particle.x - mouseRef.current.x + const dy = particle.y - mouseRef.current.y + const distance = Math.sqrt(dx * dx + dy * dy) + + if (distance < interactionRadius) { + const force = ((interactionRadius - distance) / interactionRadius) * interactionForce + particle.speedX += (dx / distance) * force * 0.2 + particle.speedY += (dy / distance) * force * 0.2 + } + } + + // Apply friction + particle.speedX *= 0.99 + particle.speedY *= 0.99 + + // Boundary check with bounce + if (particle.x < 0 || particle.x > width) { + particle.speedX *= -1 + particle.x = Math.max(0, Math.min(width, particle.x)) + } + if (particle.y < 0 || particle.y > height) { + particle.speedY *= -1 + particle.y = Math.max(0, Math.min(height, particle.y)) + } + + // Update life + particle.life += 0.01 + if (particle.life >= particle.maxLife) { + particlesRef.current[index] = createParticle(width, height) + } + + // Calculate opacity based on life + const lifeRatio = particle.life / particle.maxLife + const fadeInOut = + lifeRatio < 0.2 + ? lifeRatio * 5 // Fade in + : lifeRatio > 0.8 + ? (1 - lifeRatio) * 5 // Fade out + : 1 // Full opacity + + // Draw particle + ctx.beginPath() + ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2) + ctx.fillStyle = + particle.color + + Math.floor(particle.opacity * fadeInOut * 255) + .toString(16) + .padStart(2, "0") + ctx.fill() + }) + + animationRef.current = requestAnimationFrame(animate) + } + + // Start animation when initialized + useEffect(() => { + if (!isInitialized) return + + animationRef.current = requestAnimationFrame(animate) + + return () => { + cancelAnimationFrame(animationRef.current) + } + }, [isInitialized]) + + // Handle resize + useEffect(() => { + const handleResize = () => { + if (!containerRef.current) return + + const {width, height} = containerRef.current.getBoundingClientRect() + + if (width === 0 || height === 0) return // Skip if container has no dimensions + + setDimensions({width, height}) + + if (canvasRef.current) { + canvasRef.current.width = width + canvasRef.current.height = height + } + + // Reposition particles within new boundaries + particlesRef.current.forEach((particle) => { + particle.x = Math.min(particle.x, width) + particle.y = Math.min(particle.y, height) + }) + } + + window.addEventListener("resize", handleResize) + return () => window.removeEventListener("resize", handleResize) + }, []) + + // Handle mouse interaction + useEffect(() => { + if (!interactive || !containerRef.current) return + + const handleMouseMove = (e: MouseEvent) => { + if (!containerRef.current) return + + const rect = containerRef.current.getBoundingClientRect() + mouseRef.current = { + x: e.clientX - rect.left, + y: e.clientY - rect.top, + active: true, + } + } + + const handleMouseLeave = () => { + mouseRef.current.active = false + } + + const container = containerRef.current + container.addEventListener("mousemove", handleMouseMove) + container.addEventListener("mouseleave", handleMouseLeave) + + return () => { + container.removeEventListener("mousemove", handleMouseMove) + container.removeEventListener("mouseleave", handleMouseLeave) + } + }, [interactive]) + + return ( +
+ +
{children}
+
+ ) +} diff --git a/src/components/ui/scroll-reveal.tsx b/src/components/ui/scroll-reveal.tsx new file mode 100644 index 0000000..7a1a3dc --- /dev/null +++ b/src/components/ui/scroll-reveal.tsx @@ -0,0 +1,132 @@ +"use client" + +import React from "react" + +import {useRef, useEffect, useState} from "react" +import {motion, useInView, type Variants} from "framer-motion" + +type Direction = "up" | "down" | "left" | "right" | "none" +type Effect = "fade" | "slide" | "zoom" | "flip" | "none" + +interface ScrollRevealProps { + children: React.ReactNode + direction?: Direction + effect?: Effect + duration?: number + delay?: number + threshold?: number + once?: boolean + className?: string + staggerChildren?: boolean + staggerDelay?: number +} + +export default function ScrollReveal({ + children, + direction = "up", + effect = "fade", + duration = 0.5, + delay = 0, + threshold = 0.1, + once = true, + className = "", + staggerChildren = false, + staggerDelay = 0.1, + }: ScrollRevealProps) { + const ref = useRef(null) + const isInView = useInView(ref, {amount: threshold, once}) + const [childrenCount, setChildrenCount] = useState(0) + + // Count direct children for staggering + useEffect(() => { + if (ref.current && staggerChildren) { + setChildrenCount(ref.current.children.length) + } + }, [staggerChildren]) + + // Define animation variants based on direction and effect + const getVariants = (): Variants => { + // Initial state based on direction + const getInitialPosition = () => { + switch (direction) { + case "up": + return {y: 50} + case "down": + return {y: -50} + case "left": + return {x: 50} + case "right": + return {x: -50} + case "none": + return {} + } + } + + // Initial state based on effect + const getInitialEffect = () => { + switch (effect) { + case "fade": + return {opacity: 0} + case "zoom": + return {scale: 0.9, opacity: 0} + case "flip": + return {rotateX: 30, opacity: 0} + case "none": + return {} + default: + return {} + } + } + + return { + hidden: { + ...getInitialPosition(), + ...getInitialEffect(), + }, + visible: (i = 0) => ({ + x: 0, + y: 0, + scale: 1, + rotateX: 0, + opacity: 1, + transition: { + duration, + delay: delay + (staggerChildren ? i * staggerDelay : 0), + ease: [0.25, 0.1, 0.25, 1], // Cubic bezier for smooth easing + }, + }), + } + } + + // If staggering children, wrap each child in a motion.div + if (staggerChildren && React.Children.count(children) > 0) { + return ( + + {React.Children.map(children, (child, index) => ( + + {child} + + ))} + + ) + } + + // Otherwise, animate the container as a whole + return ( + + {children} + + ) +} diff --git a/src/components/ui/text-reveal-effect.tsx b/src/components/ui/text-reveal-effect.tsx new file mode 100644 index 0000000..ec277f9 --- /dev/null +++ b/src/components/ui/text-reveal-effect.tsx @@ -0,0 +1,89 @@ +"use client" + +import type React from "react" + +import { useEffect, useRef } from "react" +import { motion, useInView, useAnimation } from "framer-motion" + +interface TextRevealEffectProps { + text: string + className?: string + once?: boolean + delay?: number + duration?: number + as?: React.ElementType +} + +export default function TextRevealEffect({ + text, + className, + once = true, + delay = 0, + duration = 0.05, + as: Component = "div", + }: TextRevealEffectProps) { + const controls = useAnimation() + const ref = useRef(null) + const isInView = useInView(ref, { once, amount: 0.5 }) + + // Split text into words and characters + const words = text.split(" ") + + useEffect(() => { + if (isInView) { + controls.start("visible") + } else if (!once) { + controls.start("hidden") + } + }, [isInView, controls, once]) + + const wordVariants = { + hidden: {}, + visible: {}, + } + + const characterVariants = { + hidden: { + opacity: 0, + y: 20, + }, + visible: (i: number) => ({ + opacity: 1, + y: 0, + transition: { + duration: 0.4, + ease: [0.2, 0.65, 0.3, 0.9], + delay: delay + i * duration, + }, + }), + } + + return ( + + {words.map((word, wordIndex) => ( + + {word.split("").map((character, charIndex) => ( + + {character} + + ))} + {wordIndex < words.length - 1 &&  } + + ))} + + ) +} diff --git a/src/components/ui/typing-effect.tsx b/src/components/ui/typing-effect.tsx new file mode 100644 index 0000000..853e284 --- /dev/null +++ b/src/components/ui/typing-effect.tsx @@ -0,0 +1,132 @@ +"use client" + +import {useState, useEffect, useRef} from "react" +import {motion, useInView} from "framer-motion" + +interface TypingEffectProps { + text: string | string[] + className?: string + typingSpeed?: number + deletingSpeed?: number + delayBeforeDeleting?: number + delayBeforeTyping?: number + cursor?: boolean + cursorStyle?: string + cursorColor?: string + repeat?: boolean + onComplete?: () => void +} + +export default function TypingEffect({ + text, + className = "", + typingSpeed = 50, + deletingSpeed = 30, + delayBeforeDeleting = 1500, + delayBeforeTyping = 500, + cursor = true, + cursorStyle = "|", + cursorColor = "var(--primary)", + repeat = false, + onComplete, + }: TypingEffectProps) { + const [displayText, setDisplayText] = useState("") + const [currentIndex, setCurrentIndex] = useState(0) + const [isTyping, setIsTyping] = useState(true) + const [isDeleting, setIsDeleting] = useState(false) + const [isPaused, setIsPaused] = useState(false) + const [isComplete, setIsComplete] = useState(false) + const containerRef = useRef(null) + const isInView = useInView(containerRef, {once: true, amount: 0.5}) + + // Convert single string to array for consistent handling + const textArray = Array.isArray(text) ? text : [text] + + useEffect(() => { + if (!isInView || isComplete) return + + let timeout: NodeJS.Timeout + + const handleTyping = () => { + if (isPaused) return + + const currentText = textArray[currentIndex] + + if (isTyping) { + if (displayText.length < currentText.length) { + // Still typing + setDisplayText(currentText.substring(0, displayText.length + 1)) + timeout = setTimeout(handleTyping, typingSpeed) + } else { + // Finished typing + if (textArray.length === 1 || (currentIndex === textArray.length - 1 && !repeat)) { + // All done, no more texts to type + setIsComplete(true) + if (onComplete) onComplete() + } else { + // Pause before deleting + setIsPaused(true) + timeout = setTimeout(() => { + setIsPaused(false) + setIsDeleting(true) + }, delayBeforeDeleting) + } + } + } else if (isDeleting) { + if (displayText.length > 0) { + // Still deleting + setDisplayText(displayText.substring(0, displayText.length - 1)) + timeout = setTimeout(handleTyping, deletingSpeed) + } else { + // Finished deleting + setIsDeleting(false) + + // Move to next text or loop back + const nextIndex = (currentIndex + 1) % textArray.length + setCurrentIndex(nextIndex) + + // Pause before typing next text + setIsPaused(true) + timeout = setTimeout(() => { + setIsPaused(false) + setIsTyping(true) + }, delayBeforeTyping) + } + } + } + + timeout = setTimeout(handleTyping, 0) + + return () => clearTimeout(timeout) + }, [ + displayText, + currentIndex, + isTyping, + isDeleting, + isPaused, + isComplete, + isInView, + textArray, + typingSpeed, + deletingSpeed, + delayBeforeDeleting, + delayBeforeTyping, + repeat, + onComplete, + ]) + + return ( +
+ {displayText} + {cursor && !isComplete && ( + + {cursorStyle} + + )} +
+ ) +}