mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-15 03:04:44 +08:00
feat: implement the first version of landing page
This commit is contained in:
@@ -1,18 +1,24 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FlickeringGridProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
squareSize?: number
|
||||
gridGap?: number
|
||||
flickerChance?: number
|
||||
color?: string
|
||||
width?: number
|
||||
height?: number
|
||||
className?: string
|
||||
maxOpacity?: number
|
||||
squareSize?: number;
|
||||
gridGap?: number;
|
||||
flickerChance?: number;
|
||||
color?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
className?: string;
|
||||
maxOpacity?: number;
|
||||
}
|
||||
|
||||
export const FlickeringGrid: React.FC<FlickeringGridProps> = ({
|
||||
@@ -26,58 +32,58 @@ export const FlickeringGrid: React.FC<FlickeringGridProps> = ({
|
||||
maxOpacity = 0.3,
|
||||
...props
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [isInView, setIsInView] = useState(false)
|
||||
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 })
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isInView, setIsInView] = useState(false);
|
||||
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
const memoizedColor = useMemo(() => {
|
||||
const toRGBA = (color: string) => {
|
||||
if (typeof window === "undefined") {
|
||||
return `rgba(0, 0, 0,`
|
||||
return `rgba(0, 0, 0,`;
|
||||
}
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = canvas.height = 1
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return "rgba(255, 0, 0,"
|
||||
ctx.fillStyle = color
|
||||
ctx.fillRect(0, 0, 1, 1)
|
||||
const [r, g, b] = Array.from(ctx.getImageData(0, 0, 1, 1).data)
|
||||
return `rgba(${r}, ${g}, ${b},`
|
||||
}
|
||||
return toRGBA(color)
|
||||
}, [color])
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvas.height = 1;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return "rgba(255, 0, 0,";
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
const [r, g, b] = Array.from(ctx.getImageData(0, 0, 1, 1).data);
|
||||
return `rgba(${r}, ${g}, ${b},`;
|
||||
};
|
||||
return toRGBA(color);
|
||||
}, [color]);
|
||||
|
||||
const setupCanvas = useCallback(
|
||||
(canvas: HTMLCanvasElement, width: number, height: number) => {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
canvas.width = width * dpr
|
||||
canvas.height = height * dpr
|
||||
canvas.style.width = `${width}px`
|
||||
canvas.style.height = `${height}px`
|
||||
const cols = Math.floor(width / (squareSize + gridGap))
|
||||
const rows = Math.floor(height / (squareSize + gridGap))
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = width * dpr;
|
||||
canvas.height = height * dpr;
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
const cols = Math.floor(width / (squareSize + gridGap));
|
||||
const rows = Math.floor(height / (squareSize + gridGap));
|
||||
|
||||
const squares = new Float32Array(cols * rows)
|
||||
const squares = new Float32Array(cols * rows);
|
||||
for (let i = 0; i < squares.length; i++) {
|
||||
squares[i] = Math.random() * maxOpacity
|
||||
squares[i] = Math.random() * maxOpacity;
|
||||
}
|
||||
|
||||
return { cols, rows, squares, dpr }
|
||||
return { cols, rows, squares, dpr };
|
||||
},
|
||||
[squareSize, gridGap, maxOpacity]
|
||||
)
|
||||
[squareSize, gridGap, maxOpacity],
|
||||
);
|
||||
|
||||
const updateSquares = useCallback(
|
||||
(squares: Float32Array, deltaTime: number) => {
|
||||
for (let i = 0; i < squares.length; i++) {
|
||||
if (Math.random() < flickerChance * deltaTime) {
|
||||
squares[i] = Math.random() * maxOpacity
|
||||
squares[i] = Math.random() * maxOpacity;
|
||||
}
|
||||
}
|
||||
},
|
||||
[flickerChance, maxOpacity]
|
||||
)
|
||||
[flickerChance, maxOpacity],
|
||||
);
|
||||
|
||||
const drawGrid = useCallback(
|
||||
(
|
||||
@@ -87,56 +93,56 @@ export const FlickeringGrid: React.FC<FlickeringGridProps> = ({
|
||||
cols: number,
|
||||
rows: number,
|
||||
squares: Float32Array,
|
||||
dpr: number
|
||||
dpr: number,
|
||||
) => {
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
ctx.fillStyle = "transparent"
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.fillStyle = "transparent";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
for (let i = 0; i < cols; i++) {
|
||||
for (let j = 0; j < rows; j++) {
|
||||
const opacity = squares[i * rows + j]
|
||||
ctx.fillStyle = `${memoizedColor}${opacity})`
|
||||
const opacity = squares[i * rows + j];
|
||||
ctx.fillStyle = `${memoizedColor}${opacity})`;
|
||||
ctx.fillRect(
|
||||
i * (squareSize + gridGap) * dpr,
|
||||
j * (squareSize + gridGap) * dpr,
|
||||
squareSize * dpr,
|
||||
squareSize * dpr
|
||||
)
|
||||
squareSize * dpr,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[memoizedColor, squareSize, gridGap]
|
||||
)
|
||||
[memoizedColor, squareSize, gridGap],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
const container = containerRef.current
|
||||
if (!canvas || !container) return
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
let animationFrameId: number
|
||||
let gridParams: ReturnType<typeof setupCanvas>
|
||||
let animationFrameId: number;
|
||||
let gridParams: ReturnType<typeof setupCanvas>;
|
||||
|
||||
const updateCanvasSize = () => {
|
||||
const newWidth = width || container.clientWidth
|
||||
const newHeight = height || container.clientHeight
|
||||
setCanvasSize({ width: newWidth, height: newHeight })
|
||||
gridParams = setupCanvas(canvas, newWidth, newHeight)
|
||||
}
|
||||
const newWidth = width || container.clientWidth;
|
||||
const newHeight = height || container.clientHeight;
|
||||
setCanvasSize({ width: newWidth, height: newHeight });
|
||||
gridParams = setupCanvas(canvas, newWidth, newHeight);
|
||||
};
|
||||
|
||||
updateCanvasSize()
|
||||
updateCanvasSize();
|
||||
|
||||
let lastTime = 0
|
||||
let lastTime = 0;
|
||||
const animate = (time: number) => {
|
||||
if (!isInView) return
|
||||
if (!isInView) return;
|
||||
|
||||
const deltaTime = (time - lastTime) / 1000
|
||||
lastTime = time
|
||||
const deltaTime = (time - lastTime) / 1000;
|
||||
lastTime = time;
|
||||
|
||||
updateSquares(gridParams.squares, deltaTime)
|
||||
updateSquares(gridParams.squares, deltaTime);
|
||||
drawGrid(
|
||||
ctx,
|
||||
canvas.width,
|
||||
@@ -144,36 +150,38 @@ export const FlickeringGrid: React.FC<FlickeringGridProps> = ({
|
||||
gridParams.cols,
|
||||
gridParams.rows,
|
||||
gridParams.squares,
|
||||
gridParams.dpr
|
||||
)
|
||||
animationFrameId = requestAnimationFrame(animate)
|
||||
}
|
||||
gridParams.dpr,
|
||||
);
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
updateCanvasSize()
|
||||
})
|
||||
updateCanvasSize();
|
||||
});
|
||||
|
||||
resizeObserver.observe(container)
|
||||
resizeObserver.observe(container);
|
||||
|
||||
const intersectionObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
setIsInView(entry.isIntersecting)
|
||||
if (entry) {
|
||||
setIsInView(entry.isIntersecting);
|
||||
}
|
||||
},
|
||||
{ threshold: 0 }
|
||||
)
|
||||
{ threshold: 0 },
|
||||
);
|
||||
|
||||
intersectionObserver.observe(canvas)
|
||||
intersectionObserver.observe(canvas);
|
||||
|
||||
if (isInView) {
|
||||
animationFrameId = requestAnimationFrame(animate)
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
resizeObserver.disconnect()
|
||||
intersectionObserver.disconnect()
|
||||
}
|
||||
}, [setupCanvas, updateSquares, drawGrid, width, height, isInView])
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
resizeObserver.disconnect();
|
||||
intersectionObserver.disconnect();
|
||||
};
|
||||
}, [setupCanvas, updateSquares, drawGrid, width, height, isInView]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -190,5 +198,5 @@ export const FlickeringGrid: React.FC<FlickeringGridProps> = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
5
frontend/src/components/ui/galaxy.css
Normal file
5
frontend/src/components/ui/galaxy.css
Normal file
@@ -0,0 +1,5 @@
|
||||
.galaxy-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
344
frontend/src/components/ui/galaxy.jsx
Normal file
344
frontend/src/components/ui/galaxy.jsx
Normal file
@@ -0,0 +1,344 @@
|
||||
import { Renderer, Program, Mesh, Color, Triangle } from "ogl";
|
||||
import { useEffect, useRef } from "react";
|
||||
import "./galaxy.css";
|
||||
|
||||
const vertexShader = `
|
||||
attribute vec2 uv;
|
||||
attribute vec2 position;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 0, 1);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = `
|
||||
precision highp float;
|
||||
|
||||
uniform float uTime;
|
||||
uniform vec3 uResolution;
|
||||
uniform vec2 uFocal;
|
||||
uniform vec2 uRotation;
|
||||
uniform float uStarSpeed;
|
||||
uniform float uDensity;
|
||||
uniform float uHueShift;
|
||||
uniform float uSpeed;
|
||||
uniform vec2 uMouse;
|
||||
uniform float uGlowIntensity;
|
||||
uniform float uSaturation;
|
||||
uniform bool uMouseRepulsion;
|
||||
uniform float uTwinkleIntensity;
|
||||
uniform float uRotationSpeed;
|
||||
uniform float uRepulsionStrength;
|
||||
uniform float uMouseActiveFactor;
|
||||
uniform float uAutoCenterRepulsion;
|
||||
uniform bool uTransparent;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
#define NUM_LAYER 4.0
|
||||
#define STAR_COLOR_CUTOFF 0.2
|
||||
#define MAT45 mat2(0.7071, -0.7071, 0.7071, 0.7071)
|
||||
#define PERIOD 3.0
|
||||
|
||||
float Hash21(vec2 p) {
|
||||
p = fract(p * vec2(123.34, 456.21));
|
||||
p += dot(p, p + 45.32);
|
||||
return fract(p.x * p.y);
|
||||
}
|
||||
|
||||
float tri(float x) {
|
||||
return abs(fract(x) * 2.0 - 1.0);
|
||||
}
|
||||
|
||||
float tris(float x) {
|
||||
float t = fract(x);
|
||||
return 1.0 - smoothstep(0.0, 1.0, abs(2.0 * t - 1.0));
|
||||
}
|
||||
|
||||
float trisn(float x) {
|
||||
float t = fract(x);
|
||||
return 2.0 * (1.0 - smoothstep(0.0, 1.0, abs(2.0 * t - 1.0))) - 1.0;
|
||||
}
|
||||
|
||||
vec3 hsv2rgb(vec3 c) {
|
||||
vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
|
||||
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
|
||||
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
|
||||
}
|
||||
|
||||
float Star(vec2 uv, float flare) {
|
||||
float d = length(uv);
|
||||
float m = (0.05 * uGlowIntensity) / d;
|
||||
float rays = smoothstep(0.0, 1.0, 1.0 - abs(uv.x * uv.y * 1000.0));
|
||||
m += rays * flare * uGlowIntensity;
|
||||
uv *= MAT45;
|
||||
rays = smoothstep(0.0, 1.0, 1.0 - abs(uv.x * uv.y * 1000.0));
|
||||
m += rays * 0.3 * flare * uGlowIntensity;
|
||||
m *= smoothstep(1.0, 0.2, d);
|
||||
return m;
|
||||
}
|
||||
|
||||
vec3 StarLayer(vec2 uv) {
|
||||
vec3 col = vec3(0.0);
|
||||
|
||||
vec2 gv = fract(uv) - 0.5;
|
||||
vec2 id = floor(uv);
|
||||
|
||||
for (int y = -1; y <= 1; y++) {
|
||||
for (int x = -1; x <= 1; x++) {
|
||||
vec2 offset = vec2(float(x), float(y));
|
||||
vec2 si = id + vec2(float(x), float(y));
|
||||
float seed = Hash21(si);
|
||||
float size = fract(seed * 345.32);
|
||||
float glossLocal = tri(uStarSpeed / (PERIOD * seed + 1.0));
|
||||
float flareSize = smoothstep(0.9, 1.0, size) * glossLocal;
|
||||
|
||||
float red = smoothstep(STAR_COLOR_CUTOFF, 1.0, Hash21(si + 1.0)) + STAR_COLOR_CUTOFF;
|
||||
float blu = smoothstep(STAR_COLOR_CUTOFF, 1.0, Hash21(si + 3.0)) + STAR_COLOR_CUTOFF;
|
||||
float grn = min(red, blu) * seed;
|
||||
vec3 base = vec3(red, grn, blu);
|
||||
|
||||
float hue = atan(base.g - base.r, base.b - base.r) / (2.0 * 3.14159) + 0.5;
|
||||
hue = fract(hue + uHueShift / 360.0);
|
||||
float sat = length(base - vec3(dot(base, vec3(0.299, 0.587, 0.114)))) * uSaturation;
|
||||
float val = max(max(base.r, base.g), base.b);
|
||||
base = hsv2rgb(vec3(hue, sat, val));
|
||||
|
||||
vec2 pad = vec2(tris(seed * 34.0 + uTime * uSpeed / 10.0), tris(seed * 38.0 + uTime * uSpeed / 30.0)) - 0.5;
|
||||
|
||||
float star = Star(gv - offset - pad, flareSize);
|
||||
vec3 color = base;
|
||||
|
||||
float twinkle = trisn(uTime * uSpeed + seed * 6.2831) * 0.5 + 1.0;
|
||||
twinkle = mix(1.0, twinkle, uTwinkleIntensity);
|
||||
star *= twinkle;
|
||||
|
||||
col += star * size * color;
|
||||
}
|
||||
}
|
||||
|
||||
return col;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 focalPx = uFocal * uResolution.xy;
|
||||
vec2 uv = (vUv * uResolution.xy - focalPx) / uResolution.y;
|
||||
|
||||
vec2 mouseNorm = uMouse - vec2(0.5);
|
||||
|
||||
if (uAutoCenterRepulsion > 0.0) {
|
||||
vec2 centerUV = vec2(0.0, 0.0);
|
||||
float centerDist = length(uv - centerUV);
|
||||
vec2 repulsion = normalize(uv - centerUV) * (uAutoCenterRepulsion / (centerDist + 0.1));
|
||||
uv += repulsion * 0.05;
|
||||
} else if (uMouseRepulsion) {
|
||||
vec2 mousePosUV = (uMouse * uResolution.xy - focalPx) / uResolution.y;
|
||||
float mouseDist = length(uv - mousePosUV);
|
||||
vec2 repulsion = normalize(uv - mousePosUV) * (uRepulsionStrength / (mouseDist + 0.1));
|
||||
uv += repulsion * 0.05 * uMouseActiveFactor;
|
||||
} else {
|
||||
vec2 mouseOffset = mouseNorm * 0.1 * uMouseActiveFactor;
|
||||
uv += mouseOffset;
|
||||
}
|
||||
|
||||
float autoRotAngle = uTime * uRotationSpeed;
|
||||
mat2 autoRot = mat2(cos(autoRotAngle), -sin(autoRotAngle), sin(autoRotAngle), cos(autoRotAngle));
|
||||
uv = autoRot * uv;
|
||||
|
||||
uv = mat2(uRotation.x, -uRotation.y, uRotation.y, uRotation.x) * uv;
|
||||
|
||||
vec3 col = vec3(0.0);
|
||||
|
||||
for (float i = 0.0; i < 1.0; i += 1.0 / NUM_LAYER) {
|
||||
float depth = fract(i + uStarSpeed * uSpeed);
|
||||
float scale = mix(20.0 * uDensity, 0.5 * uDensity, depth);
|
||||
float fade = depth * smoothstep(1.0, 0.9, depth);
|
||||
col += StarLayer(uv * scale + i * 453.32) * fade;
|
||||
}
|
||||
|
||||
if (uTransparent) {
|
||||
float alpha = length(col);
|
||||
alpha = smoothstep(0.0, 0.3, alpha);
|
||||
alpha = min(alpha, 1.0);
|
||||
gl_FragColor = vec4(col, alpha);
|
||||
} else {
|
||||
gl_FragColor = vec4(col, 1.0);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default function Galaxy({
|
||||
focal = [0.5, 0.5],
|
||||
rotation = [1.0, 0.0],
|
||||
starSpeed = 0.5,
|
||||
density = 1,
|
||||
hueShift = 140,
|
||||
disableAnimation = false,
|
||||
speed = 1.0,
|
||||
mouseInteraction = true,
|
||||
glowIntensity = 0.3,
|
||||
saturation = 0.0,
|
||||
mouseRepulsion = true,
|
||||
repulsionStrength = 2,
|
||||
twinkleIntensity = 0.3,
|
||||
rotationSpeed = 0.1,
|
||||
autoCenterRepulsion = 0,
|
||||
transparent = true,
|
||||
...rest
|
||||
}) {
|
||||
const ctnDom = useRef(null);
|
||||
const targetMousePos = useRef({ x: 0.5, y: 0.5 });
|
||||
const smoothMousePos = useRef({ x: 0.5, y: 0.5 });
|
||||
const targetMouseActive = useRef(0.0);
|
||||
const smoothMouseActive = useRef(0.0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctnDom.current) return;
|
||||
const ctn = ctnDom.current;
|
||||
const renderer = new Renderer({
|
||||
alpha: transparent,
|
||||
premultipliedAlpha: false,
|
||||
});
|
||||
const gl = renderer.gl;
|
||||
|
||||
if (transparent) {
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
} else {
|
||||
gl.clearColor(0, 0, 0, 1);
|
||||
}
|
||||
|
||||
/** @type {Program | undefined} */
|
||||
let program;
|
||||
|
||||
function resize() {
|
||||
const scale = 1;
|
||||
renderer.setSize(ctn.offsetWidth * scale, ctn.offsetHeight * scale);
|
||||
if (program) {
|
||||
program.uniforms.uResolution.value = new Color(
|
||||
gl.canvas.width,
|
||||
gl.canvas.height,
|
||||
gl.canvas.width / gl.canvas.height,
|
||||
);
|
||||
}
|
||||
}
|
||||
window.addEventListener("resize", resize, false);
|
||||
resize();
|
||||
|
||||
const geometry = new Triangle(gl);
|
||||
program = new Program(gl, {
|
||||
vertex: vertexShader,
|
||||
fragment: fragmentShader,
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uResolution: {
|
||||
value: new Color(
|
||||
gl.canvas.width,
|
||||
gl.canvas.height,
|
||||
gl.canvas.width / gl.canvas.height,
|
||||
),
|
||||
},
|
||||
uFocal: { value: new Float32Array(focal) },
|
||||
uRotation: { value: new Float32Array(rotation) },
|
||||
uStarSpeed: { value: starSpeed },
|
||||
uDensity: { value: density },
|
||||
uHueShift: { value: hueShift },
|
||||
uSpeed: { value: speed },
|
||||
uMouse: {
|
||||
value: new Float32Array([
|
||||
smoothMousePos.current.x,
|
||||
smoothMousePos.current.y,
|
||||
]),
|
||||
},
|
||||
uGlowIntensity: { value: glowIntensity },
|
||||
uSaturation: { value: saturation },
|
||||
uMouseRepulsion: { value: mouseRepulsion },
|
||||
uTwinkleIntensity: { value: twinkleIntensity },
|
||||
uRotationSpeed: { value: rotationSpeed },
|
||||
uRepulsionStrength: { value: repulsionStrength },
|
||||
uMouseActiveFactor: { value: 0.0 },
|
||||
uAutoCenterRepulsion: { value: autoCenterRepulsion },
|
||||
uTransparent: { value: transparent },
|
||||
},
|
||||
});
|
||||
|
||||
const mesh = new Mesh(gl, { geometry, program });
|
||||
let animateId;
|
||||
|
||||
function update(t) {
|
||||
animateId = requestAnimationFrame(update);
|
||||
if (!disableAnimation) {
|
||||
program.uniforms.uTime.value = t * 0.001;
|
||||
program.uniforms.uStarSpeed.value = (t * 0.001 * starSpeed) / 10.0;
|
||||
}
|
||||
|
||||
const lerpFactor = 0.05;
|
||||
smoothMousePos.current.x +=
|
||||
(targetMousePos.current.x - smoothMousePos.current.x) * lerpFactor;
|
||||
smoothMousePos.current.y +=
|
||||
(targetMousePos.current.y - smoothMousePos.current.y) * lerpFactor;
|
||||
|
||||
smoothMouseActive.current +=
|
||||
(targetMouseActive.current - smoothMouseActive.current) * lerpFactor;
|
||||
|
||||
program.uniforms.uMouse.value[0] = smoothMousePos.current.x;
|
||||
program.uniforms.uMouse.value[1] = smoothMousePos.current.y;
|
||||
program.uniforms.uMouseActiveFactor.value = smoothMouseActive.current;
|
||||
|
||||
renderer.render({ scene: mesh });
|
||||
}
|
||||
animateId = requestAnimationFrame(update);
|
||||
ctn.appendChild(gl.canvas);
|
||||
|
||||
function handleMouseMove(e) {
|
||||
const rect = ctn.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
const y = 1.0 - (e.clientY - rect.top) / rect.height;
|
||||
targetMousePos.current = { x, y };
|
||||
targetMouseActive.current = 1.0;
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
targetMouseActive.current = 0.0;
|
||||
}
|
||||
|
||||
if (mouseInteraction) {
|
||||
ctn.addEventListener("mousemove", handleMouseMove);
|
||||
ctn.addEventListener("mouseleave", handleMouseLeave);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animateId);
|
||||
window.removeEventListener("resize", resize);
|
||||
if (mouseInteraction) {
|
||||
ctn.removeEventListener("mousemove", handleMouseMove);
|
||||
ctn.removeEventListener("mouseleave", handleMouseLeave);
|
||||
}
|
||||
ctn.removeChild(gl.canvas);
|
||||
gl.getExtension("WEBGL_lose_context")?.loseContext();
|
||||
};
|
||||
}, [
|
||||
focal,
|
||||
rotation,
|
||||
starSpeed,
|
||||
density,
|
||||
hueShift,
|
||||
disableAnimation,
|
||||
speed,
|
||||
mouseInteraction,
|
||||
glowIntensity,
|
||||
saturation,
|
||||
mouseRepulsion,
|
||||
twinkleIntensity,
|
||||
rotationSpeed,
|
||||
repulsionStrength,
|
||||
autoCenterRepulsion,
|
||||
transparent,
|
||||
]);
|
||||
|
||||
return <div ref={ctnDom} className="galaxy-container" {...rest} />;
|
||||
}
|
||||
217
frontend/src/components/ui/magic-bento.css
Normal file
217
frontend/src/components/ui/magic-bento.css
Normal file
@@ -0,0 +1,217 @@
|
||||
:root {
|
||||
--hue: 27;
|
||||
--sat: 69%;
|
||||
--white: hsl(0, 0%, 100%);
|
||||
--purple-primary: rgba(132, 0, 255, 1);
|
||||
--purple-glow: rgba(132, 0, 255, 0.2);
|
||||
--purple-border: rgba(132, 0, 255, 0.8);
|
||||
--border-color: #392e4e;
|
||||
--background-dark: #060010;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
gap: 0.5em;
|
||||
padding: 0.75em;
|
||||
max-width: 54em;
|
||||
font-size: clamp(1rem, 0.9rem + 0.5vw, 1.5rem);
|
||||
}
|
||||
|
||||
.magic-bento-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
aspect-ratio: 4/3;
|
||||
min-height: 200px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 1.25em;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--background-dark);
|
||||
font-weight: 300;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
--glow-x: 50%;
|
||||
--glow-y: 50%;
|
||||
--glow-intensity: 0;
|
||||
--glow-radius: 200px;
|
||||
}
|
||||
|
||||
.magic-bento-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.magic-bento-card__header,
|
||||
.magic-bento-card__content {
|
||||
display: flex;
|
||||
position: relative;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.magic-bento-card__header {
|
||||
gap: 0.75em;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.magic-bento-card__content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.magic-bento-card__label {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.magic-bento-card__title,
|
||||
.magic-bento-card__description {
|
||||
--clamp-title: 1;
|
||||
--clamp-desc: 2;
|
||||
}
|
||||
|
||||
.magic-bento-card__title {
|
||||
font-weight: 400;
|
||||
font-size: 16px;
|
||||
margin: 0 0 0.25em;
|
||||
}
|
||||
|
||||
.magic-bento-card__description {
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.magic-bento-card--text-autohide .magic-bento-card__title,
|
||||
.magic-bento-card--text-autohide .magic-bento-card__description {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.magic-bento-card--text-autohide .magic-bento-card__title {
|
||||
-webkit-line-clamp: var(--clamp-title);
|
||||
line-clamp: var(--clamp-title);
|
||||
}
|
||||
|
||||
.magic-bento-card--text-autohide .magic-bento-card__description {
|
||||
-webkit-line-clamp: var(--clamp-desc);
|
||||
line-clamp: var(--clamp-desc);
|
||||
}
|
||||
|
||||
@media (max-width: 599px) {
|
||||
.card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
width: 90%;
|
||||
margin: 0 auto;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
.magic-bento-card {
|
||||
width: 100%;
|
||||
min-height: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
.magic-bento-card:nth-child(3) {
|
||||
grid-column: span 2;
|
||||
grid-row: span 2;
|
||||
}
|
||||
|
||||
.magic-bento-card:nth-child(4) {
|
||||
grid-column: 1 / span 2;
|
||||
grid-row: 2 / span 2;
|
||||
}
|
||||
|
||||
.magic-bento-card:nth-child(6) {
|
||||
grid-column: 4;
|
||||
grid-row: 3;
|
||||
}
|
||||
}
|
||||
|
||||
/* Border glow effect */
|
||||
.magic-bento-card--border-glow::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 6px;
|
||||
background: radial-gradient(
|
||||
var(--glow-radius) circle at var(--glow-x) var(--glow-y),
|
||||
rgba(132, 0, 255, calc(var(--glow-intensity) * 0.8)) 0%,
|
||||
rgba(132, 0, 255, calc(var(--glow-intensity) * 0.4)) 30%,
|
||||
transparent 60%
|
||||
);
|
||||
border-radius: inherit;
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.magic-bento-card--border-glow:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.magic-bento-card--border-glow:hover {
|
||||
box-shadow:
|
||||
0 4px 20px rgba(46, 24, 78, 0.4),
|
||||
0 0 30px var(--purple-glow);
|
||||
}
|
||||
|
||||
.particle-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.particle::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
background: rgba(132, 0, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.particle-container:hover {
|
||||
box-shadow:
|
||||
0 4px 20px rgba(46, 24, 78, 0.2),
|
||||
0 0 30px var(--purple-glow);
|
||||
}
|
||||
|
||||
/* Global spotlight styles */
|
||||
.global-spotlight {
|
||||
mix-blend-mode: screen;
|
||||
will-change: transform, opacity;
|
||||
z-index: 200 !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bento-section {
|
||||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
757
frontend/src/components/ui/magic-bento.tsx
Normal file
757
frontend/src/components/ui/magic-bento.tsx
Normal file
@@ -0,0 +1,757 @@
|
||||
import { gsap } from "gsap";
|
||||
import React, { useRef, useEffect, useCallback, useState } from "react";
|
||||
import "./magic-bento.css";
|
||||
|
||||
export interface BentoCardProps {
|
||||
color?: string;
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
label?: React.ReactNode;
|
||||
textAutoHide?: boolean;
|
||||
disableAnimations?: boolean;
|
||||
}
|
||||
|
||||
export interface BentoProps {
|
||||
textAutoHide?: boolean;
|
||||
enableStars?: boolean;
|
||||
enableSpotlight?: boolean;
|
||||
enableBorderGlow?: boolean;
|
||||
disableAnimations?: boolean;
|
||||
spotlightRadius?: number;
|
||||
particleCount?: number;
|
||||
enableTilt?: boolean;
|
||||
glowColor?: string;
|
||||
clickEffect?: boolean;
|
||||
enableMagnetism?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_PARTICLE_COUNT = 12;
|
||||
const DEFAULT_SPOTLIGHT_RADIUS = 300;
|
||||
const DEFAULT_GLOW_COLOR = "132, 0, 255";
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
const cardData: BentoCardProps[] = [
|
||||
{
|
||||
color: "#0a0015",
|
||||
title: "Long/Short-term Memory",
|
||||
description: (
|
||||
<div>
|
||||
<div>Now the agent can better understand you</div>
|
||||
<div className="text-muted-foreground">Coming soon</div>
|
||||
</div>
|
||||
),
|
||||
label: "Context Engineering",
|
||||
},
|
||||
{
|
||||
color: "#0a0015",
|
||||
title: "Planning and Reasoning",
|
||||
description: "Plans ahead, reasons through complexity, then acts",
|
||||
label: "Long Task Running",
|
||||
},
|
||||
{
|
||||
color: "#0a0015",
|
||||
title: "Skills and Tools",
|
||||
description:
|
||||
"Plug, play, or even swap built-in tools. Build the agent you want.",
|
||||
label: "Extensible",
|
||||
},
|
||||
|
||||
{
|
||||
color: "#0a0015",
|
||||
title: "Sandbox with File System",
|
||||
description: "Read, write, run — like a real computer",
|
||||
label: "Persistent",
|
||||
},
|
||||
{
|
||||
color: "#0a0015",
|
||||
title: "Multi-Model Support",
|
||||
description: "Doubao, DeepSeek, OpenAI, Gemini, etc.",
|
||||
label: "Flexible",
|
||||
},
|
||||
{
|
||||
color: "#0a0015",
|
||||
title: "Open Source",
|
||||
description: "MIT License, self-hosted, full control",
|
||||
label: "Free",
|
||||
},
|
||||
];
|
||||
|
||||
const createParticleElement = (
|
||||
x: number,
|
||||
y: number,
|
||||
color: string = DEFAULT_GLOW_COLOR,
|
||||
): HTMLDivElement => {
|
||||
const el = document.createElement("div");
|
||||
el.className = "particle";
|
||||
el.style.cssText = `
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: rgba(${color}, 1);
|
||||
box-shadow: 0 0 6px rgba(${color}, 0.6);
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
left: ${x}px;
|
||||
top: ${y}px;
|
||||
`;
|
||||
return el;
|
||||
};
|
||||
|
||||
const calculateSpotlightValues = (radius: number) => ({
|
||||
proximity: radius * 0.5,
|
||||
fadeDistance: radius * 0.75,
|
||||
});
|
||||
|
||||
const updateCardGlowProperties = (
|
||||
card: HTMLElement,
|
||||
mouseX: number,
|
||||
mouseY: number,
|
||||
glow: number,
|
||||
radius: number,
|
||||
) => {
|
||||
const rect = card.getBoundingClientRect();
|
||||
const relativeX = ((mouseX - rect.left) / rect.width) * 100;
|
||||
const relativeY = ((mouseY - rect.top) / rect.height) * 100;
|
||||
|
||||
card.style.setProperty("--glow-x", `${relativeX}%`);
|
||||
card.style.setProperty("--glow-y", `${relativeY}%`);
|
||||
card.style.setProperty("--glow-intensity", glow.toString());
|
||||
card.style.setProperty("--glow-radius", `${radius}px`);
|
||||
};
|
||||
|
||||
const ParticleCard: React.FC<{
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
disableAnimations?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
particleCount?: number;
|
||||
glowColor?: string;
|
||||
enableTilt?: boolean;
|
||||
clickEffect?: boolean;
|
||||
enableMagnetism?: boolean;
|
||||
}> = ({
|
||||
children,
|
||||
className = "",
|
||||
disableAnimations = false,
|
||||
style,
|
||||
particleCount = DEFAULT_PARTICLE_COUNT,
|
||||
glowColor = DEFAULT_GLOW_COLOR,
|
||||
enableTilt = true,
|
||||
clickEffect = false,
|
||||
enableMagnetism = false,
|
||||
}) => {
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const particlesRef = useRef<HTMLDivElement[]>([]);
|
||||
const timeoutsRef = useRef<number[]>([]);
|
||||
const isHoveredRef = useRef(false);
|
||||
const memoizedParticles = useRef<HTMLDivElement[]>([]);
|
||||
const particlesInitialized = useRef(false);
|
||||
const magnetismAnimationRef = useRef<gsap.core.Tween | null>(null);
|
||||
|
||||
const initializeParticles = useCallback(() => {
|
||||
if (particlesInitialized.current || !cardRef.current) return;
|
||||
|
||||
const { width, height } = cardRef.current.getBoundingClientRect();
|
||||
memoizedParticles.current = Array.from({ length: particleCount }, () =>
|
||||
createParticleElement(
|
||||
Math.random() * width,
|
||||
Math.random() * height,
|
||||
glowColor,
|
||||
),
|
||||
);
|
||||
particlesInitialized.current = true;
|
||||
}, [particleCount, glowColor]);
|
||||
|
||||
const clearAllParticles = useCallback(() => {
|
||||
timeoutsRef.current.forEach(clearTimeout);
|
||||
timeoutsRef.current = [];
|
||||
magnetismAnimationRef.current?.kill();
|
||||
|
||||
particlesRef.current.forEach((particle) => {
|
||||
gsap.to(particle, {
|
||||
scale: 0,
|
||||
opacity: 0,
|
||||
duration: 0.3,
|
||||
ease: "back.in(1.7)",
|
||||
onComplete: () => {
|
||||
particle.parentNode?.removeChild(particle);
|
||||
},
|
||||
});
|
||||
});
|
||||
particlesRef.current = [];
|
||||
}, []);
|
||||
|
||||
const animateParticles = useCallback(() => {
|
||||
if (!cardRef.current || !isHoveredRef.current) return;
|
||||
|
||||
if (!particlesInitialized.current) {
|
||||
initializeParticles();
|
||||
}
|
||||
|
||||
memoizedParticles.current.forEach((particle, index) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!isHoveredRef.current || !cardRef.current) return;
|
||||
|
||||
const clone = particle.cloneNode(true) as HTMLDivElement;
|
||||
cardRef.current.appendChild(clone);
|
||||
particlesRef.current.push(clone);
|
||||
|
||||
gsap.fromTo(
|
||||
clone,
|
||||
{ scale: 0, opacity: 0 },
|
||||
{ scale: 1, opacity: 1, duration: 0.3, ease: "back.out(1.7)" },
|
||||
);
|
||||
|
||||
gsap.to(clone, {
|
||||
x: (Math.random() - 0.5) * 100,
|
||||
y: (Math.random() - 0.5) * 100,
|
||||
rotation: Math.random() * 360,
|
||||
duration: 2 + Math.random() * 2,
|
||||
ease: "none",
|
||||
repeat: -1,
|
||||
yoyo: true,
|
||||
});
|
||||
|
||||
gsap.to(clone, {
|
||||
opacity: 0.3,
|
||||
duration: 1.5,
|
||||
ease: "power2.inOut",
|
||||
repeat: -1,
|
||||
yoyo: true,
|
||||
});
|
||||
}, index * 100);
|
||||
|
||||
timeoutsRef.current.push(timeoutId as unknown as number);
|
||||
});
|
||||
}, [initializeParticles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (disableAnimations || !cardRef.current) return;
|
||||
|
||||
const element = cardRef.current;
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
isHoveredRef.current = true;
|
||||
animateParticles();
|
||||
|
||||
if (enableTilt) {
|
||||
gsap.to(element, {
|
||||
rotateX: 5,
|
||||
rotateY: 5,
|
||||
duration: 0.3,
|
||||
ease: "power2.out",
|
||||
transformPerspective: 1000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
isHoveredRef.current = false;
|
||||
clearAllParticles();
|
||||
|
||||
if (enableTilt) {
|
||||
gsap.to(element, {
|
||||
rotateX: 0,
|
||||
rotateY: 0,
|
||||
duration: 0.3,
|
||||
ease: "power2.out",
|
||||
});
|
||||
}
|
||||
|
||||
if (enableMagnetism) {
|
||||
gsap.to(element, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
duration: 0.3,
|
||||
ease: "power2.out",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!enableTilt && !enableMagnetism) return;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
const centerX = rect.width / 2;
|
||||
const centerY = rect.height / 2;
|
||||
|
||||
if (enableTilt) {
|
||||
const rotateX = ((y - centerY) / centerY) * -10;
|
||||
const rotateY = ((x - centerX) / centerX) * 10;
|
||||
|
||||
gsap.to(element, {
|
||||
rotateX,
|
||||
rotateY,
|
||||
duration: 0.1,
|
||||
ease: "power2.out",
|
||||
transformPerspective: 1000,
|
||||
});
|
||||
}
|
||||
|
||||
if (enableMagnetism) {
|
||||
const magnetX = (x - centerX) * 0.05;
|
||||
const magnetY = (y - centerY) * 0.05;
|
||||
|
||||
magnetismAnimationRef.current = gsap.to(element, {
|
||||
x: magnetX,
|
||||
y: magnetY,
|
||||
duration: 0.3,
|
||||
ease: "power2.out",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (!clickEffect) return;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
const maxDistance = Math.max(
|
||||
Math.hypot(x, y),
|
||||
Math.hypot(x - rect.width, y),
|
||||
Math.hypot(x, y - rect.height),
|
||||
Math.hypot(x - rect.width, y - rect.height),
|
||||
);
|
||||
|
||||
const ripple = document.createElement("div");
|
||||
ripple.style.cssText = `
|
||||
position: absolute;
|
||||
width: ${maxDistance * 2}px;
|
||||
height: ${maxDistance * 2}px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(${glowColor}, 0.4) 0%, rgba(${glowColor}, 0.2) 30%, transparent 70%);
|
||||
left: ${x - maxDistance}px;
|
||||
top: ${y - maxDistance}px;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
`;
|
||||
|
||||
element.appendChild(ripple);
|
||||
|
||||
gsap.fromTo(
|
||||
ripple,
|
||||
{
|
||||
scale: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
{
|
||||
scale: 1,
|
||||
opacity: 0,
|
||||
duration: 0.8,
|
||||
ease: "power2.out",
|
||||
onComplete: () => ripple.remove(),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
element.addEventListener("mouseenter", handleMouseEnter);
|
||||
element.addEventListener("mouseleave", handleMouseLeave);
|
||||
element.addEventListener("mousemove", handleMouseMove);
|
||||
element.addEventListener("click", handleClick);
|
||||
|
||||
return () => {
|
||||
isHoveredRef.current = false;
|
||||
element.removeEventListener("mouseenter", handleMouseEnter);
|
||||
element.removeEventListener("mouseleave", handleMouseLeave);
|
||||
element.removeEventListener("mousemove", handleMouseMove);
|
||||
element.removeEventListener("click", handleClick);
|
||||
clearAllParticles();
|
||||
};
|
||||
}, [
|
||||
animateParticles,
|
||||
clearAllParticles,
|
||||
disableAnimations,
|
||||
enableTilt,
|
||||
enableMagnetism,
|
||||
clickEffect,
|
||||
glowColor,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`${className} particle-container`}
|
||||
style={{ ...style, position: "relative", overflow: "hidden" }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const GlobalSpotlight: React.FC<{
|
||||
gridRef: React.RefObject<HTMLDivElement | null>;
|
||||
disableAnimations?: boolean;
|
||||
enabled?: boolean;
|
||||
spotlightRadius?: number;
|
||||
glowColor?: string;
|
||||
}> = ({
|
||||
gridRef,
|
||||
disableAnimations = false,
|
||||
enabled = true,
|
||||
spotlightRadius = DEFAULT_SPOTLIGHT_RADIUS,
|
||||
glowColor = DEFAULT_GLOW_COLOR,
|
||||
}) => {
|
||||
const spotlightRef = useRef<HTMLDivElement | null>(null);
|
||||
const isInsideSection = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (disableAnimations || !gridRef?.current || !enabled) return;
|
||||
|
||||
const spotlight = document.createElement("div");
|
||||
spotlight.className = "global-spotlight";
|
||||
spotlight.style.cssText = `
|
||||
position: fixed;
|
||||
width: 800px;
|
||||
height: 800px;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle,
|
||||
rgba(${glowColor}, 0.15) 0%,
|
||||
rgba(${glowColor}, 0.08) 15%,
|
||||
rgba(${glowColor}, 0.04) 25%,
|
||||
rgba(${glowColor}, 0.02) 40%,
|
||||
rgba(${glowColor}, 0.01) 65%,
|
||||
transparent 70%
|
||||
);
|
||||
z-index: 200;
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
mix-blend-mode: screen;
|
||||
`;
|
||||
document.body.appendChild(spotlight);
|
||||
spotlightRef.current = spotlight;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!spotlightRef.current || !gridRef.current) return;
|
||||
|
||||
const section = gridRef.current.closest(".bento-section");
|
||||
const rect = section?.getBoundingClientRect();
|
||||
const mouseInside =
|
||||
rect &&
|
||||
e.clientX >= rect.left &&
|
||||
e.clientX <= rect.right &&
|
||||
e.clientY >= rect.top &&
|
||||
e.clientY <= rect.bottom;
|
||||
|
||||
isInsideSection.current = mouseInside ?? false;
|
||||
const cards = gridRef.current.querySelectorAll(".magic-bento-card");
|
||||
|
||||
if (!mouseInside) {
|
||||
gsap.to(spotlightRef.current, {
|
||||
opacity: 0,
|
||||
duration: 0.3,
|
||||
ease: "power2.out",
|
||||
});
|
||||
cards.forEach((card) => {
|
||||
(card as HTMLElement).style.setProperty("--glow-intensity", "0");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { proximity, fadeDistance } =
|
||||
calculateSpotlightValues(spotlightRadius);
|
||||
let minDistance = Infinity;
|
||||
|
||||
cards.forEach((card) => {
|
||||
const cardElement = card as HTMLElement;
|
||||
const cardRect = cardElement.getBoundingClientRect();
|
||||
const centerX = cardRect.left + cardRect.width / 2;
|
||||
const centerY = cardRect.top + cardRect.height / 2;
|
||||
const distance =
|
||||
Math.hypot(e.clientX - centerX, e.clientY - centerY) -
|
||||
Math.max(cardRect.width, cardRect.height) / 2;
|
||||
const effectiveDistance = Math.max(0, distance);
|
||||
|
||||
minDistance = Math.min(minDistance, effectiveDistance);
|
||||
|
||||
let glowIntensity = 0;
|
||||
if (effectiveDistance <= proximity) {
|
||||
glowIntensity = 1;
|
||||
} else if (effectiveDistance <= fadeDistance) {
|
||||
glowIntensity =
|
||||
(fadeDistance - effectiveDistance) / (fadeDistance - proximity);
|
||||
}
|
||||
|
||||
updateCardGlowProperties(
|
||||
cardElement,
|
||||
e.clientX,
|
||||
e.clientY,
|
||||
glowIntensity,
|
||||
spotlightRadius,
|
||||
);
|
||||
});
|
||||
|
||||
gsap.to(spotlightRef.current, {
|
||||
left: e.clientX,
|
||||
top: e.clientY,
|
||||
duration: 0.1,
|
||||
ease: "power2.out",
|
||||
});
|
||||
|
||||
const targetOpacity =
|
||||
minDistance <= proximity
|
||||
? 0.8
|
||||
: minDistance <= fadeDistance
|
||||
? ((fadeDistance - minDistance) / (fadeDistance - proximity)) * 0.8
|
||||
: 0;
|
||||
|
||||
gsap.to(spotlightRef.current, {
|
||||
opacity: targetOpacity,
|
||||
duration: targetOpacity > 0 ? 0.2 : 0.5,
|
||||
ease: "power2.out",
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
isInsideSection.current = false;
|
||||
gridRef.current?.querySelectorAll(".magic-bento-card").forEach((card) => {
|
||||
(card as HTMLElement).style.setProperty("--glow-intensity", "0");
|
||||
});
|
||||
if (spotlightRef.current) {
|
||||
gsap.to(spotlightRef.current, {
|
||||
opacity: 0,
|
||||
duration: 0.3,
|
||||
ease: "power2.out",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseleave", handleMouseLeave);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseleave", handleMouseLeave);
|
||||
spotlightRef.current?.parentNode?.removeChild(spotlightRef.current);
|
||||
};
|
||||
}, [gridRef, disableAnimations, enabled, spotlightRadius, glowColor]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const BentoCardGrid: React.FC<{
|
||||
children: React.ReactNode;
|
||||
gridRef?: React.RefObject<HTMLDivElement | null>;
|
||||
}> = ({ children, gridRef }) => (
|
||||
<div className="card-grid bento-section" ref={gridRef}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const useMobileDetection = () => {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () =>
|
||||
setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT);
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener("resize", checkMobile);
|
||||
|
||||
return () => window.removeEventListener("resize", checkMobile);
|
||||
}, []);
|
||||
|
||||
return isMobile;
|
||||
};
|
||||
|
||||
const MagicBento: React.FC<BentoProps> = ({
|
||||
textAutoHide = true,
|
||||
enableStars = true,
|
||||
enableSpotlight = true,
|
||||
enableBorderGlow = true,
|
||||
disableAnimations = false,
|
||||
spotlightRadius = DEFAULT_SPOTLIGHT_RADIUS,
|
||||
particleCount = DEFAULT_PARTICLE_COUNT,
|
||||
enableTilt = false,
|
||||
glowColor = DEFAULT_GLOW_COLOR,
|
||||
clickEffect = true,
|
||||
enableMagnetism = true,
|
||||
}) => {
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const isMobile = useMobileDetection();
|
||||
const shouldDisableAnimations = disableAnimations || isMobile;
|
||||
|
||||
return (
|
||||
<>
|
||||
{enableSpotlight && (
|
||||
<GlobalSpotlight
|
||||
gridRef={gridRef}
|
||||
disableAnimations={shouldDisableAnimations}
|
||||
enabled={enableSpotlight}
|
||||
spotlightRadius={spotlightRadius}
|
||||
glowColor={glowColor}
|
||||
/>
|
||||
)}
|
||||
|
||||
<BentoCardGrid gridRef={gridRef}>
|
||||
{cardData.map((card, index) => {
|
||||
const baseClassName = `magic-bento-card ${textAutoHide ? "magic-bento-card--text-autohide" : ""} ${enableBorderGlow ? "magic-bento-card--border-glow" : ""}`;
|
||||
const cardProps = {
|
||||
className: baseClassName,
|
||||
style: {
|
||||
backgroundColor: card.color,
|
||||
"--glow-color": glowColor,
|
||||
} as React.CSSProperties,
|
||||
};
|
||||
|
||||
if (enableStars) {
|
||||
return (
|
||||
<ParticleCard
|
||||
key={index}
|
||||
{...cardProps}
|
||||
disableAnimations={shouldDisableAnimations}
|
||||
particleCount={particleCount}
|
||||
glowColor={glowColor}
|
||||
enableTilt={enableTilt}
|
||||
clickEffect={clickEffect}
|
||||
enableMagnetism={enableMagnetism}
|
||||
>
|
||||
<div className="magic-bento-card__header">
|
||||
<div className="magic-bento-card__label">{card.label}</div>
|
||||
</div>
|
||||
<div className="magic-bento-card__content">
|
||||
<h2 className="magic-bento-card__title">{card.title}</h2>
|
||||
<div className="magic-bento-card__description">
|
||||
{card.description}
|
||||
</div>
|
||||
</div>
|
||||
</ParticleCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
{...cardProps}
|
||||
ref={(el) => {
|
||||
if (!el) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (shouldDisableAnimations) return;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
const centerX = rect.width / 2;
|
||||
const centerY = rect.height / 2;
|
||||
|
||||
if (enableTilt) {
|
||||
const rotateX = ((y - centerY) / centerY) * -10;
|
||||
const rotateY = ((x - centerX) / centerX) * 10;
|
||||
gsap.to(el, {
|
||||
rotateX,
|
||||
rotateY,
|
||||
duration: 0.1,
|
||||
ease: "power2.out",
|
||||
transformPerspective: 1000,
|
||||
});
|
||||
}
|
||||
|
||||
if (enableMagnetism) {
|
||||
const magnetX = (x - centerX) * 0.05;
|
||||
const magnetY = (y - centerY) * 0.05;
|
||||
gsap.to(el, {
|
||||
x: magnetX,
|
||||
y: magnetY,
|
||||
duration: 0.3,
|
||||
ease: "power2.out",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (shouldDisableAnimations) return;
|
||||
|
||||
if (enableTilt) {
|
||||
gsap.to(el, {
|
||||
rotateX: 0,
|
||||
rotateY: 0,
|
||||
duration: 0.3,
|
||||
ease: "power2.out",
|
||||
});
|
||||
}
|
||||
|
||||
if (enableMagnetism) {
|
||||
gsap.to(el, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
duration: 0.3,
|
||||
ease: "power2.out",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (!clickEffect || shouldDisableAnimations) return;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
// Calculate the maximum distance from click point to any corner
|
||||
const maxDistance = Math.max(
|
||||
Math.hypot(x, y),
|
||||
Math.hypot(x - rect.width, y),
|
||||
Math.hypot(x, y - rect.height),
|
||||
Math.hypot(x - rect.width, y - rect.height),
|
||||
);
|
||||
|
||||
const ripple = document.createElement("div");
|
||||
ripple.style.cssText = `
|
||||
position: absolute;
|
||||
width: ${maxDistance * 2}px;
|
||||
height: ${maxDistance * 2}px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(${glowColor}, 0.4) 0%, rgba(${glowColor}, 0.2) 30%, transparent 70%);
|
||||
left: ${x - maxDistance}px;
|
||||
top: ${y - maxDistance}px;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
`;
|
||||
|
||||
el.appendChild(ripple);
|
||||
|
||||
gsap.fromTo(
|
||||
ripple,
|
||||
{
|
||||
scale: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
{
|
||||
scale: 1,
|
||||
opacity: 0,
|
||||
duration: 0.8,
|
||||
ease: "power2.out",
|
||||
onComplete: () => ripple.remove(),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
el.addEventListener("mousemove", handleMouseMove);
|
||||
el.addEventListener("mouseleave", handleMouseLeave);
|
||||
el.addEventListener("click", handleClick);
|
||||
}}
|
||||
>
|
||||
<div className="magic-bento-card__header">
|
||||
<div className="magic-bento-card__label">{card.label}</div>
|
||||
</div>
|
||||
<div className="magic-bento-card__content">
|
||||
<h2 className="magic-bento-card__title">{card.title}</h2>
|
||||
<p className="magic-bento-card__description">
|
||||
{card.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</BentoCardGrid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MagicBento;
|
||||
@@ -1,16 +1,16 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { ComponentPropsWithoutRef, useEffect, useRef } from "react"
|
||||
import { useInView, useMotionValue, useSpring } from "motion/react"
|
||||
import { type ComponentPropsWithoutRef, useEffect, useRef } from "react";
|
||||
import { useInView, useMotionValue, useSpring } from "motion/react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NumberTickerProps extends ComponentPropsWithoutRef<"span"> {
|
||||
value: number
|
||||
startValue?: number
|
||||
direction?: "up" | "down"
|
||||
delay?: number
|
||||
decimalPlaces?: number
|
||||
value: number;
|
||||
startValue?: number;
|
||||
direction?: "up" | "down";
|
||||
delay?: number;
|
||||
decimalPlaces?: number;
|
||||
}
|
||||
|
||||
export function NumberTicker({
|
||||
@@ -22,22 +22,22 @@ export function NumberTicker({
|
||||
decimalPlaces = 0,
|
||||
...props
|
||||
}: NumberTickerProps) {
|
||||
const ref = useRef<HTMLSpanElement>(null)
|
||||
const motionValue = useMotionValue(direction === "down" ? value : startValue)
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const motionValue = useMotionValue(direction === "down" ? value : startValue);
|
||||
const springValue = useSpring(motionValue, {
|
||||
damping: 60,
|
||||
stiffness: 100,
|
||||
})
|
||||
const isInView = useInView(ref, { once: true, margin: "0px" })
|
||||
});
|
||||
const isInView = useInView(ref, { once: true, margin: "0px" });
|
||||
|
||||
useEffect(() => {
|
||||
if (isInView) {
|
||||
const timer = setTimeout(() => {
|
||||
motionValue.set(direction === "down" ? startValue : value)
|
||||
}, delay * 1000)
|
||||
return () => clearTimeout(timer)
|
||||
motionValue.set(direction === "down" ? startValue : value);
|
||||
}, delay * 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [motionValue, isInView, delay, value, direction, startValue])
|
||||
}, [motionValue, isInView, delay, value, direction, startValue]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
@@ -46,22 +46,22 @@ export function NumberTicker({
|
||||
ref.current.textContent = Intl.NumberFormat("en-US", {
|
||||
minimumFractionDigits: decimalPlaces,
|
||||
maximumFractionDigits: decimalPlaces,
|
||||
}).format(Number(latest.toFixed(decimalPlaces)))
|
||||
}).format(Number(latest.toFixed(decimalPlaces)));
|
||||
}
|
||||
}),
|
||||
[springValue, decimalPlaces]
|
||||
)
|
||||
[springValue, decimalPlaces],
|
||||
);
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-block tracking-wider text-black tabular-nums dark:text-white",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{startValue}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
29
frontend/src/components/ui/spotlight-card.css
Normal file
29
frontend/src/components/ui/spotlight-card.css
Normal file
@@ -0,0 +1,29 @@
|
||||
.card-spotlight {
|
||||
position: relative;
|
||||
border-radius: 1.5rem;
|
||||
border: 1px solid #222;
|
||||
background-color: #111;
|
||||
padding: 2rem;
|
||||
overflow: hidden;
|
||||
--mouse-x: 50%;
|
||||
--mouse-y: 50%;
|
||||
--spotlight-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.card-spotlight::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: radial-gradient(circle at var(--mouse-x) var(--mouse-y), var(--spotlight-color), transparent 80%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card-spotlight:hover::before,
|
||||
.card-spotlight:focus-within::before {
|
||||
opacity: 0.6;
|
||||
}
|
||||
46
frontend/src/components/ui/spotlight-card.tsx
Normal file
46
frontend/src/components/ui/spotlight-card.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef } from "react";
|
||||
import "./spotlight-card.css";
|
||||
|
||||
interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface SpotlightCardProps extends React.PropsWithChildren {
|
||||
className?: string;
|
||||
spotlightColor?: `rgba(${number}, ${number}, ${number}, ${number})`;
|
||||
}
|
||||
|
||||
const SpotlightCard: React.FC<SpotlightCardProps> = ({
|
||||
children,
|
||||
className = "",
|
||||
spotlightColor = "rgba(255, 255, 255, 0.25)",
|
||||
}) => {
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleMouseMove: React.MouseEventHandler<HTMLDivElement> = (e) => {
|
||||
if (!divRef.current) return;
|
||||
|
||||
const rect = divRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
divRef.current.style.setProperty("--mouse-x", `${x}px`);
|
||||
divRef.current.style.setProperty("--mouse-y", `${y}px`);
|
||||
divRef.current.style.setProperty("--spotlight-color", spotlightColor);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={divRef}
|
||||
onMouseMove={handleMouseMove}
|
||||
className={`card-spotlight ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpotlightCard;
|
||||
257
frontend/src/components/ui/terminal.tsx
Normal file
257
frontend/src/components/ui/terminal.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Children,
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { motion, type MotionProps, useInView } from "motion/react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SequenceContextValue {
|
||||
completeItem: (index: number) => void;
|
||||
activeIndex: number;
|
||||
sequenceStarted: boolean;
|
||||
}
|
||||
|
||||
const SequenceContext = createContext<SequenceContextValue | null>(null);
|
||||
|
||||
const useSequence = () => useContext(SequenceContext);
|
||||
|
||||
const ItemIndexContext = createContext<number | null>(null);
|
||||
const useItemIndex = () => useContext(ItemIndexContext);
|
||||
|
||||
interface AnimatedSpanProps extends MotionProps {
|
||||
children: React.ReactNode;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
startOnView?: boolean;
|
||||
}
|
||||
|
||||
export const AnimatedSpan = ({
|
||||
children,
|
||||
delay = 0,
|
||||
className,
|
||||
startOnView = false,
|
||||
...props
|
||||
}: AnimatedSpanProps) => {
|
||||
const elementRef = useRef<HTMLDivElement | null>(null);
|
||||
const isInView = useInView(elementRef as React.RefObject<Element>, {
|
||||
amount: 0.3,
|
||||
once: true,
|
||||
});
|
||||
|
||||
const sequence = useSequence();
|
||||
const itemIndex = useItemIndex();
|
||||
const [hasStarted, setHasStarted] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!sequence || itemIndex === null) return;
|
||||
if (!sequence.sequenceStarted) return;
|
||||
if (hasStarted) return;
|
||||
if (sequence.activeIndex === itemIndex) {
|
||||
setHasStarted(true);
|
||||
}
|
||||
}, [sequence?.activeIndex, sequence?.sequenceStarted, hasStarted, itemIndex]);
|
||||
|
||||
const shouldAnimate = sequence ? hasStarted : startOnView ? isInView : true;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={elementRef}
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={shouldAnimate ? { opacity: 1, y: 0 } : { opacity: 0, y: -5 }}
|
||||
transition={{ duration: 0.3, delay: sequence ? 0 : delay / 1000 }}
|
||||
className={cn("grid text-sm font-normal tracking-tight", className)}
|
||||
onAnimationComplete={() => {
|
||||
if (!sequence) return;
|
||||
if (itemIndex === null) return;
|
||||
sequence.completeItem(itemIndex);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TypingAnimationProps extends MotionProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
duration?: number;
|
||||
delay?: number;
|
||||
as?: React.ElementType;
|
||||
startOnView?: boolean;
|
||||
}
|
||||
|
||||
export const TypingAnimation = ({
|
||||
children,
|
||||
className,
|
||||
duration = 60,
|
||||
delay = 0,
|
||||
as: Component = "span",
|
||||
startOnView = true,
|
||||
...props
|
||||
}: TypingAnimationProps) => {
|
||||
if (typeof children !== "string") {
|
||||
throw new Error("TypingAnimation: children must be a string. Received:");
|
||||
}
|
||||
|
||||
const MotionComponent = useMemo(
|
||||
() =>
|
||||
motion.create(Component, {
|
||||
forwardMotionProps: true,
|
||||
}),
|
||||
[Component],
|
||||
);
|
||||
|
||||
const [displayedText, setDisplayedText] = useState<string>("");
|
||||
const [started, setStarted] = useState(false);
|
||||
const elementRef = useRef<HTMLElement | null>(null);
|
||||
const isInView = useInView(elementRef as React.RefObject<Element>, {
|
||||
amount: 0.3,
|
||||
once: true,
|
||||
});
|
||||
|
||||
const sequence = useSequence();
|
||||
const itemIndex = useItemIndex();
|
||||
|
||||
useEffect(() => {
|
||||
if (sequence && itemIndex !== null) {
|
||||
if (!sequence.sequenceStarted) return;
|
||||
if (started) return;
|
||||
if (sequence.activeIndex === itemIndex) {
|
||||
setStarted(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!startOnView) {
|
||||
const startTimeout = setTimeout(() => setStarted(true), delay);
|
||||
return () => clearTimeout(startTimeout);
|
||||
}
|
||||
|
||||
if (!isInView) return;
|
||||
|
||||
const startTimeout = setTimeout(() => setStarted(true), delay);
|
||||
return () => clearTimeout(startTimeout);
|
||||
}, [
|
||||
delay,
|
||||
startOnView,
|
||||
isInView,
|
||||
started,
|
||||
sequence?.activeIndex,
|
||||
sequence?.sequenceStarted,
|
||||
itemIndex,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!started) return;
|
||||
|
||||
let i = 0;
|
||||
const typingEffect = setInterval(() => {
|
||||
if (i < children.length) {
|
||||
setDisplayedText(children.substring(0, i + 1));
|
||||
i++;
|
||||
} else {
|
||||
clearInterval(typingEffect);
|
||||
if (sequence && itemIndex !== null) {
|
||||
sequence.completeItem(itemIndex);
|
||||
}
|
||||
}
|
||||
}, duration);
|
||||
|
||||
return () => {
|
||||
clearInterval(typingEffect);
|
||||
};
|
||||
}, [children, duration, started]);
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
ref={elementRef}
|
||||
className={cn("text-sm font-normal tracking-tight", className)}
|
||||
{...props}
|
||||
>
|
||||
{displayedText}
|
||||
</MotionComponent>
|
||||
);
|
||||
};
|
||||
|
||||
interface TerminalProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
sequence?: boolean;
|
||||
startOnView?: boolean;
|
||||
}
|
||||
|
||||
export const Terminal = ({
|
||||
children,
|
||||
className,
|
||||
sequence = true,
|
||||
startOnView = true,
|
||||
}: TerminalProps) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const isInView = useInView(containerRef as React.RefObject<Element>, {
|
||||
amount: 0.3,
|
||||
once: true,
|
||||
});
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const sequenceHasStarted = sequence ? !startOnView || isInView : false;
|
||||
|
||||
const contextValue = useMemo<SequenceContextValue | null>(() => {
|
||||
if (!sequence) return null;
|
||||
return {
|
||||
completeItem: (index: number) => {
|
||||
setActiveIndex((current) =>
|
||||
index === current ? current + 1 : current,
|
||||
);
|
||||
},
|
||||
activeIndex,
|
||||
sequenceStarted: sequenceHasStarted,
|
||||
};
|
||||
}, [sequence, activeIndex, sequenceHasStarted]);
|
||||
|
||||
const wrappedChildren = useMemo(() => {
|
||||
if (!sequence) return children;
|
||||
const array = Children.toArray(children);
|
||||
return array.map((child, index) => (
|
||||
<ItemIndexContext.Provider key={index} value={index}>
|
||||
{child as React.ReactNode}
|
||||
</ItemIndexContext.Provider>
|
||||
));
|
||||
}, [children, sequence]);
|
||||
|
||||
const content = (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"border-border bg-background z-0 h-full max-h-[400px] w-full max-w-lg rounded-xl border",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="border-border flex flex-col gap-y-2 border-b p-4">
|
||||
<div className="flex flex-row gap-x-2">
|
||||
<div className="h-2 w-2 rounded-full bg-red-500"></div>
|
||||
<div className="h-2 w-2 rounded-full bg-yellow-500"></div>
|
||||
<div className="h-2 w-2 rounded-full bg-green-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="p-4">
|
||||
<code className="grid gap-y-1 overflow-auto">{wrappedChildren}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!sequence) return content;
|
||||
|
||||
return (
|
||||
<SequenceContext.Provider value={contextValue}>
|
||||
{content}
|
||||
</SequenceContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AnimatePresence, motion, MotionProps } from "motion/react";
|
||||
import { AnimatePresence, motion, type MotionProps } from "motion/react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AuroraText } from "./aurora-text";
|
||||
@@ -15,12 +15,12 @@ interface WordRotateProps {
|
||||
|
||||
export function WordRotate({
|
||||
words,
|
||||
duration = 2500,
|
||||
duration = 2200,
|
||||
motionProps = {
|
||||
initial: { opacity: 0, y: -50, filter: "blur(16px)" },
|
||||
animate: { opacity: 1, y: 0, filter: "blur(0px)" },
|
||||
exit: { opacity: 0, y: 50, filter: "blur(16px)" },
|
||||
transition: { duration: 0.25, ease: "easeOut" },
|
||||
transition: { duration: 0.3, ease: "easeOut" },
|
||||
},
|
||||
className,
|
||||
}: WordRotateProps) {
|
||||
@@ -37,7 +37,7 @@ export function WordRotate({
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden py-2">
|
||||
<AnimatePresence mode="wait">
|
||||
<AnimatePresence mode="popLayout">
|
||||
<motion.h1
|
||||
key={words[index]}
|
||||
className={cn(className)}
|
||||
|
||||
Reference in New Issue
Block a user