更新显示效果
This commit is contained in:
82
package-lock.json
generated
82
package-lock.json
generated
@@ -13,13 +13,16 @@
|
||||
"lucide-react": "^1.6.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"three": "^0.183.2",
|
||||
"vanta": "^0.5.24"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/three": "^0.183.1",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"eslint": "^9.39.4",
|
||||
@@ -286,6 +289,13 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dimforge/rapier3d-compat": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
|
||||
@@ -906,6 +916,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
@@ -961,6 +978,36 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/stats.js": {
|
||||
"version": "0.17.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/three": {
|
||||
"version": "0.183.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz",
|
||||
"integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
"@types/stats.js": "*",
|
||||
"@types/webxr": ">=0.5.17",
|
||||
"@webgpu/types": "*",
|
||||
"fflate": "~0.8.2",
|
||||
"meshoptimizer": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/webxr": {
|
||||
"version": "0.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.57.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz",
|
||||
@@ -1282,6 +1329,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@webgpu/types": {
|
||||
"version": "0.1.69",
|
||||
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz",
|
||||
"integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
@@ -2040,6 +2094,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
@@ -2786,6 +2847,13 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/meshoptimizer": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz",
|
||||
"integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/micromatch": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
@@ -3603,6 +3671,12 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.183.2",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz",
|
||||
"integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -3765,6 +3839,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vanta": {
|
||||
"version": "0.5.24",
|
||||
"resolved": "https://registry.npmjs.org/vanta/-/vanta-0.5.24.tgz",
|
||||
"integrity": "sha512-fvieEbHy1ZS23zrcX+topzqAgA4Uct1enngOEWLFBgs9TtOf6RDFOYatH7KSVdrABzQDMCQ5myQy+nTSZZwLzg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.2.tgz",
|
||||
|
||||
@@ -15,13 +15,16 @@
|
||||
"lucide-react": "^1.6.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"three": "^0.183.2",
|
||||
"vanta": "^0.5.24"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/three": "^0.183.1",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"eslint": "^9.39.4",
|
||||
|
||||
20
src/App.tsx
20
src/App.tsx
@@ -4,17 +4,21 @@ import { TechStack } from './components/TechStack';
|
||||
import { Projects } from './components/Projects';
|
||||
import { Capabilities } from './components/Capabilities';
|
||||
import { Contact } from './components/Contact';
|
||||
import { AnimatedBackground } from './components/AnimatedBackground';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<main className="w-full bg-background text-slate-100 min-h-screen font-sans selection:bg-primary/30 selection:text-white pb-10">
|
||||
<Hero />
|
||||
<About />
|
||||
<Capabilities />
|
||||
<TechStack />
|
||||
<Projects />
|
||||
<Contact />
|
||||
</main>
|
||||
<div className="relative w-full min-h-screen">
|
||||
<AnimatedBackground />
|
||||
<main className="relative z-10 w-full text-slate-100 min-h-screen font-sans selection:bg-primary/30 selection:text-white pb-10">
|
||||
<Hero />
|
||||
<About />
|
||||
<Capabilities />
|
||||
<TechStack />
|
||||
<Projects />
|
||||
<Contact />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
225
src/components/AnimatedBackground.tsx
Normal file
225
src/components/AnimatedBackground.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
size: number;
|
||||
opacity: number;
|
||||
hue: number;
|
||||
pulse: number;
|
||||
pulseSpeed: number;
|
||||
}
|
||||
|
||||
interface WaveBlob {
|
||||
x: number;
|
||||
y: number;
|
||||
radius: number;
|
||||
hue: number;
|
||||
speed: number;
|
||||
angle: number;
|
||||
}
|
||||
|
||||
// 极简、优雅、现代的颜色搭配 (更暗、更有质感)
|
||||
const SECTION_THEMES = [
|
||||
{ hues: [220, 240, 210] }, // Hero: 经典的深蓝/紫罗兰
|
||||
{ hues: [250, 270, 230] }, // About: 偏神秘的深紫
|
||||
{ hues: [200, 210, 220] }, // Capabilities: 清透的深墨蓝
|
||||
{ hues: [190, 200, 180] }, // TechStack: 深邃的青绿/蓝松石
|
||||
{ hues: [230, 250, 210] }, // Projects: 靛蓝与深紫交织
|
||||
{ hues: [220, 230, 240] }, // Contact: 回归深海蓝
|
||||
];
|
||||
|
||||
export function AnimatedBackground() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const scrollRef = useRef(0);
|
||||
const mouseRef = useRef({ x: -999, y: -999 });
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
let W = window.innerWidth;
|
||||
let H = window.innerHeight;
|
||||
|
||||
const resize = () => { W = window.innerWidth; H = window.innerHeight; canvas.width = W; canvas.height = H; };
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
window.addEventListener('mousemove', (e) => { mouseRef.current = { x: e.clientX, y: e.clientY }; });
|
||||
window.addEventListener('scroll', () => { scrollRef.current = window.scrollY; }, { passive: true });
|
||||
|
||||
// 光斑 (Auroras) - 更大、更缓慢
|
||||
const BLOB_COUNT = 5;
|
||||
const blobs: WaveBlob[] = Array.from({ length: BLOB_COUNT }, (_, i) => ({
|
||||
x: (W / BLOB_COUNT) * i + W / (BLOB_COUNT * 2),
|
||||
y: Math.random() * H * 0.8 + H * 0.1,
|
||||
radius: Math.random() * 300 + 200, // 更大的光斑
|
||||
hue: SECTION_THEMES[0].hues[i % 3],
|
||||
speed: Math.random() * 0.0003 + 0.0001, // 移动极度缓慢
|
||||
angle: Math.random() * Math.PI * 2,
|
||||
}));
|
||||
|
||||
// 环境粒子 (萤火虫/星光) - 减少数量,更精细
|
||||
const PARTICLE_COUNT = 80;
|
||||
const particles: Particle[] = Array.from({ length: PARTICLE_COUNT }, () => ({
|
||||
x: Math.random() * W,
|
||||
y: Math.random() * H,
|
||||
vx: (Math.random() - 0.5) * 0.15, // 速度极慢
|
||||
vy: (Math.random() - 0.5) * 0.15,
|
||||
size: Math.random() * 1.5 + 0.5,
|
||||
opacity: Math.random() * 0.5 + 0.1,
|
||||
hue: 220,
|
||||
pulse: Math.random() * Math.PI * 2,
|
||||
pulseSpeed: Math.random() * 0.01 + 0.003,
|
||||
}));
|
||||
|
||||
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||
let currentHues = [...SECTION_THEMES[0].hues, ...SECTION_THEMES[0].hues];
|
||||
let t = 0;
|
||||
|
||||
// 平滑滚动的插值
|
||||
let smoothScrollTarget = 0;
|
||||
let animationId: number;
|
||||
|
||||
const draw = () => {
|
||||
t += 0.004; // 极其缓慢的时间流逝
|
||||
|
||||
// 增加滑动阻尼感,让颜色渐变极其丝滑,不会因为快速滑动而突兀
|
||||
smoothScrollTarget = lerp(smoothScrollTarget, scrollRef.current, 0.02);
|
||||
|
||||
const pageH = Math.max(1, document.documentElement.scrollHeight - window.innerHeight);
|
||||
const scrollFrac = smoothScrollTarget / pageH; // 取值 0~1
|
||||
const rawIdx = Math.max(0, Math.min(scrollFrac * (SECTION_THEMES.length - 1), SECTION_THEMES.length - 1));
|
||||
const sectionA = Math.floor(rawIdx);
|
||||
const sectionB = Math.min(sectionA + 1, SECTION_THEMES.length - 1);
|
||||
const blend = rawIdx - sectionA;
|
||||
|
||||
for (let i = 0; i < BLOB_COUNT; i++) {
|
||||
const hA = SECTION_THEMES[sectionA].hues[i % 3];
|
||||
const hB = SECTION_THEMES[sectionB].hues[i % 3];
|
||||
currentHues[i] = lerp(currentHues[i], lerp(hA, hB, blend), 0.015);
|
||||
}
|
||||
|
||||
const domHue = lerp(SECTION_THEMES[sectionA].hues[0], SECTION_THEMES[sectionB].hues[0], blend);
|
||||
|
||||
// 深邃底色
|
||||
ctx.fillStyle = '#030305';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// ── 绘制环境极光光斑 (Auroras) ──
|
||||
for (let i = 0; i < blobs.length; i++) {
|
||||
const blob = blobs[i];
|
||||
blob.angle += blob.speed;
|
||||
|
||||
// 光斑的微小位移
|
||||
const dx = Math.cos(blob.angle * 0.7) * 40;
|
||||
const dy = Math.sin(blob.angle) * 30;
|
||||
|
||||
// 鼠标对光斑产生微弱吸引,增加沉浸感
|
||||
const mx = mouseRef.current.x - blob.x;
|
||||
const my = mouseRef.current.y - blob.y;
|
||||
const dist = Math.sqrt(mx * mx + my * my);
|
||||
if (dist < 600) {
|
||||
blob.x += mx * 0.00003;
|
||||
blob.y += my * 0.00003;
|
||||
}
|
||||
|
||||
blob.hue = lerp(blob.hue, currentHues[i], 0.03);
|
||||
|
||||
const cx = blob.x + dx;
|
||||
const cy = blob.y + dy;
|
||||
const r = blob.radius + Math.sin(blob.angle * 2) * 20;
|
||||
|
||||
const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
|
||||
// 使用非常非常低的透明度,打造"高级的高斯模糊"感觉
|
||||
const alpha = 0.04 + Math.sin(t + blob.angle) * 0.01;
|
||||
grad.addColorStop(0, `hsla(${blob.hue}, 70%, 50%, ${alpha})`);
|
||||
grad.addColorStop(0.5, `hsla(${blob.hue + 15}, 60%, 45%, ${alpha * 0.5})`);
|
||||
grad.addColorStop(1, 'transparent');
|
||||
|
||||
ctx.beginPath();
|
||||
// 压扁椭圆形更像极光
|
||||
ctx.ellipse(cx, cy, r * 1.5, r * 0.8, blob.angle * 0.1, 0, Math.PI * 2);
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// ── 绘制细微星光粒子 (Particles) ──
|
||||
for (const p of particles) {
|
||||
p.x += p.vx; p.y += p.vy; p.pulse += p.pulseSpeed;
|
||||
|
||||
// 边界循环
|
||||
if (p.x < -10) p.x = W + 10;
|
||||
if (p.x > W + 10) p.x = -10;
|
||||
if (p.y < -10) p.y = H + 10;
|
||||
if (p.y > H + 10) p.y = -10;
|
||||
|
||||
// 鼠标排斥极其微弱
|
||||
const mx = mouseRef.current.x;
|
||||
const my = mouseRef.current.y;
|
||||
const mdx = p.x - mx, mdy = p.y - my;
|
||||
const mdist = Math.sqrt(mdx * mdx + mdy * mdy);
|
||||
if (mdist < 150 && mdist > 0) {
|
||||
const force = (150 - mdist) / 150;
|
||||
p.vx += (mdx / mdist) * force * 0.01;
|
||||
p.vy += (mdy / mdist) * force * 0.01;
|
||||
}
|
||||
|
||||
// 阻尼,让其缓慢恢复原速
|
||||
p.vx *= 0.99; p.vy *= 0.99;
|
||||
|
||||
// 粒子受环境主色调影响
|
||||
p.hue = lerp(p.hue, domHue + Math.sin(p.pulse * 0.5) * 15, 0.02);
|
||||
|
||||
const po = p.opacity * (0.5 + 0.5 * Math.sin(p.pulse));
|
||||
const ps = p.size;
|
||||
|
||||
// 星光耀斑
|
||||
const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, ps * 3);
|
||||
glow.addColorStop(0, `hsla(${p.hue}, 80%, 75%, ${po * 0.8})`);
|
||||
glow.addColorStop(1, `hsla(${p.hue}, 80%, 75%, 0)`);
|
||||
|
||||
ctx.beginPath(); ctx.arc(p.x, p.y, ps * 3, 0, Math.PI * 2);
|
||||
ctx.fillStyle = glow; ctx.fill();
|
||||
|
||||
// 核心实心点
|
||||
ctx.beginPath(); ctx.arc(p.x, p.y, ps * 0.5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `hsla(${p.hue}, 90%, 85%, ${po})`;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// ── 鼠标氛围聚光灯 ──
|
||||
if (mouseRef.current.x > -900) {
|
||||
const mx = mouseRef.current.x;
|
||||
const my = mouseRef.current.y;
|
||||
const spotlight = ctx.createRadialGradient(mx, my, 0, mx, my, 350);
|
||||
spotlight.addColorStop(0, `hsla(${domHue}, 60%, 60%, 0.025)`);
|
||||
spotlight.addColorStop(0.5, `hsla(${domHue}, 60%, 50%, 0.01)`);
|
||||
spotlight.addColorStop(1, 'transparent');
|
||||
ctx.beginPath();
|
||||
ctx.arc(mx, my, 350, 0, Math.PI * 2);
|
||||
ctx.fillStyle = spotlight;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
animationId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
animationId = requestAnimationFrame(draw);
|
||||
return () => {
|
||||
cancelAnimationFrame(animationId);
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="fixed inset-0 w-full h-full pointer-events-none"
|
||||
style={{ zIndex: 0 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -41,11 +41,11 @@ export const Hero = () => {
|
||||
|
||||
<motion.h1
|
||||
variants={itemVariants}
|
||||
className="text-5xl md:text-7xl lg:text-8xl font-semibold tracking-tight text-white mb-6 leading-[1.1]"
|
||||
className="text-5xl md:text-7xl lg:text-8xl font-semibold tracking-tight text-white mb-6 leading-[1.1] text-glow"
|
||||
>
|
||||
构建 <br className="hidden md:block" />
|
||||
<span className="text-gradient">稳健架构</span> 与 <br className="hidden md:block" />
|
||||
<span className="text-gradient">极简交互</span>.
|
||||
<span className="text-shimmer">稳健架构</span> 与 <br className="hidden md:block" />
|
||||
<span className="text-shimmer">极简交互</span>.
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Section } from './ui/Section';
|
||||
import { Card } from './ui/Card';
|
||||
import { TiltCard } from './ui/TiltCard';
|
||||
import { ExternalLink, Code } from 'lucide-react';
|
||||
import { Button } from './ui/Button';
|
||||
|
||||
|
||||
const projects = [
|
||||
{
|
||||
title: '分布式云存储系统',
|
||||
@@ -48,7 +49,7 @@ export const Projects = () => {
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full max-w-5xl mx-auto">
|
||||
{projects.map((project) => (
|
||||
<Card key={project.title} glow className="flex flex-col h-full">
|
||||
<TiltCard key={project.title} glow className="flex flex-col h-full">
|
||||
<h3 className="text-2xl font-semibold text-white mb-2">{project.title}</h3>
|
||||
<p className="text-slate-400 text-sm mb-6 flex-grow">{project.description}</p>
|
||||
|
||||
@@ -80,7 +81,7 @@ export const Projects = () => {
|
||||
<Code size={16} /> 查看源码
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</TiltCard>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
136
src/components/ui/TiltCard.tsx
Normal file
136
src/components/ui/TiltCard.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React, { useRef, useState, useCallback } from 'react';
|
||||
import { cn } from '../../utils';
|
||||
|
||||
interface TiltCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
glow?: boolean;
|
||||
tiltMax?: number;
|
||||
}
|
||||
|
||||
export const TiltCard = React.forwardRef<HTMLDivElement, TiltCardProps>(
|
||||
({ className, children, glow = false, tiltMax = 14, style, ...props }, _ref) => {
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const frameRef = useRef<number>(0);
|
||||
|
||||
const [tilt, setTilt] = useState({ rotX: 0, rotY: 0, scale: 1 });
|
||||
const [glare, setGlare] = useState({ x: 50, y: 50, opacity: 0 });
|
||||
// normalized -1..1 mouse position for the border gradient
|
||||
const [border, setBorder] = useState({ x: 0.5, y: 0.5, opacity: 0 });
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const card = cardRef.current;
|
||||
if (!card) return;
|
||||
const rect = card.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
const nx = x / rect.width; // 0..1
|
||||
const ny = y / rect.height;
|
||||
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
setTilt({
|
||||
rotX: -(ny - 0.5) * tiltMax * 2,
|
||||
rotY: (nx - 0.5) * tiltMax * 2,
|
||||
scale: 1.03,
|
||||
});
|
||||
setGlare({ x: nx * 100, y: ny * 100, opacity: 0.2 });
|
||||
setBorder({ x: nx, y: ny, opacity: 1 });
|
||||
});
|
||||
}, [tiltMax]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
setTilt({ rotX: 0, rotY: 0, scale: 1 });
|
||||
setGlare(g => ({ ...g, opacity: 0 }));
|
||||
setBorder(b => ({ ...b, opacity: 0 }));
|
||||
}, []);
|
||||
|
||||
const isResting = tilt.rotX === 0 && tilt.rotY === 0;
|
||||
|
||||
// Dynamic border: a conic gradient anchored to mouse position
|
||||
const borderAngle = Math.atan2(border.y - 0.5, border.x - 0.5) * (180 / Math.PI) + 90;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
style={{
|
||||
...style,
|
||||
transform: `perspective(900px) rotateX(${tilt.rotX}deg) rotateY(${tilt.rotY}deg) scale3d(${tilt.scale},${tilt.scale},${tilt.scale})`,
|
||||
transition: isResting
|
||||
? 'transform 0.55s cubic-bezier(.03,.98,.52,.99), box-shadow 0.55s ease'
|
||||
: 'transform 0.08s linear',
|
||||
willChange: 'transform',
|
||||
transformStyle: 'preserve-3d',
|
||||
// Glow box shadow that intensifies on hover
|
||||
boxShadow: border.opacity
|
||||
? `0 0 40px -8px hsla(${220 + border.x * 40},80%,60%,0.35), 0 20px 80px -20px hsla(260,80%,50%,0.2)`
|
||||
: '0 0 0 0 transparent',
|
||||
}}
|
||||
className={cn(
|
||||
'relative rounded-3xl overflow-hidden group',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* Animated neon border */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-3xl transition-opacity duration-300 pointer-events-none"
|
||||
style={{
|
||||
opacity: border.opacity * 0.9,
|
||||
padding: '1px',
|
||||
background: `conic-gradient(
|
||||
from ${borderAngle}deg at ${border.x * 100}% ${border.y * 100}%,
|
||||
hsla(220,90%,65%,0) 0deg,
|
||||
hsla(220,90%,65%,0.9) 60deg,
|
||||
hsla(270,90%,70%,0.6) 120deg,
|
||||
hsla(190,90%,65%,0.8) 180deg,
|
||||
hsla(220,90%,65%,0) 240deg
|
||||
)`,
|
||||
WebkitMask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
|
||||
WebkitMaskComposite: 'xor',
|
||||
maskComposite: 'exclude',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Card body */}
|
||||
<div className={cn(
|
||||
'absolute inset-[1px] rounded-3xl',
|
||||
'bg-surface/50 backdrop-blur-lg border border-white/5 group-hover:border-white/10 transition-colors duration-300',
|
||||
glow && 'glow-effect',
|
||||
)} />
|
||||
|
||||
{/* Glare highlight */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-3xl pointer-events-none transition-opacity duration-300"
|
||||
style={{
|
||||
opacity: glare.opacity,
|
||||
background: `radial-gradient(circle at ${glare.x}% ${glare.y}%, rgba(255,255,255,0.18) 0%, transparent 65%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Laser shimmer sweep on hover */}
|
||||
<div className="absolute inset-0 rounded-3xl pointer-events-none overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500"
|
||||
style={{
|
||||
background: `linear-gradient(105deg, transparent 30%, rgba(255,255,255,0.06) 50%, transparent 70%)`,
|
||||
animation: 'shimmer-sweep 2s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content — slight Z lift for depth */}
|
||||
<div
|
||||
className="relative p-6 md:p-8"
|
||||
style={{ transform: 'translateZ(20px)', transformStyle: 'preserve-3d' }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
TiltCard.displayName = 'TiltCard';
|
||||
@@ -34,6 +34,23 @@
|
||||
@apply bg-clip-text text-transparent bg-gradient-to-r from-blue-400 via-indigo-400 to-purple-400;
|
||||
}
|
||||
|
||||
/* Animated shimmer text */
|
||||
.text-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#60a5fa 0%,
|
||||
#a78bfa 25%,
|
||||
#22d3ee 50%,
|
||||
#a78bfa 75%,
|
||||
#60a5fa 100%
|
||||
);
|
||||
background-size: 200% auto;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: text-shimmer-move 4s linear infinite;
|
||||
}
|
||||
|
||||
.bg-grid-pattern {
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(255,255,255,0.03) 1px, transparent 1px),
|
||||
@@ -41,7 +58,7 @@
|
||||
background-size: 50px 50px;
|
||||
}
|
||||
|
||||
/* Utility for glow effects behind cards */
|
||||
/* Glow behind cards */
|
||||
.glow-effect::before {
|
||||
content: '';
|
||||
@apply absolute inset-0 -z-10 bg-primary/20 blur-2xl rounded-full opacity-0 transition-opacity duration-300;
|
||||
@@ -49,4 +66,69 @@
|
||||
.glow-effect:hover::before {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
/* Neon text glow */
|
||||
.text-glow {
|
||||
text-shadow:
|
||||
0 0 20px rgba(99, 130, 246, 0.5),
|
||||
0 0 60px rgba(99, 130, 246, 0.25),
|
||||
0 0 100px rgba(99, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* Pulsing ring */
|
||||
.pulse-ring {
|
||||
position: relative;
|
||||
}
|
||||
.pulse-ring::after {
|
||||
content: '';
|
||||
@apply absolute inset-0 rounded-full;
|
||||
border: 1px solid rgba(99, 130, 246, 0.5);
|
||||
animation: pulse-expand 2s ease-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Keyframes ── */
|
||||
|
||||
@keyframes text-shimmer-move {
|
||||
0% { background-position: 0% center; }
|
||||
100% { background-position: 200% center; }
|
||||
}
|
||||
|
||||
@keyframes shimmer-sweep {
|
||||
0% { transform: translateX(-100%) skewX(-12deg); }
|
||||
100% { transform: translateX(200%) skewX(-12deg); }
|
||||
}
|
||||
|
||||
@keyframes pulse-expand {
|
||||
0% { transform: scale(1); opacity: 0.7; }
|
||||
100% { transform: scale(2); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes blob {
|
||||
0% { transform: translate(0px, 0px) scale(1); }
|
||||
33% { transform: translate(30px, -50px) scale(1.1); }
|
||||
66% { transform: translate(-20px, 20px) scale(0.9); }
|
||||
100% { transform: translate(0px, 0px) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes fadeUp {
|
||||
0% { opacity: 0; transform: translateY(20px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
/* Scan line across elements */
|
||||
@keyframes scan-line {
|
||||
0% { top: -2px; opacity: 0.8; }
|
||||
80% { opacity: 0.6; }
|
||||
100% { top: 100%; opacity: 0; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user