前端:增加了文章展示,和加载图片动画
This commit is contained in:
parent
b4ded09c61
commit
b29fe30c71
@ -1,12 +1,17 @@
|
|||||||
import ErrorPage from "hooks/error";
|
import ErrorPage from "hooks/error";
|
||||||
import layout from "themes/echoes/layout";
|
import layout from "themes/echoes/layout";
|
||||||
|
import article from "themes/echoes/article";
|
||||||
|
import about from "themes/echoes/about";
|
||||||
|
|
||||||
export default function Routes() {
|
export default function Routes() {
|
||||||
return layout.element({
|
const args = {
|
||||||
children: <></>,
|
|
||||||
args: {
|
|
||||||
title: "我的页面",
|
title: "我的页面",
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
},
|
nav: '<a href="h">a</a>'
|
||||||
|
};
|
||||||
|
|
||||||
|
return layout.render({
|
||||||
|
children: article.render(args),
|
||||||
|
args,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
581
frontend/hooks/ParticleImage.tsx
Normal file
581
frontend/hooks/ParticleImage.tsx
Normal file
@ -0,0 +1,581 @@
|
|||||||
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { gsap } from 'gsap';
|
||||||
|
|
||||||
|
interface Particle {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
originalX: number;
|
||||||
|
originalY: number;
|
||||||
|
originalColor: THREE.Color;
|
||||||
|
delay: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createErrorParticles = (width: number, height: number) => {
|
||||||
|
const particles: Particle[] = [];
|
||||||
|
const positionArray: number[] = [];
|
||||||
|
const colorArray: number[] = [];
|
||||||
|
|
||||||
|
const errorColor = new THREE.Color(0.8, 0, 0); // 更深的红色
|
||||||
|
|
||||||
|
// X 形状的两条线
|
||||||
|
const lines = [
|
||||||
|
// 左上到右下的线
|
||||||
|
{ start: [-1, 1], end: [1, -1] },
|
||||||
|
// 右上到左下的线
|
||||||
|
{ start: [1, 1], end: [-1, -1] }
|
||||||
|
];
|
||||||
|
|
||||||
|
// 每条线上的粒子数量
|
||||||
|
const particlesPerLine = 50;
|
||||||
|
|
||||||
|
lines.forEach(line => {
|
||||||
|
for (let i = 0; i < particlesPerLine; i++) {
|
||||||
|
const t = i / (particlesPerLine - 1);
|
||||||
|
const x = line.start[0] + (line.end[0] - line.start[0]) * t;
|
||||||
|
const y = line.start[1] + (line.end[1] - line.start[1]) * t;
|
||||||
|
|
||||||
|
// 添加一些随机偏移
|
||||||
|
const randomOffset = 0.1;
|
||||||
|
const randomX = x + (Math.random() - 0.5) * randomOffset;
|
||||||
|
const randomY = y + (Math.random() - 0.5) * randomOffset;
|
||||||
|
|
||||||
|
// 缩放到适合容器的大小
|
||||||
|
const scaledX = randomX * (width * 0.3);
|
||||||
|
const scaledY = randomY * (height * 0.3);
|
||||||
|
|
||||||
|
particles.push({
|
||||||
|
x: scaledX,
|
||||||
|
y: scaledY,
|
||||||
|
z: 0,
|
||||||
|
originalX: scaledX,
|
||||||
|
originalY: scaledY,
|
||||||
|
originalColor: errorColor,
|
||||||
|
delay: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// 随机初始位置
|
||||||
|
positionArray.push(
|
||||||
|
(Math.random() - 0.5) * width * 2,
|
||||||
|
(Math.random() - 0.5) * height * 2,
|
||||||
|
(Math.random() - 0.5) * 100
|
||||||
|
);
|
||||||
|
|
||||||
|
// 随机初始颜色
|
||||||
|
colorArray.push(errorColor.r, errorColor.g, errorColor.b);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { particles, positionArray, colorArray };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加笑脸粒子生成函数
|
||||||
|
const createSmileParticles = (width: number, height: number) => {
|
||||||
|
const particles: Particle[] = [];
|
||||||
|
const positionArray: number[] = [];
|
||||||
|
const colorArray: number[] = [];
|
||||||
|
|
||||||
|
// 调整笑脸参数
|
||||||
|
const radius = Math.min(width, height) * 0.35; // 脸部大小
|
||||||
|
const particlesCount = 400; // 轮廓粒子数量
|
||||||
|
|
||||||
|
// 修改颜色为更深的金色
|
||||||
|
const particleColor = new THREE.Color(0.8, 0.6, 0); // 更深的金色
|
||||||
|
|
||||||
|
// 创建圆形脸部轮廓
|
||||||
|
for (let i = 0; i < particlesCount / 2; i++) {
|
||||||
|
const angle = (i / (particlesCount / 2)) * Math.PI * 2;
|
||||||
|
const x = Math.cos(angle) * radius;
|
||||||
|
const y = Math.sin(angle) * radius;
|
||||||
|
|
||||||
|
particles.push({
|
||||||
|
x, y, z: 0,
|
||||||
|
originalX: x,
|
||||||
|
originalY: y,
|
||||||
|
originalColor: particleColor,
|
||||||
|
delay: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
positionArray.push(
|
||||||
|
(Math.random() - 0.5) * width * 2,
|
||||||
|
(Math.random() - 0.5) * height * 2,
|
||||||
|
(Math.random() - 0.5) * 100
|
||||||
|
);
|
||||||
|
colorArray.push(particleColor.r, particleColor.g, particleColor.b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 眼睛参数
|
||||||
|
const eyeOffset = radius * 0.2; // 眼睛水平间距
|
||||||
|
const eyeY = radius * 0.2; // 眼睛垂直位置
|
||||||
|
const eyeSize = radius * 0.08; // 眼睛大小
|
||||||
|
|
||||||
|
// 创建实心眼睛
|
||||||
|
[-1, 1].forEach(side => {
|
||||||
|
// 创建密集的点来填充眼睛
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
const r = Math.random() * eyeSize; // 随机半径
|
||||||
|
const angle = Math.random() * Math.PI * 2; // 随机角度
|
||||||
|
const x = side * eyeOffset + Math.cos(angle) * r;
|
||||||
|
const y = eyeY + Math.sin(angle) * r;
|
||||||
|
|
||||||
|
particles.push({
|
||||||
|
x, y, z: 0,
|
||||||
|
originalX: x,
|
||||||
|
originalY: y,
|
||||||
|
originalColor: particleColor,
|
||||||
|
delay: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
positionArray.push(
|
||||||
|
(Math.random() - 0.5) * width * 2,
|
||||||
|
(Math.random() - 0.5) * height * 2,
|
||||||
|
(Math.random() - 0.5) * 100
|
||||||
|
);
|
||||||
|
colorArray.push(particleColor.r, particleColor.g, particleColor.b);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 嘴巴参数
|
||||||
|
const smileWidth = radius * 0.5; // 嘴巴宽度
|
||||||
|
const smileY = -radius * 0.3; // 将嘴巴位置向下移动更多
|
||||||
|
const smilePoints = 40; // 嘴巴粒子数量
|
||||||
|
|
||||||
|
// 创建微笑
|
||||||
|
for (let i = 0; i < smilePoints; i++) {
|
||||||
|
const t = i / (smilePoints - 1);
|
||||||
|
const x = (t * 2 - 1) * smileWidth;
|
||||||
|
|
||||||
|
// 简单的抛物线,向上弯曲的笑脸
|
||||||
|
const y = smileY + (Math.pow(x / smileWidth, 2) * radius * 0.2);
|
||||||
|
|
||||||
|
particles.push({
|
||||||
|
x, y, z: 0,
|
||||||
|
originalX: x,
|
||||||
|
originalY: y,
|
||||||
|
originalColor: particleColor,
|
||||||
|
delay: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
positionArray.push(
|
||||||
|
(Math.random() - 0.5) * width * 2,
|
||||||
|
(Math.random() - 0.5) * height * 2,
|
||||||
|
(Math.random() - 0.5) * 100
|
||||||
|
);
|
||||||
|
colorArray.push(particleColor.r, particleColor.g, particleColor.b);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { particles, positionArray, colorArray };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 在文件开头添加新的 helper 函数
|
||||||
|
const easeOutCubic = (t: number) => {
|
||||||
|
return 1 - Math.pow(1 - t, 3);
|
||||||
|
};
|
||||||
|
|
||||||
|
const customEase = (t: number) => {
|
||||||
|
return t < 0.5
|
||||||
|
? 4 * t * t * t
|
||||||
|
: 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ParticleImage = ({ src, onLoad, onError }: {
|
||||||
|
src?: string;
|
||||||
|
onLoad?: () => void;
|
||||||
|
onError?: () => void;
|
||||||
|
}) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const sceneRef = useRef<THREE.Scene>();
|
||||||
|
const cameraRef = useRef<THREE.OrthographicCamera>();
|
||||||
|
const rendererRef = useRef<THREE.WebGLRenderer>();
|
||||||
|
const animationFrameRef = useRef<number>();
|
||||||
|
|
||||||
|
// 添加 resize 处理函数
|
||||||
|
const handleResize = useCallback(() => {
|
||||||
|
if (!containerRef.current || !cameraRef.current || !rendererRef.current) return;
|
||||||
|
|
||||||
|
const width = containerRef.current.offsetWidth;
|
||||||
|
const height = containerRef.current.offsetHeight;
|
||||||
|
|
||||||
|
const camera = cameraRef.current;
|
||||||
|
camera.left = width / -2.1;
|
||||||
|
camera.right = width / 2.1;
|
||||||
|
camera.top = height / 2.1;
|
||||||
|
camera.bottom = height / -2.1;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
|
||||||
|
rendererRef.current.setSize(width, height);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
console.log('Current src:', src);
|
||||||
|
|
||||||
|
const width = containerRef.current.offsetWidth;
|
||||||
|
const height = containerRef.current.offsetHeight;
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
sceneRef.current = scene;
|
||||||
|
|
||||||
|
const camera = new THREE.OrthographicCamera(
|
||||||
|
width / -1.5, // 扩大视野范围,从 -2 改为 -1.5
|
||||||
|
width / 1.5, // 扩大视野范围,从 2 改为 1.5
|
||||||
|
height / 1.5, // 扩大视野范围
|
||||||
|
height / -1.5, // 扩大视野范围
|
||||||
|
1,
|
||||||
|
1000
|
||||||
|
);
|
||||||
|
camera.position.z = 100;
|
||||||
|
cameraRef.current = camera;
|
||||||
|
|
||||||
|
const renderer = new THREE.WebGLRenderer({
|
||||||
|
alpha: true,
|
||||||
|
antialias: true
|
||||||
|
});
|
||||||
|
renderer.setPixelRatio(window.devicePixelRatio);
|
||||||
|
renderer.setSize(width, height);
|
||||||
|
rendererRef.current = renderer;
|
||||||
|
containerRef.current.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
const geometry = new THREE.BufferGeometry();
|
||||||
|
const material = new THREE.PointsMaterial({
|
||||||
|
size: 1.2,
|
||||||
|
vertexColors: true,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 1,
|
||||||
|
sizeAttenuation: true,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
depthWrite: false,
|
||||||
|
depthTest: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// 检查是否应该显示笑脸
|
||||||
|
if (src === '') {
|
||||||
|
console.log('Showing smile animation');
|
||||||
|
const { particles, positionArray, colorArray } = createSmileParticles(width, height);
|
||||||
|
|
||||||
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positionArray, 3));
|
||||||
|
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colorArray, 3));
|
||||||
|
|
||||||
|
const points = new THREE.Points(geometry, material);
|
||||||
|
scene.add(points);
|
||||||
|
|
||||||
|
const positionAttribute = geometry.attributes.position;
|
||||||
|
|
||||||
|
particles.forEach((particle, i) => {
|
||||||
|
const i3 = i * 3;
|
||||||
|
gsap.to(positionAttribute.array, {
|
||||||
|
duration: 1,
|
||||||
|
delay: Math.random() * 0.3,
|
||||||
|
[i3]: particle.originalX,
|
||||||
|
[i3 + 1]: particle.originalY,
|
||||||
|
[i3 + 2]: 0,
|
||||||
|
ease: "back.out(1.7)",
|
||||||
|
onUpdate: () => {
|
||||||
|
positionAttribute.needsUpdate = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 启动动画循环
|
||||||
|
const animate = () => {
|
||||||
|
if (renderer && scene && camera) {
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
}
|
||||||
|
animationFrameRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
animate();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建错误动画函数
|
||||||
|
const showErrorAnimation = () => {
|
||||||
|
if (!scene) return;
|
||||||
|
|
||||||
|
const { particles, positionArray, colorArray } = createErrorParticles(width, height);
|
||||||
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positionArray, 3));
|
||||||
|
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colorArray, 3));
|
||||||
|
|
||||||
|
const points = new THREE.Points(geometry, material);
|
||||||
|
scene.clear(); // 清除现有内容
|
||||||
|
scene.add(points);
|
||||||
|
|
||||||
|
const positionAttribute = geometry.attributes.position;
|
||||||
|
|
||||||
|
particles.forEach((particle, i) => {
|
||||||
|
const i3 = i * 3;
|
||||||
|
gsap.to(positionAttribute.array, {
|
||||||
|
duration: 0.6,
|
||||||
|
delay: Math.random() * 0.2,
|
||||||
|
[i3]: particle.originalX,
|
||||||
|
[i3 + 1]: particle.originalY,
|
||||||
|
[i3 + 2]: 0,
|
||||||
|
ease: "back.out(1.7)",
|
||||||
|
onUpdate: () => {
|
||||||
|
positionAttribute.needsUpdate = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onError?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载图片
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = 'anonymous';
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
showErrorAnimation();
|
||||||
|
}, 5000); // 5秒超时
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
if (ctx) {
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
|
||||||
|
// 计算图片绘制尺寸和位置,确保不会超出容器
|
||||||
|
const imgAspect = img.width / img.height;
|
||||||
|
const containerAspect = width / height;
|
||||||
|
|
||||||
|
let drawWidth = width;
|
||||||
|
let drawHeight = height;
|
||||||
|
let offsetX = 0;
|
||||||
|
let offsetY = 0;
|
||||||
|
|
||||||
|
if (imgAspect > containerAspect) {
|
||||||
|
// 图片较宽,以容器宽度为准,确保高度不超出
|
||||||
|
drawWidth = width;
|
||||||
|
drawHeight = width / imgAspect;
|
||||||
|
offsetY = (height - drawHeight) / 2;
|
||||||
|
} else {
|
||||||
|
// 图片较高,以容器高度为准,确保宽度不超出
|
||||||
|
drawHeight = height;
|
||||||
|
drawWidth = height * imgAspect;
|
||||||
|
offsetX = (width - drawWidth) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制图片
|
||||||
|
ctx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight);
|
||||||
|
const imageData = ctx.getImageData(0, 0, width, height);
|
||||||
|
|
||||||
|
const particles: Particle[] = [];
|
||||||
|
const positionArray = [];
|
||||||
|
const colorArray = [];
|
||||||
|
const samplingGap = Math.ceil(Math.max(width, height) / 100); // 动态采样间隔,确保粒子数量适中
|
||||||
|
|
||||||
|
// 采样图片像素
|
||||||
|
for (let y = 0; y < height; y += samplingGap) {
|
||||||
|
for (let x = 0; x < width; x += samplingGap) {
|
||||||
|
const i = (y * width + x) * 4;
|
||||||
|
const r = imageData.data[i] / 255;
|
||||||
|
const g = imageData.data[i + 1] / 255;
|
||||||
|
const b = imageData.data[i + 2] / 255;
|
||||||
|
const a = imageData.data[i + 3] / 255;
|
||||||
|
|
||||||
|
if (a > 0.3) {
|
||||||
|
// 计算距离中心的距离,用于动画延迟
|
||||||
|
const distanceToCenter = Math.sqrt(
|
||||||
|
Math.pow(x - width/2, 2) +
|
||||||
|
Math.pow(y - height/2, 2)
|
||||||
|
);
|
||||||
|
const maxDistance = Math.sqrt(Math.pow(width/2, 2) + Math.pow(height/2, 2));
|
||||||
|
const normalizedDistance = distanceToCenter / maxDistance;
|
||||||
|
|
||||||
|
const px = x - width / 2;
|
||||||
|
const py = height / 2 - y;
|
||||||
|
|
||||||
|
particles.push({
|
||||||
|
x: px,
|
||||||
|
y: py,
|
||||||
|
z: 0,
|
||||||
|
originalX: px,
|
||||||
|
originalY: py,
|
||||||
|
originalColor: new THREE.Color(r, g, b),
|
||||||
|
delay: normalizedDistance * 0.3 // 基于距离的延迟
|
||||||
|
});
|
||||||
|
|
||||||
|
// 随机初始位置(根据距离调整范围)
|
||||||
|
const spread = 1 - normalizedDistance * 0.5; // 距离越远,初始扩散越小
|
||||||
|
positionArray.push(
|
||||||
|
(Math.random() - 0.5) * width * spread,
|
||||||
|
(Math.random() - 0.5) * height * spread,
|
||||||
|
(Math.random() - 0.5) * 50 * spread
|
||||||
|
);
|
||||||
|
|
||||||
|
colorArray.push(r, g, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.clear();
|
||||||
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positionArray, 3));
|
||||||
|
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colorArray, 3));
|
||||||
|
|
||||||
|
const points = new THREE.Points(geometry, material);
|
||||||
|
scene.add(points);
|
||||||
|
|
||||||
|
// 动画
|
||||||
|
const positionAttribute = geometry.attributes.position;
|
||||||
|
const colorAttribute = geometry.attributes.color;
|
||||||
|
|
||||||
|
let completedAnimations = 0;
|
||||||
|
const totalAnimations = particles.length * 2; // 位置和颜色动画
|
||||||
|
|
||||||
|
const checkComplete = () => {
|
||||||
|
completedAnimations++;
|
||||||
|
if (completedAnimations === totalAnimations) {
|
||||||
|
onLoad?.(); // 所有画完成后调用 onLoad
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
particles.forEach((particle, i) => {
|
||||||
|
const i3 = i * 3;
|
||||||
|
|
||||||
|
// 位置动画
|
||||||
|
gsap.to(positionAttribute.array, {
|
||||||
|
duration: 1.2 + Math.random() * 0.3, // 减少随机性范围
|
||||||
|
delay: particle.delay, // 使用基于距离的延迟
|
||||||
|
[i3]: particle.originalX,
|
||||||
|
[i3 + 1]: particle.originalY,
|
||||||
|
[i3 + 2]: 0,
|
||||||
|
ease: customEase,
|
||||||
|
onUpdate: () => {
|
||||||
|
positionAttribute.needsUpdate = true;
|
||||||
|
},
|
||||||
|
onComplete: checkComplete
|
||||||
|
});
|
||||||
|
|
||||||
|
// 颜色动画
|
||||||
|
gsap.to(colorAttribute.array, {
|
||||||
|
duration: 1,
|
||||||
|
delay: particle.delay + 0.2, // 稍微延迟颜色变化
|
||||||
|
[i3]: particle.originalColor.r,
|
||||||
|
[i3 + 1]: particle.originalColor.g,
|
||||||
|
[i3 + 2]: particle.originalColor.b,
|
||||||
|
ease: "power2.inOut",
|
||||||
|
onUpdate: () => {
|
||||||
|
colorAttribute.needsUpdate = true;
|
||||||
|
},
|
||||||
|
onComplete: checkComplete
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 修改动画序列部分
|
||||||
|
const timeline = gsap.timeline({
|
||||||
|
defaults: { ease: "power2.inOut" }
|
||||||
|
});
|
||||||
|
|
||||||
|
const imgElement = document.querySelector(`img[src="${src}"]`) as HTMLImageElement;
|
||||||
|
if (imgElement) {
|
||||||
|
// 设置初始状态
|
||||||
|
gsap.set(imgElement, { opacity: 0 });
|
||||||
|
|
||||||
|
timeline
|
||||||
|
.to(imgElement, {
|
||||||
|
opacity: 1,
|
||||||
|
duration: 0.8,
|
||||||
|
delay: 1.6
|
||||||
|
})
|
||||||
|
.to(material, {
|
||||||
|
opacity: 0,
|
||||||
|
duration: 0.8
|
||||||
|
}, "-=0.6"); // 提前开始消失
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
showErrorAnimation();
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = src || '';
|
||||||
|
|
||||||
|
// 动画循环
|
||||||
|
const animate = () => {
|
||||||
|
if (renderer && scene && camera) {
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
}
|
||||||
|
animationFrameRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
animate();
|
||||||
|
|
||||||
|
// 添加 resize 监听
|
||||||
|
const resizeObserver = new ResizeObserver(handleResize);
|
||||||
|
resizeObserver.observe(containerRef.current);
|
||||||
|
|
||||||
|
// 添加窗口 resize 监听
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
}
|
||||||
|
if (renderer && containerRef.current) {
|
||||||
|
containerRef.current.removeChild(renderer.domElement);
|
||||||
|
renderer.dispose();
|
||||||
|
}
|
||||||
|
// 清除所有 GSAP 动画
|
||||||
|
gsap.killTweensOf(geometry.attributes.position?.array);
|
||||||
|
|
||||||
|
// 移除 resize 监听
|
||||||
|
if (containerRef.current) {
|
||||||
|
resizeObserver.unobserve(containerRef.current);
|
||||||
|
}
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
|
}, [src, onError, handleResize]);
|
||||||
|
|
||||||
|
return <div ref={containerRef} className="w-full h-full" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 图片加载组件
|
||||||
|
export const ImageLoader = ({ src, alt, className }: {
|
||||||
|
src?: string;
|
||||||
|
alt: string;
|
||||||
|
className: string;
|
||||||
|
}) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [hasError, setHasError] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-[100px] md:w-[140px] h-[100px] md:h-[140px] shrink-0 overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-[rgb(10,37,77)] via-[rgb(8,27,57)] to-[rgb(2,8,23)] rounded-lg overflow-hidden">
|
||||||
|
<ParticleImage
|
||||||
|
src={src}
|
||||||
|
onLoad={() => setIsLoading(false)}
|
||||||
|
onError={() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
setHasError(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!hasError && (
|
||||||
|
<div className="absolute inset-0 rounded-lg overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className={`
|
||||||
|
w-full h-full object-cover
|
||||||
|
transition-opacity duration-1000
|
||||||
|
${className}
|
||||||
|
${isLoading ? 'opacity-0' : 'opacity-100'}
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
visibility: isLoading ? 'hidden' : 'visible',
|
||||||
|
objectFit: 'cover',
|
||||||
|
objectPosition: 'center'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
15
frontend/interface/post.ts
Normal file
15
frontend/interface/post.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export interface Post {
|
||||||
|
id: number; // 自增整数
|
||||||
|
authorName: string; // 作者名称
|
||||||
|
coverImage?: string; // 封面图片
|
||||||
|
title?: string; // 标题
|
||||||
|
metaKeywords: string; // 元关键词
|
||||||
|
metaDescription: string; // 元描述
|
||||||
|
content: string; // 内容
|
||||||
|
status: string; // 状态
|
||||||
|
isEditor: boolean; // 是否为编辑器
|
||||||
|
draftContent?: string; // 草稿内容
|
||||||
|
createdAt: Date; // 创建时间
|
||||||
|
updatedAt: Date; // 更新时间
|
||||||
|
publishedAt?: Date; // 发布时间
|
||||||
|
}
|
@ -1,27 +1,45 @@
|
|||||||
import { HttpClient } from "core/http";
|
import { HttpClient } from "core/http";
|
||||||
import { CapabilityService } from "core/capability";
|
import { CapabilityService } from "core/capability";
|
||||||
import { Serializable } from "interface/serializableType";
|
import { Serializable } from "interface/serializableType";
|
||||||
|
import { Layout } from "./layout";
|
||||||
|
|
||||||
export class Template {
|
export class Template {
|
||||||
|
private http: HttpClient;
|
||||||
|
private capability: CapabilityService;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public config: {
|
public config: {
|
||||||
layout?: string;
|
layout?: Layout;
|
||||||
styles?: string[];
|
styles?: string[];
|
||||||
scripts?: string[];
|
scripts?: string[];
|
||||||
description?: string;
|
description?: string;
|
||||||
},
|
},
|
||||||
public element: (services: {
|
public element: (props: {
|
||||||
http: HttpClient;
|
http: HttpClient;
|
||||||
capability: CapabilityService;
|
|
||||||
args: Serializable;
|
args: Serializable;
|
||||||
}) => React.ReactNode,
|
}) => React.ReactNode,
|
||||||
) {}
|
services?: {
|
||||||
|
http?: HttpClient;
|
||||||
|
capability?: CapabilityService;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
this.http = services?.http || HttpClient.getInstance();
|
||||||
|
this.capability = services?.capability || CapabilityService.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
render(services: {
|
render(args: Serializable) {
|
||||||
http: HttpClient;
|
const content = this.element({
|
||||||
capability: CapabilityService;
|
http: this.http,
|
||||||
args: Serializable;
|
args,
|
||||||
}) {
|
});
|
||||||
return this.element(services);
|
|
||||||
|
if (this.config.layout) {
|
||||||
|
return this.config.layout.render({
|
||||||
|
children: content,
|
||||||
|
args,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,17 +8,8 @@ export interface ThemeConfig {
|
|||||||
description?: string;
|
description?: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
templates: Map<string, PathDescription>;
|
templates: Map<string, PathDescription>;
|
||||||
globalSettings?: {
|
|
||||||
layout?: string;
|
layout?: string;
|
||||||
css?: string;
|
|
||||||
};
|
|
||||||
configuration: Configuration;
|
configuration: Configuration;
|
||||||
routes: {
|
error?: string;
|
||||||
article: string;
|
routes: Map<string, string>;
|
||||||
post: string;
|
|
||||||
tag: string;
|
|
||||||
category: string;
|
|
||||||
error: string;
|
|
||||||
page: Map<string, string>;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
@ -19,13 +19,17 @@
|
|||||||
"@remix-run/react": "^2.14.0",
|
"@remix-run/react": "^2.14.0",
|
||||||
"@remix-run/serve": "^2.14.0",
|
"@remix-run/serve": "^2.14.0",
|
||||||
"@types/axios": "^0.14.4",
|
"@types/axios": "^0.14.4",
|
||||||
|
"@types/three": "^0.170.0",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"bootstrap-icons": "^1.11.3",
|
"bootstrap-icons": "^1.11.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
|
"gsap": "^3.12.5",
|
||||||
|
"html-react-parser": "^5.1.19",
|
||||||
"isbot": "^4.1.0",
|
"isbot": "^4.1.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0"
|
"react-dom": "^18.2.0",
|
||||||
|
"three": "^0.171.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@remix-run/dev": "^2.14.0",
|
"@remix-run/dev": "^2.14.0",
|
||||||
|
@ -24,6 +24,9 @@ export default {
|
|||||||
animation: {
|
animation: {
|
||||||
progress: "progress 3s linear",
|
progress: "progress 3s linear",
|
||||||
},
|
},
|
||||||
|
zIndex: {
|
||||||
|
'-10': '-10',
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
156
frontend/themes/echoes/about.tsx
Normal file
156
frontend/themes/echoes/about.tsx
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import { Template } from "interface/template";
|
||||||
|
import { Container, Heading, Text, Box, Flex, Link } from "@radix-ui/themes";
|
||||||
|
import { GitHubLogoIcon, TwitterLogoIcon, LinkedInLogoIcon, EnvelopeClosedIcon } from "@radix-ui/react-icons";
|
||||||
|
import { ParticleImage } from "hooks/ParticleImage";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { gsap } from "gsap";
|
||||||
|
|
||||||
|
const socialLinks = [
|
||||||
|
{
|
||||||
|
icon: <GitHubLogoIcon className="w-5 h-5" />,
|
||||||
|
url: "https://github.com/yourusername",
|
||||||
|
label: "GitHub"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <TwitterLogoIcon className="w-5 h-5" />,
|
||||||
|
url: "https://twitter.com/yourusername",
|
||||||
|
label: "Twitter"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <LinkedInLogoIcon className="w-5 h-5" />,
|
||||||
|
url: "https://linkedin.com/in/yourusername",
|
||||||
|
label: "LinkedIn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <EnvelopeClosedIcon className="w-5 h-5" />,
|
||||||
|
url: "mailto:your.email@example.com",
|
||||||
|
label: "Email"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const skills = [
|
||||||
|
{ name: "React", level: 90 },
|
||||||
|
{ name: "TypeScript", level: 85 },
|
||||||
|
{ name: "Node.js", level: 80 },
|
||||||
|
{ name: "Three.js", level: 75 },
|
||||||
|
{ name: "Python", level: 70 }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default new Template(
|
||||||
|
{},
|
||||||
|
({ http, args }) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsVisible(true);
|
||||||
|
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
// 标题动画
|
||||||
|
gsap.from(".animate-title", {
|
||||||
|
y: 30,
|
||||||
|
opacity: 0,
|
||||||
|
duration: 1,
|
||||||
|
ease: "power3.out",
|
||||||
|
stagger: 0.2
|
||||||
|
});
|
||||||
|
|
||||||
|
// 技能条动画
|
||||||
|
gsap.from(".skill-bar", {
|
||||||
|
width: 0,
|
||||||
|
duration: 1.5,
|
||||||
|
ease: "power3.out",
|
||||||
|
stagger: 0.1,
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: ".skills-section",
|
||||||
|
start: "top center+=100",
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 社交链接动画
|
||||||
|
gsap.from(".social-link", {
|
||||||
|
scale: 0,
|
||||||
|
opacity: 0,
|
||||||
|
duration: 0.5,
|
||||||
|
ease: "back.out(1.7)",
|
||||||
|
stagger: 0.1
|
||||||
|
});
|
||||||
|
}, containerRef);
|
||||||
|
|
||||||
|
return () => ctx.revert();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
ref={containerRef}
|
||||||
|
className={`transition-opacity duration-1000 ${isVisible ? 'opacity-100' : 'opacity-0'}`}
|
||||||
|
>
|
||||||
|
<Box className="max-w-4xl mx-auto px-4">
|
||||||
|
{/* 头部个人介绍 */}
|
||||||
|
<Flex
|
||||||
|
direction="column"
|
||||||
|
align="center"
|
||||||
|
className="text-center mb-16"
|
||||||
|
>
|
||||||
|
<Box className="w-40 h-40 mb-8 relative">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-[rgb(10,37,77)] via-[rgb(8,27,57)] to-[rgb(2,8,23)] rounded-full overflow-hidden">
|
||||||
|
<ParticleImage src="/path/to/your/avatar.jpg" />
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Heading size="8" className="animate-title mb-4">
|
||||||
|
你的名字
|
||||||
|
</Heading>
|
||||||
|
<Text size="5" className="animate-title text-[--gray-11] mb-6">
|
||||||
|
全栈开发者 / 设计爱好者
|
||||||
|
</Text>
|
||||||
|
<Text className="animate-title text-[--gray-11] max-w-2xl leading-relaxed">
|
||||||
|
热爱编程和创新的全栈开发者,专注于创建优雅且高性能的web应用。
|
||||||
|
擅长将复杂的问题简化,追求代码的优雅和用户体验的完美统一。
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* 技能展示 */}
|
||||||
|
<Box className="skills-section mb-16">
|
||||||
|
<Heading size="4" className="mb-8">专业技能</Heading>
|
||||||
|
<Flex direction="column" gap="4">
|
||||||
|
{skills.map((skill) => (
|
||||||
|
<Box key={skill.name}>
|
||||||
|
<Flex justify="between" className="mb-2">
|
||||||
|
<Text weight="medium">{skill.name}</Text>
|
||||||
|
<Text className="text-[--gray-11]">{skill.level}%</Text>
|
||||||
|
</Flex>
|
||||||
|
<Box className="h-2 bg-[--gray-4] rounded-full overflow-hidden">
|
||||||
|
<Box
|
||||||
|
className="skill-bar h-full bg-[--accent-9] rounded-full"
|
||||||
|
style={{ width: `${skill.level}%` }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 社交链接 */}
|
||||||
|
<Flex
|
||||||
|
gap="4"
|
||||||
|
justify="center"
|
||||||
|
className="pt-8 border-t border-[--gray-5]"
|
||||||
|
>
|
||||||
|
{socialLinks.map((link, index) => (
|
||||||
|
<Link
|
||||||
|
key={link.label}
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="social-link p-3 rounded-full hover:bg-[--gray-3] transition-colors"
|
||||||
|
>
|
||||||
|
{link.icon}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
@ -1,10 +1,219 @@
|
|||||||
import { Template } from "interface/template";
|
import { Template } from "interface/template";
|
||||||
|
import { Container, Heading, Text, Flex, Card, Button } from "@radix-ui/themes";
|
||||||
|
import { CalendarIcon, PersonIcon, ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons";
|
||||||
|
import { Post } from "interface/post";
|
||||||
|
import { useMemo} from "react";
|
||||||
|
|
||||||
|
import { ImageLoader } from "hooks/ParticleImage";
|
||||||
|
|
||||||
|
// 模拟文章列表数据
|
||||||
|
const mockArticles: Post[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: "构建现代化的前端开发工作流",
|
||||||
|
content: "在现代前端开发中,一个高效的工作流程对于提高开发效率至关重要...",
|
||||||
|
authorName: "张三",
|
||||||
|
publishedAt: new Date("2024-03-15"),
|
||||||
|
coverImage: "",
|
||||||
|
metaKeywords: "",
|
||||||
|
metaDescription: "",
|
||||||
|
status: "published",
|
||||||
|
isEditor: false,
|
||||||
|
createdAt: new Date("2024-03-15"),
|
||||||
|
updatedAt: new Date("2024-03-15")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: "React 18 新特性详解",
|
||||||
|
content: "React 18 带来了许多令人兴奋的新特性,包括并发渲染、自动批处理更新...",
|
||||||
|
authorName: "李四",
|
||||||
|
publishedAt: new Date("2024-03-14"),
|
||||||
|
coverImage: "https://avatars.githubusercontent.com/u/2?v=4",
|
||||||
|
metaKeywords: "",
|
||||||
|
metaDescription: "",
|
||||||
|
status: "published",
|
||||||
|
isEditor: false,
|
||||||
|
createdAt: new Date("2024-03-14"),
|
||||||
|
updatedAt: new Date("2024-03-14")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: "JavaScript 性能优化技巧",
|
||||||
|
content: "在这篇文章中,我们将探讨一些提高 JavaScript 性能的技巧和最佳实践...",
|
||||||
|
authorName: "王五",
|
||||||
|
publishedAt: new Date("2024-03-13"),
|
||||||
|
coverImage: "https://avatars.githubusercontent.com/u/",
|
||||||
|
metaKeywords: "",
|
||||||
|
metaDescription: "",
|
||||||
|
status: "published",
|
||||||
|
isEditor: false,
|
||||||
|
createdAt: new Date("2024-03-13"),
|
||||||
|
updatedAt: new Date("2024-03-13")
|
||||||
|
},
|
||||||
|
// 可以添加更多模拟文章
|
||||||
|
];
|
||||||
|
|
||||||
|
// 修改颜色组合数组,增加更多颜色选项
|
||||||
|
const colorSchemes = [
|
||||||
|
{ bg: 'bg-blue-100', text: 'text-blue-600' },
|
||||||
|
{ bg: 'bg-green-100', text: 'text-green-600' },
|
||||||
|
{ bg: 'bg-purple-100', text: 'text-purple-600' },
|
||||||
|
{ bg: 'bg-pink-100', text: 'text-pink-600' },
|
||||||
|
{ bg: 'bg-orange-100', text: 'text-orange-600' },
|
||||||
|
{ bg: 'bg-teal-100', text: 'text-teal-600' },
|
||||||
|
{ bg: 'bg-red-100', text: 'text-red-600' },
|
||||||
|
{ bg: 'bg-indigo-100', text: 'text-indigo-600' },
|
||||||
|
{ bg: 'bg-yellow-100', text: 'text-yellow-600' },
|
||||||
|
{ bg: 'bg-cyan-100', text: 'text-cyan-600' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const categories = ['前端开发', '后端开发', 'UI设计', '移动开发', '人工智能'];
|
||||||
|
const tags = ['React', 'TypeScript', 'Vue', 'Node.js', 'Flutter', 'Python', 'Docker'];
|
||||||
|
|
||||||
|
// 定义 SlideGeometry 类
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default new Template(
|
export default new Template(
|
||||||
{
|
{
|
||||||
layout: "default",
|
|
||||||
},
|
},
|
||||||
({ http, args }) => {
|
({ http, args }) => {
|
||||||
return <div>Hello World</div>;
|
const articleData = useMemo(() => {
|
||||||
},
|
return mockArticles.map((article) => {
|
||||||
|
// 使用更复杂的散列函数来生成看起来更随机的索引
|
||||||
|
const hash = (str: string) => {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
const char = str.charCodeAt(i);
|
||||||
|
hash = ((hash << 5) - hash) + char;
|
||||||
|
hash = hash & hash;
|
||||||
|
}
|
||||||
|
return Math.abs(hash);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用文章的不同属性来生成索引
|
||||||
|
const categoryIndex = hash(article.title + article.id.toString()) % categories.length;
|
||||||
|
const colorIndex = hash(article.authorName + article.id.toString()) % colorSchemes.length;
|
||||||
|
|
||||||
|
// 为标签生成不同的索引
|
||||||
|
const tagIndices = tags
|
||||||
|
.map((_, index) => ({
|
||||||
|
index,
|
||||||
|
sort: hash(article.title + index.toString() + article.id.toString())
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.sort - b.sort)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map(item => item.index);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...article,
|
||||||
|
category: categories[categoryIndex],
|
||||||
|
categoryColor: colorSchemes[colorIndex],
|
||||||
|
tags: tagIndices.map(index => ({
|
||||||
|
name: tags[index],
|
||||||
|
color: colorSchemes[hash(tags[index] + article.id.toString()) % colorSchemes.length]
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="3" className="pt-2 pb-4 md:pb-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6 px-4 md:px-0">
|
||||||
|
{articleData.map(article => (
|
||||||
|
<Card
|
||||||
|
key={article.id}
|
||||||
|
className="group cursor-pointer hover:shadow-lg transition-all duration-300 border border-[--gray-5] hover:border-[--accent-8] relative overflow-visible"
|
||||||
|
>
|
||||||
|
<div className={`p-5 relative flex gap-5`}>
|
||||||
|
<ImageLoader
|
||||||
|
src={article.coverImage}
|
||||||
|
alt={article.title || ''}
|
||||||
|
className="group-hover:scale-105 transition-transform duration-500 relative z-10"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-3 mb-2">
|
||||||
|
<Heading
|
||||||
|
size="3"
|
||||||
|
className="group-hover:text-[--accent-9] transition-colors duration-200 line-clamp-2 text-base md:text-lg flex-1"
|
||||||
|
>
|
||||||
|
{article.title}
|
||||||
|
</Heading>
|
||||||
|
<Text
|
||||||
|
size="1"
|
||||||
|
className={`px-2 py-0.5 rounded-full shrink-0 ${article.categoryColor.bg} ${article.categoryColor.text}`}
|
||||||
|
>
|
||||||
|
{article.category}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Flex gap="2" align="center" className="text-[--gray-11] mb-3 flex-wrap">
|
||||||
|
<CalendarIcon className="w-3 h-3" />
|
||||||
|
<Text size="1">
|
||||||
|
{article.publishedAt?.toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<span className="mx-1">·</span>
|
||||||
|
<Text size="1" weight="medium">{article.authorName}</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Text className="text-[--gray-11] text-xs md:text-sm line-clamp-2 md:line-clamp-3 leading-relaxed">
|
||||||
|
{article.content}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Flex gap="2" className="mt-auto pt-3 flex-wrap">
|
||||||
|
{article.tags.map(tag => (
|
||||||
|
<Text
|
||||||
|
key={tag.name}
|
||||||
|
size="1"
|
||||||
|
className={`px-2 py-0.5 rounded-full ${tag.color.bg} ${tag.color.text}`}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Flex justify="center" align="center" gap="2" className="mt-8">
|
||||||
|
<Button
|
||||||
|
variant="soft"
|
||||||
|
className="group"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="w-4 h-4 group-hover:-translate-x-0.5 transition-transform" />
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Flex gap="1">
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
className="bg-[--accent-9] text-white hover:bg-[--accent-10]"
|
||||||
|
>
|
||||||
|
1
|
||||||
|
</Button>
|
||||||
|
<Button variant="soft">2</Button>
|
||||||
|
<Button variant="soft">3</Button>
|
||||||
|
<div className="flex items-center px-2 text-[--gray-11]">...</div>
|
||||||
|
<Button variant="soft">10</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="soft"
|
||||||
|
className="group"
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
<ChevronRightIcon className="w-4 h-4 group-hover:translate-x-0.5 transition-transform" />
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
@ -14,9 +14,10 @@ import { useState, useEffect } from "react";
|
|||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
import throttle from "lodash/throttle";
|
import throttle from "lodash/throttle";
|
||||||
import "./styles/layouts.css";
|
import "./styles/layouts.css";
|
||||||
|
import parse from 'html-react-parser';
|
||||||
|
|
||||||
// 直接导出 Layout 实例
|
// 直接导出 Layout 实例
|
||||||
const EchoesLayout = new Layout(({ children, args }) => {
|
export default new Layout(({ children, args }) => {
|
||||||
const [moreState, setMoreState] = useState(false);
|
const [moreState, setMoreState] = useState(false);
|
||||||
const [loginState, setLoginState] = useState(true);
|
const [loginState, setLoginState] = useState(true);
|
||||||
const [device, setDevice] = useState("");
|
const [device, setDevice] = useState("");
|
||||||
@ -34,6 +35,7 @@ const EchoesLayout = new Layout(({ children, args }) => {
|
|||||||
const handleResize = throttle(() => {
|
const handleResize = throttle(() => {
|
||||||
if (window.innerWidth >= 1024) {
|
if (window.innerWidth >= 1024) {
|
||||||
setDevice("desktop");
|
setDevice("desktop");
|
||||||
|
setMoreState(false);
|
||||||
} else {
|
} else {
|
||||||
setDevice("mobile");
|
setDevice("mobile");
|
||||||
}
|
}
|
||||||
@ -47,6 +49,8 @@ const EchoesLayout = new Layout(({ children, args }) => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const navString = typeof args === 'object' && args && 'nav' in args ? args.nav as string : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Theme
|
<Theme
|
||||||
grayColor="gray"
|
grayColor="gray"
|
||||||
@ -54,7 +58,10 @@ const EchoesLayout = new Layout(({ children, args }) => {
|
|||||||
radius="large"
|
radius="large"
|
||||||
panelBackground="solid"
|
panelBackground="solid"
|
||||||
>
|
>
|
||||||
<Box className="min-h-screen flex flex-col" id="nav">
|
<Box
|
||||||
|
className="min-h-screen flex flex-col"
|
||||||
|
id="nav"
|
||||||
|
>
|
||||||
{/* 导航栏 */}
|
{/* 导航栏 */}
|
||||||
<Box
|
<Box
|
||||||
asChild
|
asChild
|
||||||
@ -103,7 +110,7 @@ const EchoesLayout = new Layout(({ children, args }) => {
|
|||||||
</TextField.Root>
|
</TextField.Root>
|
||||||
|
|
||||||
<Box className="flex items-center gap-6">
|
<Box className="flex items-center gap-6">
|
||||||
<a href="h">首页</a>
|
{parse(navString)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<DropdownMenuPrimitive.Root>
|
<DropdownMenuPrimitive.Root>
|
||||||
@ -125,8 +132,8 @@ const EchoesLayout = new Layout(({ children, args }) => {
|
|||||||
>
|
>
|
||||||
<DropdownMenuPrimitive.Content
|
<DropdownMenuPrimitive.Content
|
||||||
align="end"
|
align="end"
|
||||||
sideOffset={5}
|
sideOffset={10}
|
||||||
className="mt-2 p-1 min-w-[180px] rounded-md bg-[--color-background] border border-[--gray-a5] shadow-lg animate-in fade-in slide-in-from-top-2"
|
className="mt-3 p-1 min-w-[180px] rounded-md bg-[--color-background] border border-[--gray-a5] shadow-lg animate-in fade-in slide-in-from-top-2"
|
||||||
>
|
>
|
||||||
{loginState ? (
|
{loginState ? (
|
||||||
<>
|
<>
|
||||||
@ -182,8 +189,7 @@ const EchoesLayout = new Layout(({ children, args }) => {
|
|||||||
className="mt-2 p-3 min-w-[280px] rounded-md bg-[--color-background] border border-[--gray-a5] shadow-lg animate-in fade-in slide-in-from-top-2"
|
className="mt-2 p-3 min-w-[280px] rounded-md bg-[--color-background] border border-[--gray-a5] shadow-lg animate-in fade-in slide-in-from-top-2"
|
||||||
>
|
>
|
||||||
<Box className="flex flex-col gap-2">
|
<Box className="flex flex-col gap-2">
|
||||||
<a href="h" >首页</a>
|
{parse(navString)}
|
||||||
<a href="h">首页</a>
|
|
||||||
</Box>
|
</Box>
|
||||||
<Box className="mt-3 pt-3 border-t border-[--gray-a5]">
|
<Box className="mt-3 pt-3 border-t border-[--gray-a5]">
|
||||||
<TextField.Root
|
<TextField.Root
|
||||||
@ -191,15 +197,13 @@ const EchoesLayout = new Layout(({ children, args }) => {
|
|||||||
variant="surface"
|
variant="surface"
|
||||||
placeholder="搜索..."
|
placeholder="搜索..."
|
||||||
className="w-full [&_input]:pl-3"
|
className="w-full [&_input]:pl-3"
|
||||||
|
id="search"
|
||||||
>
|
>
|
||||||
<TextField.Slot
|
<TextField.Slot
|
||||||
side="right"
|
side="right"
|
||||||
className="p-2"
|
className="p-2"
|
||||||
>
|
>
|
||||||
<MagnifyingGlassIcon
|
<MagnifyingGlassIcon className="h-4 w-4 text-[--gray-a12]" />
|
||||||
className="h-4 w-4 text-[--gray-a12]"
|
|
||||||
id="search"
|
|
||||||
/>
|
|
||||||
</TextField.Slot>
|
</TextField.Slot>
|
||||||
</TextField.Root>
|
</TextField.Root>
|
||||||
</Box>
|
</Box>
|
||||||
@ -234,4 +238,3 @@ const EchoesLayout = new Layout(({ children, args }) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export default EchoesLayout;
|
|
||||||
|
@ -6,10 +6,13 @@ const themeConfig: ThemeConfig = {
|
|||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
description: "一个简约风格的博客主题",
|
description: "一个简约风格的博客主题",
|
||||||
author: "lsy",
|
author: "lsy",
|
||||||
configuration: {},
|
configuration: {
|
||||||
globalSettings: {
|
"nav": {
|
||||||
layout: "layout.tsx",
|
title: "导航配置",
|
||||||
|
data: '<a href="h">你好</a> <a href="h">不好</a>'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
layout: "layout.tsx",
|
||||||
templates: new Map([
|
templates: new Map([
|
||||||
[
|
[
|
||||||
"page",
|
"page",
|
||||||
@ -21,14 +24,7 @@ const themeConfig: ThemeConfig = {
|
|||||||
],
|
],
|
||||||
]),
|
]),
|
||||||
|
|
||||||
routes: {
|
routes: new Map<string, string>([])
|
||||||
article: "",
|
|
||||||
post: "",
|
|
||||||
tag: "",
|
|
||||||
category: "",
|
|
||||||
error: "",
|
|
||||||
page: new Map<string, string>([]),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default themeConfig;
|
export default themeConfig;
|
||||||
|
Loading…
Reference in New Issue
Block a user