feat: enhance skills section with GSAP animations and dynamic random items

This commit is contained in:
grtsinry43 2025-05-07 00:34:56 +08:00
parent b7504d5acf
commit 0b4479beff
Signed by: grtsinry43
GPG Key ID: F3305FB3A978C934
7 changed files with 180 additions and 180 deletions

View File

@ -140,14 +140,16 @@ export default function HomePage() {
<AboutSection/> <AboutSection/>
{/* Skills Section with animation */} {/* Skills Section with animation */}
<SkillsSection/> {/*<SkillsSection/>*/}
<GsapSkillsTree/> <motion.div>
<GsapSkillsTree/>
</motion.div>
<GsapProjectsShowcase/> <GsapProjectsShowcase/>
{/* Projects Section with enhanced visuals */} {/* Projects Section with enhanced visuals */}
<ProjectsSection/> {/*<ProjectsSection/>*/}
<LampContainer className="rounded-none"> <LampContainer className="rounded-none">
<motion.h1 <motion.h1
@ -159,7 +161,7 @@ export default function HomePage() {
ease: "easeInOut", ease: "easeInOut",
}} }}
className="mt-8 bg-gradient-to-br from-slate-300 to-slate-500 py-4 className="mt-8 bg-gradient-to-br from-slate-300 to-slate-500 py-4
font-bold bg-clip-text text-center text-4xl tracking-tight text-transparent md:text-6xl" font-bold bg-clip-text text-center text-3xl tracking-tight text-transparent md:text-5xl"
> >
<p className="mt-4"> <p className="mt-4">

View File

