feat: enhance skills section with GSAP animations and dynamic random items
This commit is contained in:
parent
b7504d5acf
commit
0b4479beff
@ -140,14 +140,16 @@ export default function HomePage() {
|
||||
<AboutSection/>
|
||||
|
||||
{/* Skills Section with animation */}
|
||||
<SkillsSection/>
|
||||
{/*<SkillsSection/>*/}
|
||||
|
||||
<motion.div>
|
||||
<GsapSkillsTree/>
|
||||
</motion.div>
|
||||
|
||||
<GsapProjectsShowcase/>
|
||||
|
||||
{/* Projects Section with enhanced visuals */}
|
||||
<ProjectsSection/>
|
||||
{/*<ProjectsSection/>*/}
|
||||
|
||||
<LampContainer className="rounded-none">
|
||||
<motion.h1
|
||||
@ -159,7 +161,7 @@ export default function HomePage() {
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
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">
|
||||
|
||||
@ -8,6 +8,11 @@ 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";
|
||||
|
||||
const RandomItems = dynamic(() => (import('@/components/ui/random-items')), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function GsapPersonalIntro() {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
@ -15,6 +20,16 @@ export default function GsapPersonalIntro() {
|
||||
const imageRef = useRef<HTMLDivElement>(null)
|
||||
const {theme, resolvedTheme} = useTheme()
|
||||
|
||||
const [isDark, setIsDark] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (resolvedTheme === "dark") {
|
||||
setIsDark(true)
|
||||
} else {
|
||||
setIsDark(false)
|
||||
}
|
||||
}, [resolvedTheme]);
|
||||
|
||||
const {scrollYProgress} = useScroll({
|
||||
target: containerRef,
|
||||
offset: ["start start", "end start"],
|
||||
@ -100,7 +115,7 @@ export default function GsapPersonalIntro() {
|
||||
// 确保清理所有 ScrollTrigger 实例
|
||||
ScrollTrigger.getAll().forEach((trigger) => trigger.kill());
|
||||
};
|
||||
}, [theme, resolvedTheme]);
|
||||
}, [theme, resolvedTheme, isDark]);
|
||||
|
||||
const words = ["全栈开发者", "设计者", "创造者"]
|
||||
const [currentWord, setCurrentWord] = useState(0)
|
||||
@ -217,10 +232,10 @@ export default function GsapPersonalIntro() {
|
||||
<img
|
||||
src="https://dogeoss.grtsinry43.com/img/author-removebg.png"
|
||||
alt="grtsinry43"
|
||||
style={resolvedTheme === "dark" ? {filter: "brightness(0.8)"} : {}}
|
||||
style={isDark ? {filter: "brightness(0.8)"} : {}}
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
{resolvedTheme === "dark" && (
|
||||
{isDark && (
|
||||
<div
|
||||
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"/>
|
||||
<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"/>
|
||||
{[...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,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<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">
|
||||
|
||||
@ -67,7 +67,6 @@ export default function PersonalitySection() {
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<div className="absolute inset-0 z-0 overflow-hidden">
|
||||
|
||||
@ -321,7 +321,8 @@ export default function GsapProjectsShowcase() {
|
||||
|
||||
<div className="project-links flex gap-4">
|
||||
{project.githubUrl && (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Button asChild variant="outline" size="sm"
|
||||
className="bg-primary/10 text-primary border-primary/20">
|
||||
<a
|
||||
href={project.githubUrl}
|
||||
target="_blank"
|
||||
@ -334,7 +335,7 @@ export default function GsapProjectsShowcase() {
|
||||
</Button>
|
||||
)}
|
||||
{project.liveUrl && (
|
||||
<Button asChild size="sm">
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<a
|
||||
href={project.liveUrl}
|
||||
target="_blank"
|
||||
|
||||
@ -326,34 +326,6 @@ export default function GsapRhythmGamesSection() {
|
||||
点击游戏卡片,体验互动音符动画效果
|
||||
</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">
|
||||
<canvas ref={canvasRef} className="w-full h-full"/>
|
||||
|
||||
@ -8,7 +8,7 @@ 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)
|
||||
|
||||
// 技能数据
|
||||
@ -44,11 +44,12 @@ export default function GsapSkillsTree() {
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
// 检测元素是否在视口中
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
setIsInView(true)
|
||||
// 可选:确保只触发一次
|
||||
if (containerRef.current) observer.unobserve(containerRef.current);
|
||||
}
|
||||
},
|
||||
{threshold: 0.2},
|
||||
@ -66,172 +67,170 @@ export default function GsapSkillsTree() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInView) return
|
||||
if (!isInView) return;
|
||||
|
||||
// 动态导入GSAP以避免SSR问题
|
||||
const loadGsap = async () => {
|
||||
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
|
||||
|
||||
// 注册ScrollTrigger插件
|
||||
gsap.registerPlugin(ScrollTrigger)
|
||||
|
||||
if (!containerRef.current || !canvasRef.current) return
|
||||
|
||||
const container = containerRef.current
|
||||
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;
|
||||
|
||||
// 设置canvas尺寸
|
||||
const setCanvasSize = () => {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
canvas.width = rect.width * window.devicePixelRatio
|
||||
canvas.height = rect.height * window.devicePixelRatio
|
||||
ctx.scale(window.devicePixelRatio, window.devicePixelRatio)
|
||||
}
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
cssWidth = rect.width;
|
||||
cssHeight = rect.height;
|
||||
|
||||
setCanvasSize()
|
||||
window.addEventListener("resize", setCanvasSize)
|
||||
canvas.width = cssWidth * dpr;
|
||||
canvas.height = cssHeight * dpr;
|
||||
|
||||
// 创建3D技能球体
|
||||
const skillSphereRadius = Math.min(canvas.width, canvas.height) * 0.3
|
||||
const center = {x: canvas.width / 2, y: canvas.height / 2}
|
||||
ctx.resetTransform();
|
||||
ctx.scale(dpr, dpr);
|
||||
};
|
||||
|
||||
setCanvasSize(); // Initial size
|
||||
resizeListener = setCanvasSize; // Store for cleanup
|
||||
window.addEventListener("resize", resizeListener);
|
||||
|
||||
|
||||
const modelSpaceRadius = 100; // 抽象的模型空间半径
|
||||
|
||||
// 为每个技能分配3D位置
|
||||
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: skillSphereRadius * Math.cos(theta) * Math.sin(phi),
|
||||
y3d: skillSphereRadius * Math.sin(theta) * Math.sin(phi),
|
||||
z3d: skillSphereRadius * Math.cos(phi),
|
||||
x3d: modelSpaceRadius * Math.cos(theta) * Math.sin(phi),
|
||||
y3d: modelSpaceRadius * Math.sin(theta) * Math.sin(phi),
|
||||
z3d: modelSpaceRadius * Math.cos(phi),
|
||||
x2d: 0,
|
||||
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;
|
||||
|
||||
// 鼠标移动事件
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
mouseX = ((e.clientX - rect.left) / rect.width - 0.5) * 2
|
||||
mouseY = ((e.clientY - rect.top) / rect.height - 0.5) * 2
|
||||
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;
|
||||
|
||||
if (Math.abs(mouseX) > 0.1 || Math.abs(mouseY) > 0.1) {
|
||||
autoRotate = false
|
||||
targetRotation.x = mouseY * 0.5
|
||||
targetRotation.y = mouseX * 0.5
|
||||
|
||||
// 5秒后恢复自动旋转
|
||||
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
|
||||
}, 5000)
|
||||
}
|
||||
autoRotate = true;
|
||||
}, 7000); // Longer timeout
|
||||
}
|
||||
};
|
||||
canvas.addEventListener("mousemove", mouseMoveListener);
|
||||
|
||||
let autoRotateTimeout: NodeJS.Timeout
|
||||
canvas.addEventListener("mousemove", handleMouseMove)
|
||||
|
||||
// 绘制函数
|
||||
const draw = () => {
|
||||
// 清空画布
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.clearRect(0, 0, cssWidth, cssHeight); // 使用CSS尺寸清空
|
||||
|
||||
// 更新旋转
|
||||
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
|
||||
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);
|
||||
|
||||
// 计算3D旋转
|
||||
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; // 球体在屏幕上的显示半径
|
||||
|
||||
// 更新每个技能节点的位置
|
||||
skillNodes.forEach((node) => {
|
||||
// 应用旋转变换
|
||||
const x = node.x3d
|
||||
const y = node.y3d * cosX - node.z3d * sinX
|
||||
const z = node.y3d * sinX + node.z3d * cosX
|
||||
// 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 x2 = x * cosY - z * sinY
|
||||
const z2 = x * sinY + z * cosY
|
||||
// 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); // 调整阈值使更多/更少的点可见
|
||||
|
||||
// 投影到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) {
|
||||
ctx.save()
|
||||
ctx.globalAlpha = node.opacity
|
||||
// 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;
|
||||
|
||||
// 节点大小基于技能等级
|
||||
const nodeSize = (10 + node.level / 10) * scale
|
||||
ctx.save();
|
||||
ctx.globalAlpha = node.opacity;
|
||||
|
||||
// 绘制节点
|
||||
ctx.fillStyle = node.color
|
||||
ctx.beginPath()
|
||||
ctx.arc(node.x2d, node.y2d, nodeSize, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
const nodeSize = (8 + node.level / 15) * node.scale; // 调整基础大小和等级影响因子
|
||||
|
||||
// 绘制技能名称
|
||||
ctx.font = `${12 * scale}px Arial`
|
||||
ctx.fillStyle = theme === "dark" ? "#ffffff" : "#000000"
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "middle"
|
||||
ctx.fillText(node.name, node.x2d, node.y2d)
|
||||
ctx.fillStyle = node.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x2d, node.y2d, nodeSize, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
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); // 文字略微向下偏移
|
||||
}
|
||||
})
|
||||
|
||||
requestAnimationFrame(draw)
|
||||
ctx.restore();
|
||||
}
|
||||
});
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
// 开始动画
|
||||
draw()
|
||||
draw();
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
window.removeEventListener("resize", setCanvasSize)
|
||||
canvas.removeEventListener("mousemove", handleMouseMove)
|
||||
clearTimeout(autoRotateTimeout)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load GSAP:", error)
|
||||
}
|
||||
console.error("Failed to load GSAP or run animation:", error);
|
||||
}
|
||||
};
|
||||
|
||||
loadGsap()
|
||||
}, [isInView, theme])
|
||||
loadGsapAndAnimate();
|
||||
|
||||
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 (
|
||||
<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 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>
|
||||
|
||||
{/* 3D技能球体 */}
|
||||
<div className="relative w-full mb-16">
|
||||
{/* 3D技能球体容器 - 应用居中和宽高比 */}
|
||||
<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"}}/>
|
||||
|
||||
{/* 技能说明 */}
|
||||
<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">
|
||||
<p className="text-sm text-center">{activeSkill ? `${activeSkill}` : "移动鼠标探索技能球体"}</p>
|
||||
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-center">{activeSkill ? `${activeSkill}` : "移动鼠标探索技能球体"}</p>
|
||||
</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) => (
|
||||
<motion.div
|
||||
key={group.id}
|
||||
@ -290,31 +287,32 @@ export default function GsapSkillsTree() {
|
||||
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/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>
|
||||
<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">
|
||||
{skills
|
||||
.filter((skill) => skill.group === group.id)
|
||||
.slice(0, 4)
|
||||
.slice(0, 4) // 显示前4个
|
||||
.map((skill) => (
|
||||
<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="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
|
||||
className={`h-full rounded-full bg-gradient-to-r ${group.color}`}
|
||||
style={{width: `${skill.level}%`}}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
))}
|
||||
{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} 更多技能
|
||||
</div>
|
||||
)}
|
||||
|
||||
23
src/components/ui/random-items.tsx
Normal file
23
src/components/ui/random-items.tsx
Normal 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;
|
||||
Loading…
x
Reference in New Issue
Block a user