feat: add new animated components including particle container, typing effect, and scroll reveal
This commit is contained in:
parent
0b4479beff
commit
90e99c0c51
@ -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 */}
|
||||
<ContactSection/>
|
||||
|
||||
<FinalSection/>
|
||||
</main>
|
||||
|
||||
{/* Footer with subtle animation */}
|
||||
|
||||
660
src/components/sections/final-section.tsx
Normal file
660
src/components/sections/final-section.tsx
Normal file
@ -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<HTMLDivElement>(null)
|
||||
const textRef = useRef<HTMLDivElement>(null)
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const cardsRef = useRef<HTMLDivElement>(null)
|
||||
const ctaRef = useRef<HTMLDivElement>(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) => `<span class="thank-you-char relative inline-block">${char}</span>`)
|
||||
.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 (
|
||||
<section
|
||||
id="final"
|
||||
ref={containerRef}
|
||||
className="relative min-h-screen py-24 md:py-32 overflow-hidden perspective-[1000px]"
|
||||
>
|
||||
<ParticleContainer
|
||||
className="absolute inset-0"
|
||||
particleCount={40}
|
||||
particleSize={[1, 3]}
|
||||
particleLife={[8, 15]}
|
||||
interactive={true}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0">
|
||||
<div className="container px-4 mx-auto relative z-10">
|
||||
<motion.div style={{y, opacity}} className="flex flex-col items-center justify-center">
|
||||
{/* Stars animation */}
|
||||
<div className="flex items-center justify-center mb-6 relative h-20">
|
||||
<div className="star h-8 w-8 text-yellow-500 mx-1 absolute">
|
||||
<Star className="h-full w-full fill-yellow-500"/>
|
||||
</div>
|
||||
<div className="star h-10 w-10 text-yellow-500 mx-1 absolute"
|
||||
style={{left: "calc(50% - 30px)"}}>
|
||||
<Star className="h-full w-full fill-yellow-500"/>
|
||||
</div>
|
||||
<div className="star h-12 w-12 text-yellow-500 mx-1 absolute"
|
||||
style={{left: "calc(50% - 6px)"}}>
|
||||
<Star className="h-full w-full fill-yellow-500"/>
|
||||
</div>
|
||||
<div className="star h-10 w-10 text-yellow-500 mx-1 absolute"
|
||||
style={{left: "calc(50% + 20px)"}}>
|
||||
<Star className="h-full w-full fill-yellow-500"/>
|
||||
</div>
|
||||
<div className="star h-8 w-8 text-yellow-500 mx-1 absolute"
|
||||
style={{left: "calc(50% + 40px)"}}>
|
||||
<Star className="h-full w-full fill-yellow-500"/>
|
||||
</div>
|
||||
<div
|
||||
className="star h-6 w-6 text-yellow-500 mx-1 absolute"
|
||||
style={{left: "calc(50% - 50px)", top: "5px"}}
|
||||
>
|
||||
<Sparkles className="h-full w-full text-yellow-500"/>
|
||||
</div>
|
||||
<div
|
||||
className="star h-6 w-6 text-yellow-500 mx-1 absolute"
|
||||
style={{left: "calc(50% + 60px)", top: "10px"}}
|
||||
>
|
||||
<Sparkles className="h-full w-full text-yellow-500"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thank you text with character animation */}
|
||||
<div ref={textRef} className="mb-6 perspective-[1000px]">
|
||||
<h2 className="text-4xl md:text-6xl font-bold text-center mb-6 [transform-style:preserve-3d]">
|
||||
感谢您的浏览
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* SVG decoration */}
|
||||
<div className="relative w-full max-w-md h-20 mb-8">
|
||||
<svg ref={svgRef} viewBox="0 0 500 100" className="w-full h-full"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<path
|
||||
d="M20,50 C100,20 150,80 250,50 C350,20 400,80 480,50"
|
||||
stroke={theme === "dark" ? "#60a5fa" : "#3b82f6"}
|
||||
strokeWidth="3"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M20,70 C100,40 150,100 250,70 C350,40 400,100 480,70"
|
||||
stroke={theme === "dark" ? "#a78bfa" : "#8b5cf6"}
|
||||
strokeWidth="3"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M250,10 L250,90"
|
||||
stroke={theme === "dark" ? "#f472b6" : "#ec4899"}
|
||||
strokeWidth="3"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="5,5"
|
||||
/>
|
||||
<circle
|
||||
cx="250"
|
||||
cy="50"
|
||||
r="8"
|
||||
stroke={theme === "dark" ? "#f472b6" : "#ec4899"}
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
animate={isInView ? {opacity: 1, y: 0} : {}}
|
||||
transition={{duration: 0.8, delay: 0.6}}
|
||||
>
|
||||
<TypingEffect
|
||||
text={["希望您喜欢我的个人网站", "期待与您合作", "随时联系我"]}
|
||||
className="text-xl md:text-2xl text-center text-muted-foreground mb-8"
|
||||
typingSpeed={70}
|
||||
repeat={true}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<AnimatedDivider width="200px" height="2px" pattern="wave" className="mb-12" delay={0.7}/>
|
||||
|
||||
{/* Cards with 3D effect */}
|
||||
<div
|
||||
ref={cardsRef}
|
||||
className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-16 max-w-4xl mx-auto [transform-style:preserve-3d]"
|
||||
>
|
||||
<ThreeDCard
|
||||
className="card-item p-6 rounded-xl bg-card/80 backdrop-blur-sm border border-border/50 text-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
||||
<Mail className="h-6 w-6 text-primary"/>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2">联系我</h3>
|
||||
<p className="text-muted-foreground mb-4">随时通过邮件联系我,我会尽快回复</p>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a href="mailto:grtsinry43@outlook.com" className="flex items-center gap-2">
|
||||
发送邮件 <ArrowRight className="h-4 w-4"/>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</ThreeDCard>
|
||||
|
||||
<ThreeDCard
|
||||
className="card-item p-6 rounded-xl bg-card/80 backdrop-blur-sm border border-border/50 text-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
||||
<Github className="h-6 w-6 text-primary"/>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2">查看代码</h3>
|
||||
<p className="text-muted-foreground mb-4">访问我的GitHub查看更多项目和代码</p>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a
|
||||
href="https://github.com/grtsinry43"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
GitHub <ArrowRight className="h-4 w-4"/>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</ThreeDCard>
|
||||
|
||||
<ThreeDCard
|
||||
className="card-item p-6 rounded-xl bg-card/80 backdrop-blur-sm border border-border/50 text-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
||||
<ExternalLink className="h-6 w-6 text-primary"/>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2">访问博客</h3>
|
||||
<p className="text-muted-foreground mb-4">阅读我的技术博客,了解更多内容</p>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a
|
||||
href="https://blog.grtsinry43.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
博客 <ArrowRight className="h-4 w-4"/>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</ThreeDCard>
|
||||
</div>
|
||||
|
||||
{/* CTA section with advanced animations */}
|
||||
<div ref={ctaRef} className="text-center mb-12 relative">
|
||||
<div className="absolute inset-0 -z-10 opacity-30 blur-xl">
|
||||
<div
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-3/4 h-3/4 rounded-full bg-gradient-to-r from-blue-500/30 to-purple-500/30"></div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-2xl font-semibold mb-4">让我们保持联系</h3>
|
||||
<p className="text-muted-foreground mb-6 max-w-xl mx-auto">
|
||||
无论您是想讨论项目合作,还是只是想打个招呼,我都很乐意听到您的声音。
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
<MagneticButton strength={15}>
|
||||
<Button asChild size="lg" className="social-link relative overflow-hidden group">
|
||||
<a href="#contact" className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5 transition-transform group-hover:scale-110"/>
|
||||
<span className="relative z-10">联系我</span>
|
||||
<span
|
||||
className="absolute inset-0 bg-gradient-to-r from-blue-600 to-blue-500 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></span>
|
||||
</a>
|
||||
</Button>
|
||||
</MagneticButton>
|
||||
|
||||
<MagneticButton strength={15}>
|
||||
<Button asChild variant="outline" size="lg"
|
||||
className="social-link relative overflow-hidden group">
|
||||
<a href="#" className="flex items-center gap-2">
|
||||
<Download className="h-5 w-5 transition-transform group-hover:rotate-12"/>
|
||||
<span>下载简历</span>
|
||||
<span
|
||||
className="absolute inset-0 bg-gradient-to-r from-blue-500/10 to-purple-500/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></span>
|
||||
</a>
|
||||
</Button>
|
||||
</MagneticButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
animate={isInView ? {opacity: 1, y: 0} : {}}
|
||||
transition={{duration: 0.8, delay: 1.2}}
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<motion.div
|
||||
className="flex items-center text-muted-foreground"
|
||||
whileHover={{scale: 1.05}}
|
||||
transition={{type: "spring", stiffness: 400, damping: 10}}
|
||||
>
|
||||
<span>用</span>
|
||||
<Heart className="heart-icon h-5 w-5 text-red-500 mx-2 inline-block"/>
|
||||
<span>制作</span>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Interactive elements that respond to mouse movement */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div
|
||||
className="absolute w-40 h-40 rounded-full bg-gradient-to-r from-blue-500/5 to-purple-500/5 blur-3xl"
|
||||
style={{
|
||||
left: `calc(50% + ${mousePosition.x * 100}px)`,
|
||||
top: `calc(50% + ${mousePosition.y * 100}px)`,
|
||||
transition: "left 0.3s ease-out, top 0.3s ease-out",
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -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<HTMLDivElement>(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 = [
|
||||
<Code key="code" className="h-6 w-6 text-blue-500"/>,
|
||||
<Heart key="heart" className="h-6 w-6 text-pink-500"/>,
|
||||
<Coffee key="coffee" className="h-6 w-6 text-amber-500"/>,
|
||||
<Github key="github" className="h-6 w-6 text-purple-500"/>,
|
||||
<Mail key="mail" className="h-6 w-6 text-green-500"/>,
|
||||
]
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
className="relative py-24 md:py-32 overflow-hidden"
|
||||
style={{y, scale, opacity}}
|
||||
>
|
||||
<motion.div ref={containerRef} className="relative py-24 md:py-32 overflow-hidden" style={{y, scale, opacity}}>
|
||||
{/* Background elements */}
|
||||
<div className="absolute inset-0 -z-10">
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-b from-background via-background/80 to-background opacity-20"/>
|
||||
<div className="absolute top-0 left-0 w-full h-full overflow-hidden">
|
||||
<div className="parallax absolute top-10 left-1/4 w-64 h-64 rounded-full bg-primary/5 blur-3xl"/>
|
||||
<div
|
||||
className="parallax absolute bottom-20 right-1/5 w-96 h-96 rounded-full bg-blue-500/5 blur-3xl"/>
|
||||
<div
|
||||
className="parallax absolute top-1/3 right-1/4 w-80 h-80 rounded-full bg-emerald-500/5 blur-3xl"/>
|
||||
<motion.div
|
||||
className="parallax absolute top-10 left-1/4 w-64 h-64 rounded-full bg-primary/5 blur-3xl"
|
||||
animate={{
|
||||
x: [0, 20, 0],
|
||||
y: [0, -30, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 15,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
className="parallax absolute bottom-20 right-1/5 w-96 h-96 rounded-full bg-blue-500/5 blur-3xl"
|
||||
animate={{
|
||||
x: [0, -40, 0],
|
||||
y: [0, 20, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 20,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
className="parallax absolute top-1/3 right-1/4 w-80 h-80 rounded-full bg-emerald-500/5 blur-3xl"
|
||||
animate={{
|
||||
x: [0, 30, 0],
|
||||
y: [0, 40, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 18,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -150,10 +235,18 @@ export default function GsapPersonalIntro() {
|
||||
<div className="grid grid-cols-1 mt-12 lg:grid-cols-2 lg:mt-0 gap-12 items-center">
|
||||
<div ref={textRef} className="space-y-8">
|
||||
<div>
|
||||
<h3 className="text-3xl md:text-4xl font-bold tracking-tight mb-4">
|
||||
你好,我是 <p className="text-primary text-7xl">grtsinry43</p>
|
||||
</h3>
|
||||
<h4 className="text-2xl md:text-3xl font-semibold text-foreground/80">
|
||||
<TextRevealEffect
|
||||
text="你好,我是"
|
||||
className="text-3xl md:text-4xl font-bold tracking-tight mb-4"
|
||||
delay={0.2}
|
||||
/>
|
||||
<AnimatedGradientText
|
||||
text="grtsinry43"
|
||||
className="text-7xl font-bold tracking-tight"
|
||||
gradient="from-blue-600 via-purple-600 to-blue-600"
|
||||
delay={0.5}
|
||||
/>
|
||||
<h4 className="text-2xl md:text-3xl font-semibold text-foreground/80 mt-4">
|
||||
热爱生活的{" "}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.span
|
||||
@ -176,59 +269,85 @@ export default function GsapPersonalIntro() {
|
||||
我相信良好的用户体验和干净的代码同样重要。
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<div className="flex items-center gap-2 icon-item">
|
||||
<motion.div
|
||||
className="flex items-center gap-2 icon-item"
|
||||
whileHover={{scale: 1.05, x: 5}}
|
||||
transition={{type: "spring", stiffness: 400, damping: 10}}
|
||||
>
|
||||
<div className="p-2 rounded-full bg-primary/10">
|
||||
<Code className="h-5 w-5 text-primary"/>
|
||||
</div>
|
||||
<span>开发者</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 icon-item">
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="flex items-center gap-2 icon-item"
|
||||
whileHover={{scale: 1.05, x: 5}}
|
||||
transition={{type: "spring", stiffness: 400, damping: 10}}
|
||||
>
|
||||
<div className="p-2 rounded-full bg-pink-500/10">
|
||||
<Heart className="h-5 w-5 text-pink-500"/>
|
||||
</div>
|
||||
<span>热爱创造</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 icon-item">
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="flex items-center gap-2 icon-item"
|
||||
whileHover={{scale: 1.05, x: 5}}
|
||||
transition={{type: "spring", stiffness: 400, damping: 10}}
|
||||
>
|
||||
<div className="p-2 rounded-full bg-amber-500/10">
|
||||
<Coffee className="h-5 w-5 text-amber-500"/>
|
||||
</div>
|
||||
<span>咖啡爱好者</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Button asChild variant="default" className="rounded-full">
|
||||
<a
|
||||
href="https://github.com/grtsinry43"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Github className="h-5 w-5"/>
|
||||
GitHub
|
||||
</a>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="rounded-full">
|
||||
<a href="mailto:grtsinry43@outlook.com" className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5"/>
|
||||
联系我
|
||||
</a>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" className="rounded-full">
|
||||
<a
|
||||
href="https://blog.grtsinry43.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="h-5 w-5"/>
|
||||
博客
|
||||
</a>
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-4 ml-2">
|
||||
<MagneticButton strength={20}>
|
||||
<Button asChild variant="default" className="rounded-full">
|
||||
<a
|
||||
href="https://github.com/grtsinry43"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Github className="h-5 w-5"/>
|
||||
GitHub
|
||||
</a>
|
||||
</Button>
|
||||
</MagneticButton>
|
||||
|
||||
<MagneticButton strength={20}>
|
||||
<Button asChild variant="outline" className="rounded-full">
|
||||
<a href="mailto:grtsinry43@outlook.com" className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5"/>
|
||||
联系我
|
||||
</a>
|
||||
</Button>
|
||||
</MagneticButton>
|
||||
|
||||
<MagneticButton strength={20}>
|
||||
<Button asChild variant="ghost" className="rounded-full">
|
||||
<a
|
||||
href="https://blog.grtsinry43.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="h-5 w-5"/>
|
||||
博客
|
||||
</a>
|
||||
</Button>
|
||||
</MagneticButton>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={imageRef} className="relative">
|
||||
<div className="relative z-10 mx-auto max-w-md">
|
||||
<div className="profile-image relative rounded-2xl overflow-hidden">
|
||||
<motion.div
|
||||
className="profile-image relative rounded-2xl"
|
||||
initial={{opacity: 0, y: 20}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.8, delay: 0.3}}
|
||||
whileHover={{scale: 1.03}}
|
||||
>
|
||||
<img
|
||||
src="https://dogeoss.grtsinry43.com/img/author-removebg.png"
|
||||
alt="grtsinry43"
|
||||
@ -239,19 +358,16 @@ export default function GsapPersonalIntro() {
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent"/>
|
||||
)}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 p-6 transform translate-y-full hover:translate-y-0 transition-transform duration-300">
|
||||
<h4 className="text-xl font-bold text-white mb-2">grtsinry43</h4>
|
||||
<p className="text-white/80">全栈开发者 & 设计爱好者</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<FloatingIcons icons={floatingIcons} className="absolute inset-0 z-20 pointer-events-none"/>
|
||||
|
||||
<div
|
||||
className="shape absolute -top-6 -right-6 w-12 h-12 rounded-lg bg-gradient-to-br from-blue-500 to-purple-500 rotate-12 shadow-lg"/>
|
||||
<div
|
||||
className="shape absolute -bottom-8 -left-8 w-16 h-16 rounded-full bg-gradient-to-br from-emerald-500 to-green-500 shadow-lg"/>
|
||||
<div
|
||||
className="shape absolute top-1/2 -right-12 w-10 h-10 rounded-md bg-gradient-to-br from-amber-500 to-orange-500 rotate-45 shadow-lg"/>
|
||||
<RandomItems/>
|
||||
</div>
|
||||
<div
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full h-full -z-10">
|
||||
@ -263,4 +379,4 @@ export default function GsapPersonalIntro() {
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const {theme} = useTheme()
|
||||
const [activeSkill, setActiveSkill] = useState<string | null>(null) // 状态已声明,但更新逻辑未在原始代码中提供
|
||||
const [activeSkill, setActiveSkill] = useState<string | null>(null)
|
||||
const [isInView, setIsInView] = useState(false)
|
||||
const [hoveredGroup, setHoveredGroup] = useState<string | null>(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 (
|
||||
<section id="skills" ref={containerRef} className="relative py-24 md:py-32 overflow-hidden bg-muted/30">
|
||||
@ -281,46 +316,65 @@ export default function GsapSkillsTree() {
|
||||
{/* 技能分组说明 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8">
|
||||
{skillGroups.map((group, idx) => (
|
||||
<motion.div
|
||||
<ThreeDCard
|
||||
key={group.id}
|
||||
initial={{opacity: 0, y: 30}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.6, delay: 0.2 + idx * 0.1}}
|
||||
viewport={{once: true, amount: 0.3}}
|
||||
className="bg-card/80 backdrop-blur-sm p-6 rounded-2xl border border-border/20 shadow-sm border-l-sky-200"
|
||||
className={`p-6 rounded-2xl border-l-4 ${
|
||||
hoveredGroup === group.id
|
||||
? "border-l-primary"
|
||||
: `border-l-${group.color.split(" ")[0].replace("from-", "")}`
|
||||
}`}
|
||||
glareColor={`rgba(${group.id === "web" ? "100, 150, 255" : group.id === "backend" ? "100, 200, 150" : "255, 150, 100"}, 0.3)`}
|
||||
rotationIntensity={10}
|
||||
onMouseEnter={() => setHoveredGroup(group.id)}
|
||||
onMouseLeave={() => setHoveredGroup(null)}
|
||||
>
|
||||
<div className={`h-2 w-16 rounded-full bg-gradient-to-r ${group.color} mb-4`}></div>
|
||||
<h4 className="text-xl font-semibold mb-3 text-card-foreground">{group.name}</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-2xl">{group.icon}</span>
|
||||
<h4 className="text-xl font-semibold text-card-foreground">{group.name}</h4>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{skills
|
||||
.filter((skill) => skill.group === group.id)
|
||||
.slice(0, 4) // 显示前4个
|
||||
.map((skill) => (
|
||||
<div key={skill.name} className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">{skill.name}</span>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="h-1.5 w-20 md:w-24 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full bg-gradient-to-r ${group.color}`}
|
||||
style={{width: `${skill.level}%`}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className="text-xs text-muted-foreground/80 ml-2 w-8 text-right">{skill.level}%</span>
|
||||
<div key={skill.name} className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">{skill.name}</span>
|
||||
<span className="text-xs text-muted-foreground/80 font-medium">
|
||||
<AnimatedCounter
|
||||
from={0}
|
||||
to={skill.level}
|
||||
duration={1.5}
|
||||
delay={idx * 0.2}
|
||||
formatter={(value) => `${Math.round(value)}%`}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full bg-muted rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className={`h-full rounded-full bg-gradient-to-r ${group.color}`}
|
||||
initial={{width: 0}}
|
||||
whileInView={{width: `${skill.level}%`}}
|
||||
transition={{duration: 1.5, delay: idx * 0.2 + 0.2}}
|
||||
viewport={{once: true}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{skills.filter((skill) => skill.group === group.id).length > 4 && (
|
||||
<div className="text-xs text-right text-muted-foreground/70 mt-2">
|
||||
<motion.div
|
||||
className="text-xs text-right text-muted-foreground/70 mt-2"
|
||||
whileHover={{x: 5, color: "var(--primary)"}}
|
||||
>
|
||||
+{skills.filter((skill) => skill.group === group.id).length - 4} 更多技能
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</ThreeDCard>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
108
src/components/ui/3d-card.tsx
Normal file
108
src/components/ui/3d-card.tsx
Normal file
@ -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<HTMLDivElement>(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<HTMLDivElement>) {
|
||||
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 (
|
||||
<motion.div
|
||||
ref={cardRef}
|
||||
className={cn("relative overflow-hidden rounded-xl transition-all duration-200", className)}
|
||||
style={{
|
||||
perspective: "1200px",
|
||||
transformStyle: "preserve-3d",
|
||||
rotateX,
|
||||
rotateY,
|
||||
border: `1px solid ${borderColor}`,
|
||||
boxShadow: isHovered ? `0 20px 40px ${shadowColor}` : `0 10px 20px ${shadowColor}`,
|
||||
}}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{/* Background gradient */}
|
||||
<div className="absolute inset-0 z-0" style={{ background: backgroundGradient }} />
|
||||
|
||||
{/* Glare effect */}
|
||||
<motion.div
|
||||
className="absolute inset-0 z-10 rounded-xl"
|
||||
style={{
|
||||
background: `radial-gradient(circle at ${useTransform(glareX, [0, 1], ["0%", "100%"])} ${useTransform(glareY, [0, 1], ["0%", "100%"])}, ${glareColor} 0%, rgba(255, 255, 255, 0) 80%)`,
|
||||
opacity: glareOpacity,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-20">{children}</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
60
src/components/ui/animated-counter.tsx
Normal file
60
src/components/ui/animated-counter.tsx
Normal file
@ -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<HTMLSpanElement>(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 (
|
||||
<span ref={nodeRef} className={className}>
|
||||
{formatter(count)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
128
src/components/ui/animated-divider.tsx
Normal file
128
src/components/ui/animated-divider.tsx
Normal file
@ -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<HTMLDivElement>(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 (
|
||||
<div ref={ref} className={cn("overflow-hidden", className)} style={{width, height}}>
|
||||
<motion.svg
|
||||
width={svgWidth}
|
||||
height={svgHeight}
|
||||
viewBox={viewBox}
|
||||
preserveAspectRatio="none"
|
||||
initial={{pathLength: 0, opacity: 0}}
|
||||
animate={isInView ? {pathLength: 1, opacity: 1} : {pathLength: 0, opacity: 0}}
|
||||
transition={{duration: animationDuration, delay, ease: "easeInOut"}}
|
||||
>
|
||||
<motion.path
|
||||
d={generatePath()}
|
||||
stroke={color}
|
||||
strokeWidth="1"
|
||||
fill="none"
|
||||
strokeDasharray={pathLength}
|
||||
strokeDashoffset={pathLength}
|
||||
animate={isInView ? {strokeDashoffset: 0} : {strokeDashoffset: pathLength}}
|
||||
transition={{duration: animationDuration, delay, ease: "easeInOut"}}
|
||||
/>
|
||||
</motion.svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render standard divider for solid, dashed, dotted
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("overflow-hidden", className)}
|
||||
style={{
|
||||
width: direction === "horizontal" ? width : height,
|
||||
height: direction === "horizontal" ? height : width,
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: color,
|
||||
borderStyle: pattern === "solid" ? "none" : pattern,
|
||||
borderWidth: pattern === "solid" ? 0 : "1px",
|
||||
borderColor: pattern === "solid" ? "transparent" : color,
|
||||
transformOrigin: direction === "horizontal" ? "left center" : "center top",
|
||||
scaleX: direction === "horizontal" ? 0 : 1,
|
||||
scaleY: direction === "vertical" ? 0 : 1,
|
||||
}}
|
||||
initial={{
|
||||
scaleX: direction === "horizontal" ? 0 : 1,
|
||||
scaleY: direction === "vertical" ? 0 : 1,
|
||||
}}
|
||||
animate={
|
||||
isInView
|
||||
? {
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
}
|
||||
: {
|
||||
scaleX: direction === "horizontal" ? 0 : 1,
|
||||
scaleY: direction === "vertical" ? 0 : 1,
|
||||
}
|
||||
}
|
||||
transition={{duration: animationDuration, delay, ease: "easeInOut"}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
59
src/components/ui/animated-gradient-text.tsx
Normal file
59
src/components/ui/animated-gradient-text.tsx
Normal file
@ -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<HTMLDivElement>(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 (
|
||||
<motion.div
|
||||
ref={textRef}
|
||||
className={cn(
|
||||
"inline-block font-bold bg-clip-text text-transparent",
|
||||
`bg-gradient-to-r ${gradient}`,
|
||||
"bg-[length:200%_auto]",
|
||||
className,
|
||||
)}
|
||||
style={{ backgroundPosition: "0% center" }}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: delay * 0.1 }}
|
||||
>
|
||||
{text}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
94
src/components/ui/cursor-spotlight.tsx
Normal file
94
src/components/ui/cursor-spotlight.tsx
Normal file
@ -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<HTMLDivElement>(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 (
|
||||
<div ref={containerRef} className={`relative overflow-hidden ${className}`}>
|
||||
{/* Spotlight effect */}
|
||||
<motion.div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
x: spotlightX,
|
||||
y: spotlightY,
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: "50%",
|
||||
background: color,
|
||||
filter: `blur(${blur}px)`,
|
||||
opacity,
|
||||
transform: "translate(-50%, -50%)",
|
||||
transition: `opacity ${delay}s ease`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
70
src/components/ui/floating-icons.tsx
Normal file
70
src/components/ui/floating-icons.tsx
Normal file
@ -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<HTMLDivElement>(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 (
|
||||
<div ref={containerRef} className={className}>
|
||||
{icons.map((icon, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="floating-icon absolute"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
>
|
||||
{icon}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
91
src/components/ui/magnetic-button.tsx
Normal file
91
src/components/ui/magnetic-button.tsx
Normal file
@ -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<HTMLButtonElement>(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<HTMLButtonElement>) {
|
||||
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 (
|
||||
<motion.button
|
||||
ref={buttonRef}
|
||||
className={cn(
|
||||
"relative overflow-hidden transition-colors",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
style={{ x, y }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={handleClick}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</motion.button>
|
||||
)
|
||||
}
|
||||
266
src/components/ui/particle-container.tsx
Normal file
266
src/components/ui/particle-container.tsx
Normal file
@ -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<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const particlesRef = useRef<Particle[]>([])
|
||||
const animationRef = useRef<number>(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 (
|
||||
<div ref={containerRef} className={`relative overflow-hidden ${className}`}>
|
||||
<canvas ref={canvasRef} className="absolute inset-0 pointer-events-none z-0"/>
|
||||
<div className="relative z-10">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
132
src/components/ui/scroll-reveal.tsx
Normal file
132
src/components/ui/scroll-reveal.tsx
Normal file
@ -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<HTMLDivElement>(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 (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
className={className}
|
||||
initial="hidden"
|
||||
animate={isInView ? "visible" : "hidden"}
|
||||
variants={getVariants()}
|
||||
>
|
||||
{React.Children.map(children, (child, index) => (
|
||||
<motion.div variants={getVariants()} custom={index} className="scroll-reveal-child">
|
||||
{child}
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// Otherwise, animate the container as a whole
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
className={className}
|
||||
initial="hidden"
|
||||
animate={isInView ? "visible" : "hidden"}
|
||||
variants={getVariants()}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
89
src/components/ui/text-reveal-effect.tsx
Normal file
89
src/components/ui/text-reveal-effect.tsx
Normal file
@ -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<HTMLDivElement>(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 (
|
||||
<Component className={className} ref={ref}>
|
||||
{words.map((word, wordIndex) => (
|
||||
<motion.span
|
||||
key={`word-${wordIndex}`}
|
||||
className="inline-block whitespace-nowrap"
|
||||
variants={wordVariants}
|
||||
initial="hidden"
|
||||
animate={controls}
|
||||
aria-hidden
|
||||
>
|
||||
{word.split("").map((character, charIndex) => (
|
||||
<motion.span
|
||||
key={`char-${charIndex}`}
|
||||
className="inline-block"
|
||||
variants={characterVariants}
|
||||
initial="hidden"
|
||||
animate={controls}
|
||||
custom={wordIndex * 0.25 + charIndex}
|
||||
>
|
||||
{character}
|
||||
</motion.span>
|
||||
))}
|
||||
{wordIndex < words.length - 1 && <span> </span>}
|
||||
</motion.span>
|
||||
))}
|
||||
</Component>
|
||||
)
|
||||
}
|
||||
132
src/components/ui/typing-effect.tsx
Normal file
132
src/components/ui/typing-effect.tsx
Normal file
@ -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<HTMLDivElement>(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 (
|
||||
<div ref={containerRef} className={className}>
|
||||
<span>{displayText}</span>
|
||||
{cursor && !isComplete && (
|
||||
<motion.span
|
||||
style={{color: cursorColor}}
|
||||
animate={{opacity: [1, 0]}}
|
||||
transition={{duration: 0.8, repeat: Number.POSITIVE_INFINITY, repeatType: "reverse"}}
|
||||
>
|
||||
{cursorStyle}
|
||||
</motion.span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user