@ -8,6 +8,11 @@ import {TextPlugin} from "gsap/TextPlugin"
import {Github, Mail, ExternalLink, Code, Heart, Coffee} from "lucide-react" import {Github, Mail, ExternalLink, Code, Heart, Coffee} from "lucide-react"
import {Button} from "@/components/ui/button" import {Button} from "@/components/ui/button"
import {useTheme} from "next-themes" import {useTheme} from "next-themes"
import dynamic from "next/dynamic";
const RandomItems = dynamic(() => (import('@/components/ui/random-items')), {
ssr: false,
});
export default function GsapPersonalIntro() { export default function GsapPersonalIntro() {
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
@ -15,6 +20,16 @@ export default function GsapPersonalIntro() {
const imageRef = useRef<HTMLDivElement>(null) const imageRef = useRef<HTMLDivElement>(null)
const {theme, resolvedTheme} = useTheme() const {theme, resolvedTheme} = useTheme()
const [isDark, setIsDark] = useState<boolean>(false)
useEffect(() => {
if (resolvedTheme === "dark") {
setIsDark(true)
} else {
setIsDark(false)
}
}, [resolvedTheme]);
const {scrollYProgress} = useScroll({ const {scrollYProgress} = useScroll({
target: containerRef, target: containerRef,
offset: ["start start", "end start"], offset: ["start start", "end start"],
@ -100,7 +115,7 @@ export default function GsapPersonalIntro() {
// 确保清理所有 ScrollTrigger 实例 // 确保清理所有 ScrollTrigger 实例
ScrollTrigger.getAll().forEach((trigger) => trigger.kill()); ScrollTrigger.getAll().forEach((trigger) => trigger.kill());
}; };
}, [theme, resolvedTheme]); }, [theme, resolvedTheme, isDark]);
const words = ["全栈开发者", "设计者", "创造者"] const words = ["全栈开发者", "设计者", "创造者"]
const [currentWord, setCurrentWord] = useState(0) const [currentWord, setCurrentWord] = useState(0)
@ -217,10 +232,10 @@ export default function GsapPersonalIntro() {
<img <img
src="https://dogeoss.grtsinry43.com/img/author-removebg.png" src="https://dogeoss.grtsinry43.com/img/author-removebg.png"
alt="grtsinry43" alt="grtsinry43"
style={resolvedTheme === "dark" ? {filter: "brightness(0.8)"} : {}} style={isDark ? {filter: "brightness(0.8)"} : {}}
className="w-full h-auto" className="w-full h-auto"
/> />
{resolvedTheme === "dark" && ( {isDark && (
<div <div
className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent"/> className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent"/>
)} )}
@ -236,17 +251,7 @@ export default function GsapPersonalIntro() {
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"/> 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 <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"/> 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"/>
{[...Array(10)].map((_, i) => ( <RandomItems/>
<div
key={i}
className="particle absolute w-3 h-3 rounded-full bg-primary/30"
style={{
top: `${20 + Math.random() * 60}%`,
left: `${Math.random() * 100}%`,
opacity: 0.3 + Math.random() * 0.7,
}}
/>
))}
</div> </div>
<div <div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full h-full -z-10"> className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full h-full -z-10">

View File

@ -67,7 +67,6 @@ export default function PersonalitySection() {
return ( return (
<section id="personality" ref={containerRef} className="relative min-h-screen py-24 md:py-32 overflow-hidden"> <section id="personality" ref={containerRef} className="relative min-h-screen py-24 md:py-32 overflow-hidden">
<div className="absolute inset-0 bg-neutral-50 dark:bg-neutral-950 z-0"/>
{/* Background elements */} {/* Background elements */}
<div className="absolute inset-0 z-0 overflow-hidden"> <div className="absolute inset-0 z-0 overflow-hidden">

View File

@ -321,7 +321,8 @@ export default function GsapProjectsShowcase() {
<div className="project-links flex gap-4"> <div className="project-links flex gap-4">
{project.githubUrl && ( {project.githubUrl && (
<Button asChild variant="outline" size="sm"> <Button asChild variant="outline" size="sm"
className="bg-primary/10 text-primary border-primary/20">
<a <a
href={project.githubUrl} href={project.githubUrl}
target="_blank" target="_blank"
@ -334,7 +335,7 @@ export default function GsapProjectsShowcase() {
</Button> </Button>
)} )}
{project.liveUrl && ( {project.liveUrl && (
<Button asChild size="sm"> <Button asChild size="sm" variant="ghost">
<a <a
href={project.liveUrl} href={project.liveUrl}
target="_blank" target="_blank"

View File

@ -326,34 +326,6 @@ export default function GsapRhythmGamesSection() {
</motion.p> </motion.p>
{/* 图标展示 */}
<div className="flex flex-wrap justify-center gap-8 mb-16">
<div className="icon-item flex flex-col items-center gap-2">
<div className="p-4 rounded-full bg-blue-500/10">
<Music className="h-8 w-8 text-blue-500"/>
</div>
<span className="text-sm font-medium"></span>
</div>
<div className="icon-item flex flex-col items-center gap-2">
<div className="p-4 rounded-full bg-purple-500/10">
<Gamepad2 className="h-8 w-8 text-purple-500"/>
</div>
<span className="text-sm font-medium"></span>
</div>
<div className="icon-item flex flex-col items-center gap-2">
<div className="p-4 rounded-full bg-green-500/10">
<Star className="h-8 w-8 text-green-500"/>
</div>
<span className="text-sm font-medium"></span>
</div>
<div className="icon-item flex flex-col items-center gap-2">
<div className="p-4 rounded-full bg-amber-500/10">
<Trophy className="h-8 w-8 text-amber-500"/>
</div>
<span className="text-sm font-medium"></span>
</div>
</div>
{/* 交互式音符动画画布 */} {/* 交互式音符动画画布 */}
<div className="relative h-[300px] mb-16"> <div className="relative h-[300px] mb-16">
<canvas ref={canvasRef} className="w-full h-full"/> <canvas ref={canvasRef} className="w-full h-full"/>

View File

@ -8,7 +8,7 @@ export default function GsapSkillsTree() {
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null) const canvasRef = useRef<HTMLCanvasElement>(null)
const {theme} = useTheme() const {theme} = useTheme()
const [activeSkill, setActiveSkill] = useState<string | null>(null) const [activeSkill, setActiveSkill] = useState<string | null>(null) // 状态已声明,但更新逻辑未在原始代码中提供
const [isInView, setIsInView] = useState(false) const [isInView, setIsInView] = useState(false)
// 技能数据 // 技能数据
@ -44,11 +44,12 @@ export default function GsapSkillsTree() {
] ]
useEffect(() => { useEffect(() => {
// 检测元素是否在视口中
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
if (entries[0].isIntersecting) { if (entries[0].isIntersecting) {
setIsInView(true) setIsInView(true)
// 可选:确保只触发一次
if (containerRef.current) observer.unobserve(containerRef.current);
} }
}, },
{threshold: 0.2}, {threshold: 0.2},
@ -66,172 +67,170 @@ export default function GsapSkillsTree() {
}, []) }, [])
useEffect(() => { useEffect(() => {
if (!isInView) return if (!isInView) return;
// 动态导入GSAP以避免SSR问题 let animationFrameId: number;
const loadGsap = async () => { let autoRotateTimeout: NodeJS.Timeout;
let resizeListener: () => void;
let mouseMoveListener: (e: MouseEvent) => void;
const loadGsapAndAnimate = async () => {
try { try {
const gsapModule = await import("gsap") const gsapModule = await import("gsap")
const ScrollTriggerModule = await import("gsap/ScrollTrigger") const ScrollTriggerModule = await import("gsap/ScrollTrigger")
const gsap = gsapModule.default const gsap = gsapModule.default
const ScrollTrigger = ScrollTriggerModule.default const ScrollTrigger = ScrollTriggerModule.default
// 注册ScrollTrigger插件
gsap.registerPlugin(ScrollTrigger) gsap.registerPlugin(ScrollTrigger)
if (!containerRef.current || !canvasRef.current) return if (!canvasRef.current) return;
const container = containerRef.current
const canvas = canvasRef.current const canvas = canvasRef.current
const ctx = canvas.getContext("2d") const ctx = canvas.getContext("2d")
if (!ctx) return if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
let cssWidth = 0;
let cssHeight = 0;
// 设置canvas尺寸
const setCanvasSize = () => { const setCanvasSize = () => {
const rect = canvas.getBoundingClientRect() const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * window.devicePixelRatio cssWidth = rect.width;
canvas.height = rect.height * window.devicePixelRatio cssHeight = rect.height;
ctx.scale(window.devicePixelRatio, window.devicePixelRatio)
}
setCanvasSize() canvas.width = cssWidth * dpr;
window.addEventListener("resize", setCanvasSize) canvas.height = cssHeight * dpr;
// 创建3D技能球体 ctx.resetTransform();
const skillSphereRadius = Math.min(canvas.width, canvas.height) * 0.3 ctx.scale(dpr, dpr);
const center = {x: canvas.width / 2, y: canvas.height / 2} };
setCanvasSize(); // Initial size
resizeListener = setCanvasSize; // Store for cleanup
window.addEventListener("resize", resizeListener);
const modelSpaceRadius = 100; // 抽象的模型空间半径
// 为每个技能分配3D位置
const skillNodes = skills.map((skill, index) => { const skillNodes = skills.map((skill, index) => {
const phi = Math.acos(-1 + (2 * index) / skills.length) const phi = Math.acos(-1 + (2 * index) / skills.length);
const theta = Math.sqrt(skills.length * Math.PI) * phi const theta = Math.sqrt(skills.length * Math.PI) * phi;
return { return {
...skill, ...skill,
x3d: skillSphereRadius * Math.cos(theta) * Math.sin(phi), x3d: modelSpaceRadius * Math.cos(theta) * Math.sin(phi),
y3d: skillSphereRadius * Math.sin(theta) * Math.sin(phi), y3d: modelSpaceRadius * Math.sin(theta) * Math.sin(phi),
z3d: skillSphereRadius * Math.cos(phi), z3d: modelSpaceRadius * Math.cos(phi),
x2d: 0, x2d: 0,
y2d: 0, y2d: 0,
scale: 0, scale: 0,
opacity: 0, opacity: 0,
} };
}) });
// 动画参数 const rotation = {x: 0, y: 0};
const rotation = {x: 0, y: 0} const targetRotation = {x: 0, y: 0};
const targetRotation = {x: 0, y: 0} let autoRotate = true;
let autoRotate = true let mouseX = 0;
let mouseX = 0 let mouseY = 0;
let mouseY = 0
// 鼠标移动事件 mouseMoveListener = (e: MouseEvent) => {
const handleMouseMove = (e: MouseEvent) => { const rect = canvas.getBoundingClientRect();
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 mouseX = ((e.clientX - rect.left) / rect.width - 0.5) * 2;
mouseY = ((e.clientY - rect.top) / rect.height - 0.5) * 2 mouseY = ((e.clientY - rect.top) / rect.height - 0.5) * 2;
if (Math.abs(mouseX) > 0.1 || Math.abs(mouseY) > 0.1) { if (Math.abs(mouseX) > 0.05 || Math.abs(mouseY) > 0.05) { // Reduced sensitivity
autoRotate = false autoRotate = false;
targetRotation.x = mouseY * 0.5 targetRotation.x = mouseY * 0.3; // Reduced rotation speed
targetRotation.y = mouseX * 0.5 targetRotation.y = mouseX * 0.3; // Reduced rotation speed
clearTimeout(autoRotateTimeout);
// 5秒后恢复自动旋转
clearTimeout(autoRotateTimeout)
autoRotateTimeout = setTimeout(() => { autoRotateTimeout = setTimeout(() => {
autoRotate = true autoRotate = true;
}, 5000) }, 7000); // Longer timeout
} }
} };
canvas.addEventListener("mousemove", mouseMoveListener);
let autoRotateTimeout: NodeJS.Timeout
canvas.addEventListener("mousemove", handleMouseMove)
// 绘制函数
const draw = () => { const draw = () => {
// 清空画布 ctx.clearRect(0, 0, cssWidth, cssHeight); // 使用CSS尺寸清空
ctx.clearRect(0, 0, canvas.width, canvas.height)
// 更新旋转
if (autoRotate) { if (autoRotate) {
targetRotation.y += 0.003 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 const cosX = Math.cos(rotation.x);
rotation.y += (targetRotation.y - rotation.y) * 0.05 const sinX = Math.sin(rotation.x);
const cosY = Math.cos(rotation.y);
const sinY = Math.sin(rotation.y);
// 计算3D旋转 const currentCssCenter = {x: cssWidth / 2, y: cssHeight / 2};
const cosX = Math.cos(rotation.x) const cssSphereDisplayRadius = Math.min(cssWidth, cssHeight) * 0.35; // 球体在屏幕上的显示半径
const sinX = Math.sin(rotation.x)
const cosY = Math.cos(rotation.y)
const sinY = Math.sin(rotation.y)
// 更新每个技能节点的位置
skillNodes.forEach((node) => { skillNodes.forEach((node) => {
// 应用旋转变换 // 3D Rotation
const x = node.x3d const y_rotated = node.y3d * cosX - node.z3d * sinX;
const y = node.y3d * cosX - node.z3d * sinX const z_intermediate = node.y3d * sinX + node.z3d * cosX;
const z = 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 x2 = x * cosY - z * sinY // Perspective scale and opacity
const z2 = x * sinY + z * cosY // 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); // 调整阈值使更多/更少的点可见
// 投影到2D
const scale = (z2 / skillSphereRadius + 2) / 3
node.x2d = center.x + x2 / window.devicePixelRatio
node.y2d = center.y + y / window.devicePixelRatio
node.scale = scale
node.opacity = (scale - 0.6) * 2.5
// 绘制技能节点
if (node.opacity > 0) { if (node.opacity > 0) {
ctx.save() // Project to 2D CSS coordinates
ctx.globalAlpha = node.opacity // (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;
// 节点大小基于技能等级 ctx.save();
const nodeSize = (10 + node.level / 10) * scale ctx.globalAlpha = node.opacity;
// 绘制节点 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()
// 绘制技能名称 ctx.fillStyle = node.color;
ctx.font = `${12 * scale}px Arial` ctx.beginPath();
ctx.fillStyle = theme === "dark" ? "#ffffff" : "#000000" ctx.arc(node.x2d, node.y2d, nodeSize, 0, Math.PI * 2);
ctx.textAlign = "center" ctx.fill();
ctx.textBaseline = "middle"
ctx.fillText(node.name, node.x2d, node.y2d)
ctx.restore() // 仅当节点足够大时显示文字,避免拥挤
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.restore();
} }
}) });
animationFrameId = requestAnimationFrame(draw);
};
requestAnimationFrame(draw) draw();
}
// 开始动画
draw()
// 清理函数
return () => {
window.removeEventListener("resize", setCanvasSize)
canvas.removeEventListener("mousemove", handleMouseMove)
clearTimeout(autoRotateTimeout)
}
} catch (error) { } catch (error) {
console.error("Failed to load GSAP:", error) console.error("Failed to load GSAP or run animation:", error);
} }
} };
loadGsap() loadGsapAndAnimate();
}, [isInView, theme])
return () => {
cancelAnimationFrame(animationFrameId);
clearTimeout(autoRotateTimeout);
if (resizeListener) window.removeEventListener("resize", resizeListener);
if (canvasRef.current && mouseMoveListener) {
canvasRef.current.removeEventListener("mousemove", mouseMoveListener);
}
};
}, [isInView, theme, skills]); // skills 作为依赖,尽管在此处是常量
return ( return (
<section id="skills" ref={containerRef} className="relative py-24 md:py-32 overflow-hidden bg-muted/30"> <section id="skills" ref={containerRef} className="relative py-24 md:py-32 overflow-hidden bg-muted/30">
{/* 背景效果 */}
<div className="absolute inset-0 -z-10"> <div className="absolute inset-0 -z-10">
<div <div
className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-primary/5 via-transparent to-transparent"/> className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-primary/5 via-transparent to-transparent"/>
@ -270,19 +269,17 @@ export default function GsapSkillsTree() {
- -
</motion.p> </motion.p>
{/* 3D技能球体 */} {/* 3D技能球体容器 - 应用居中和宽高比 */}
<div className="relative w-full mb-16"> <div className="relative w-full max-w-xl md:max-w-2xl aspect-square mx-auto mb-16">
<canvas ref={canvasRef} className="w-full h-full cursor-move" style={{touchAction: "none"}}/> <canvas ref={canvasRef} className="w-full h-full cursor-move" style={{touchAction: "none"}}/>
{/* 技能说明 */}
<div <div
className="absolute bottom-4 left-1/2 transform -translate-x-1/2 bg-card/80 backdrop-blur-sm p-4 rounded-xl border border-border/50 shadow-lg"> className="absolute bottom-4 left-1/2 transform -translate-x-1/2 bg-card/80 backdrop-blur-sm p-3 rounded-lg border border-border/50 shadow-lg text-xs md:text-sm">
<p className="text-sm text-center">{activeSkill ? `${activeSkill}` : "移动鼠标探索技能球体"}</p> <p className="text-center">{activeSkill ? `${activeSkill}` : "移动鼠标探索技能球体"}</p>
</div> </div>
</div> </div>
{/* 技能分组说明 */} {/* 技能分组说明 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8">
{skillGroups.map((group, idx) => ( {skillGroups.map((group, idx) => (
<motion.div <motion.div
key={group.id} key={group.id}
@ -290,31 +287,32 @@ export default function GsapSkillsTree() {
whileInView={{opacity: 1, y: 0}} whileInView={{opacity: 1, y: 0}}
transition={{duration: 0.6, delay: 0.2 + idx * 0.1}} transition={{duration: 0.6, delay: 0.2 + idx * 0.1}}
viewport={{once: true, amount: 0.3}} viewport={{once: true, amount: 0.3}}
className="bg-card/80 backdrop-blur-sm p-6 rounded-2xl border border-border/50 shadow-lg" className="bg-card/80 backdrop-blur-sm p-6 rounded-2xl border border-border/20 shadow-sm border-l-sky-200"
> >
<div className={`h-2 w-16 rounded-full bg-gradient-to-r ${group.color} mb-4`}></div> <div className={`h-2 w-16 rounded-full bg-gradient-to-r ${group.color} mb-4`}></div>
<h4 className="text-xl font-bold mb-3">{group.name}</h4> <h4 className="text-xl font-semibold mb-3 text-card-foreground">{group.name}</h4>
<div className="space-y-2"> <div className="space-y-2">
{skills {skills
.filter((skill) => skill.group === group.id) .filter((skill) => skill.group === group.id)
.slice(0, 4) .slice(0, 4) // 显示前4个
.map((skill) => ( .map((skill) => (
<div key={skill.name} className="flex items-center justify-between"> <div key={skill.name} className="flex items-center justify-between">
<span className="text-sm">{skill.name}</span> <span className="text-sm text-muted-foreground">{skill.name}</span>
<div className="flex items-center"> <div className="flex items-center">
<div className="h-1.5 w-24 bg-muted rounded-full overflow-hidden"> <div
className="h-1.5 w-20 md:w-24 bg-muted rounded-full overflow-hidden">
<div <div
className={`h-full rounded-full bg-gradient-to-r ${group.color}`} className={`h-full rounded-full bg-gradient-to-r ${group.color}`}
style={{width: `${skill.level}%`}} style={{width: `${skill.level}%`}}
/> />
</div> </div>
<span <span
className="text-xs text-muted-foreground ml-2">{skill.level}%</span> className="text-xs text-muted-foreground/80 ml-2 w-8 text-right">{skill.level}%</span>
</div> </div>
</div> </div>
))} ))}
{skills.filter((skill) => skill.group === group.id).length > 4 && ( {skills.filter((skill) => skill.group === group.id).length > 4 && (
<div className="text-xs text-right text-muted-foreground"> <div className="text-xs text-right text-muted-foreground/70 mt-2">
+{skills.filter((skill) => skill.group === group.id).length - 4} +{skills.filter((skill) => skill.group === group.id).length - 4}
</div> </div>
)} )}
@ -325,4 +323,4 @@ export default function GsapSkillsTree() {
</div> </div>
</section> </section>
) )
} }

View File

@ -0,0 +1,23 @@
"use client"
import React from 'react';
const RandomItems = () => {
return (
<>
{[...Array(10)].map((_, i) => (
<div
key={i}
className="particle absolute w-3 h-3 rounded-full bg-primary/30"
style={{
top: `${20 + Math.random() * 60}%`,
left: `${Math.random() * 100}%`,
opacity: 0.3 + Math.random() * 0.7,
}}
/>
))}
</>
);
};
export default RandomItems;