+
-
+
+ {/* Fingerprint identifier - for FOFA/Shodan and other search engines to identify */}
+
+
+ {/* Main content area */}
+
+
+
+
{/* Version number - fixed at the bottom of the page */}
-
+
- {process.env.NEXT_PUBLIC_VERSION || 'dev'}
+ {process.env.NEXT_PUBLIC_VERSION || "dev"}
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index cfa4fcf3..a7a4ecca 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -272,6 +272,20 @@
z-index: 1;
}
+/* 终端光标闪烁动画 */
+@keyframes blink {
+ 0%, 50% {
+ opacity: 1;
+ }
+ 51%, 100% {
+ opacity: 0;
+ }
+}
+
+.animate-blink {
+ animation: blink 1s step-end infinite;
+}
+
/* 通知铃铛摇晃动画 */
@keyframes wiggle {
0%, 100% {
diff --git a/frontend/components.json b/frontend/components.json
index 6e661d7a..8ab82eae 100644
--- a/frontend/components.json
+++ b/frontend/components.json
@@ -19,6 +19,8 @@
"hooks": "@/hooks"
},
"registries": {
- "@animate-ui": "https://animate-ui.com/r"
+ "@animate-ui": "https://animate-ui.com/r/{name}.json",
+ "@magicui": "https://magicui.design/r/{name}.json",
+ "@react-bits": "https://reactbits.dev/r/{name}.json"
}
}
diff --git a/frontend/components/FaultyTerminal.css b/frontend/components/FaultyTerminal.css
new file mode 100644
index 00000000..dacc2fdd
--- /dev/null
+++ b/frontend/components/FaultyTerminal.css
@@ -0,0 +1,6 @@
+.faulty-terminal-container {
+ width: 100%;
+ height: 100%;
+ position: relative;
+ overflow: hidden;
+}
diff --git a/frontend/components/FaultyTerminal.tsx b/frontend/components/FaultyTerminal.tsx
new file mode 100644
index 00000000..6a6103c5
--- /dev/null
+++ b/frontend/components/FaultyTerminal.tsx
@@ -0,0 +1,400 @@
+import { Renderer, Program, Mesh, Color, Triangle } from 'ogl';
+import { useEffect, useRef, useMemo, useCallback } from 'react';
+import './FaultyTerminal.css';
+
+const vertexShader = `
+attribute vec2 position;
+attribute vec2 uv;
+varying vec2 vUv;
+void main() {
+ vUv = uv;
+ gl_Position = vec4(position, 0.0, 1.0);
+}
+`;
+
+const fragmentShader = `
+precision mediump float;
+
+varying vec2 vUv;
+
+uniform float iTime;
+uniform vec3 iResolution;
+uniform float uScale;
+
+uniform vec2 uGridMul;
+uniform float uDigitSize;
+uniform float uScanlineIntensity;
+uniform float uGlitchAmount;
+uniform float uFlickerAmount;
+uniform float uNoiseAmp;
+uniform float uChromaticAberration;
+uniform float uDither;
+uniform float uCurvature;
+uniform vec3 uTint;
+uniform vec2 uMouse;
+uniform float uMouseStrength;
+uniform float uUseMouse;
+uniform float uPageLoadProgress;
+uniform float uUsePageLoadAnimation;
+uniform float uBrightness;
+
+float time;
+
+float hash21(vec2 p){
+ p = fract(p * 234.56);
+ p += dot(p, p + 34.56);
+ return fract(p.x * p.y);
+}
+
+float noise(vec2 p)
+{
+ return sin(p.x * 10.0) * sin(p.y * (3.0 + sin(time * 0.090909))) + 0.2;
+}
+
+mat2 rotate(float angle)
+{
+ float c = cos(angle);
+ float s = sin(angle);
+ return mat2(c, -s, s, c);
+}
+
+float fbm(vec2 p)
+{
+ p *= 1.1;
+ float f = 0.0;
+ float amp = 0.5 * uNoiseAmp;
+
+ mat2 modify0 = rotate(time * 0.02);
+ f += amp * noise(p);
+ p = modify0 * p * 2.0;
+ amp *= 0.454545;
+
+ mat2 modify1 = rotate(time * 0.02);
+ f += amp * noise(p);
+ p = modify1 * p * 2.0;
+ amp *= 0.454545;
+
+ mat2 modify2 = rotate(time * 0.08);
+ f += amp * noise(p);
+
+ return f;
+}
+
+float pattern(vec2 p, out vec2 q, out vec2 r) {
+ vec2 offset1 = vec2(1.0);
+ vec2 offset0 = vec2(0.0);
+ mat2 rot01 = rotate(0.1 * time);
+ mat2 rot1 = rotate(0.1);
+
+ q = vec2(fbm(p + offset1), fbm(rot01 * p + offset1));
+ r = vec2(fbm(rot1 * q + offset0), fbm(q + offset0));
+ return fbm(p + r);
+}
+
+float digit(vec2 p){
+ vec2 grid = uGridMul * 15.0;
+ vec2 s = floor(p * grid) / grid;
+ p = p * grid;
+ vec2 q, r;
+ float intensity = pattern(s * 0.1, q, r) * 1.3 - 0.03;
+
+ if(uUseMouse > 0.5){
+ vec2 mouseWorld = uMouse * uScale;
+ float distToMouse = distance(s, mouseWorld);
+ float mouseInfluence = exp(-distToMouse * 8.0) * uMouseStrength * 10.0;
+ intensity += mouseInfluence;
+
+ float ripple = sin(distToMouse * 20.0 - iTime * 5.0) * 0.1 * mouseInfluence;
+ intensity += ripple;
+ }
+
+ if(uUsePageLoadAnimation > 0.5){
+ float cellRandom = fract(sin(dot(s, vec2(12.9898, 78.233))) * 43758.5453);
+ float cellDelay = cellRandom * 0.8;
+ float cellProgress = clamp((uPageLoadProgress - cellDelay) / 0.2, 0.0, 1.0);
+
+ float fadeAlpha = smoothstep(0.0, 1.0, cellProgress);
+ intensity *= fadeAlpha;
+ }
+
+ p = fract(p);
+ p *= uDigitSize;
+
+ float px5 = p.x * 5.0;
+ float py5 = (1.0 - p.y) * 5.0;
+ float x = fract(px5);
+ float y = fract(py5);
+
+ float i = floor(py5) - 2.0;
+ float j = floor(px5) - 2.0;
+ float n = i * i + j * j;
+ float f = n * 0.0625;
+
+ float isOn = step(0.1, intensity - f);
+ float brightness = isOn * (0.2 + y * 0.8) * (0.75 + x * 0.25);
+
+ return step(0.0, p.x) * step(p.x, 1.0) * step(0.0, p.y) * step(p.y, 1.0) * brightness;
+}
+
+float onOff(float a, float b, float c)
+{
+ return step(c, sin(iTime + a * cos(iTime * b))) * uFlickerAmount;
+}
+
+float displace(vec2 look)
+{
+ float y = look.y - mod(iTime * 0.25, 1.0);
+ float window = 1.0 / (1.0 + 50.0 * y * y);
+ return sin(look.y * 20.0 + iTime) * 0.0125 * onOff(4.0, 2.0, 0.8) * (1.0 + cos(iTime * 60.0)) * window;
+}
+
+vec3 getColor(vec2 p){
+
+ float bar = step(mod(p.y + time * 20.0, 1.0), 0.2) * 0.4 + 1.0;
+ bar *= uScanlineIntensity;
+
+ float displacement = displace(p);
+ p.x += displacement;
+
+ if (uGlitchAmount != 1.0) {
+ float extra = displacement * (uGlitchAmount - 1.0);
+ p.x += extra;
+ }
+
+ float middle = digit(p);
+
+ const float off = 0.002;
+ float sum = digit(p + vec2(-off, -off)) + digit(p + vec2(0.0, -off)) + digit(p + vec2(off, -off)) +
+ digit(p + vec2(-off, 0.0)) + digit(p + vec2(0.0, 0.0)) + digit(p + vec2(off, 0.0)) +
+ digit(p + vec2(-off, off)) + digit(p + vec2(0.0, off)) + digit(p + vec2(off, off));
+
+ vec3 baseColor = vec3(0.9) * middle + sum * 0.1 * vec3(1.0) * bar;
+ return baseColor;
+}
+
+vec2 barrel(vec2 uv){
+ vec2 c = uv * 2.0 - 1.0;
+ float r2 = dot(c, c);
+ c *= 1.0 + uCurvature * r2;
+ return c * 0.5 + 0.5;
+}
+
+void main() {
+ time = iTime * 0.333333;
+ vec2 uv = vUv;
+
+ if(uCurvature != 0.0){
+ uv = barrel(uv);
+ }
+
+ vec2 p = uv * uScale;
+ vec3 col = getColor(p);
+
+ if(uChromaticAberration != 0.0){
+ vec2 ca = vec2(uChromaticAberration) / iResolution.xy;
+ col.r = getColor(p + ca).r;
+ col.b = getColor(p - ca).b;
+ }
+
+ col *= uTint;
+ col *= uBrightness;
+
+ if(uDither > 0.0){
+ float rnd = hash21(gl_FragCoord.xy);
+ col += (rnd - 0.5) * (uDither * 0.003922);
+ }
+
+ gl_FragColor = vec4(col, 1.0);
+}
+`;
+
+function hexToRgb(hex) {
+ let h = hex.replace('#', '').trim();
+ if (h.length === 3)
+ h = h
+ .split('')
+ .map(c => c + c)
+ .join('');
+ const num = parseInt(h, 16);
+ return [((num >> 16) & 255) / 255, ((num >> 8) & 255) / 255, (num & 255) / 255];
+}
+
+export default function FaultyTerminal({
+ scale = 1,
+ gridMul = [2, 1],
+ digitSize = 1.5,
+ timeScale = 0.3,
+ pause = false,
+ scanlineIntensity = 0.3,
+ glitchAmount = 1,
+ flickerAmount = 1,
+ noiseAmp = 0,
+ chromaticAberration = 0,
+ dither = 0,
+ curvature = 0.2,
+ tint = '#ffffff',
+ mouseReact = true,
+ mouseStrength = 0.2,
+ dpr = Math.min(window.devicePixelRatio || 1, 2),
+ pageLoadAnimation = true,
+ brightness = 1,
+ className,
+ style,
+ ...rest
+}) {
+ const containerRef = useRef(null);
+ const programRef = useRef(null);
+ const rendererRef = useRef(null);
+ const mouseRef = useRef({ x: 0.5, y: 0.5 });
+ const smoothMouseRef = useRef({ x: 0.5, y: 0.5 });
+ const frozenTimeRef = useRef(0);
+ const rafRef = useRef(0);
+ const loadAnimationStartRef = useRef(0);
+ const timeOffsetRef = useRef(Math.random() * 100);
+
+ const tintVec = useMemo(() => hexToRgb(tint), [tint]);
+
+ const ditherValue = useMemo(() => (typeof dither === 'boolean' ? (dither ? 1 : 0) : dither), [dither]);
+
+ const handleMouseMove = useCallback((e: MouseEvent) => {
+ const ctn = containerRef.current;
+ if (!ctn) return;
+ const rect = ctn.getBoundingClientRect();
+ const x = (e.clientX - rect.left) / rect.width;
+ const y = 1 - (e.clientY - rect.top) / rect.height;
+ mouseRef.current = { x, y };
+ }, []);
+
+ useEffect(() => {
+ const ctn = containerRef.current;
+ if (!ctn) return;
+
+ const renderer = new Renderer({ dpr });
+ rendererRef.current = renderer;
+ const gl = renderer.gl;
+ gl.clearColor(0, 0, 0, 1);
+
+ const geometry = new Triangle(gl);
+
+ const program = new Program(gl, {
+ vertex: vertexShader,
+ fragment: fragmentShader,
+ uniforms: {
+ iTime: { value: 0 },
+ iResolution: {
+ value: new Color(gl.canvas.width, gl.canvas.height, gl.canvas.width / gl.canvas.height)
+ },
+ uScale: { value: scale },
+
+ uGridMul: { value: new Float32Array(gridMul) },
+ uDigitSize: { value: digitSize },
+ uScanlineIntensity: { value: scanlineIntensity },
+ uGlitchAmount: { value: glitchAmount },
+ uFlickerAmount: { value: flickerAmount },
+ uNoiseAmp: { value: noiseAmp },
+ uChromaticAberration: { value: chromaticAberration },
+ uDither: { value: ditherValue },
+ uCurvature: { value: curvature },
+ uTint: { value: new Color(tintVec[0], tintVec[1], tintVec[2]) },
+ uMouse: {
+ value: new Float32Array([smoothMouseRef.current.x, smoothMouseRef.current.y])
+ },
+ uMouseStrength: { value: mouseStrength },
+ uUseMouse: { value: mouseReact ? 1 : 0 },
+ uPageLoadProgress: { value: pageLoadAnimation ? 0 : 1 },
+ uUsePageLoadAnimation: { value: pageLoadAnimation ? 1 : 0 },
+ uBrightness: { value: brightness }
+ }
+ });
+ programRef.current = program;
+
+ const mesh = new Mesh(gl, { geometry, program });
+
+ function resize() {
+ if (!ctn || !renderer) return;
+ renderer.setSize(ctn.offsetWidth, ctn.offsetHeight);
+ program.uniforms.iResolution.value = new Color(
+ gl.canvas.width,
+ gl.canvas.height,
+ gl.canvas.width / gl.canvas.height
+ );
+ }
+
+ const resizeObserver = new ResizeObserver(() => resize());
+ resizeObserver.observe(ctn);
+ resize();
+
+ const update = t => {
+ rafRef.current = requestAnimationFrame(update);
+
+ if (pageLoadAnimation && loadAnimationStartRef.current === 0) {
+ loadAnimationStartRef.current = t;
+ }
+
+ if (!pause) {
+ const elapsed = (t * 0.001 + timeOffsetRef.current) * timeScale;
+ program.uniforms.iTime.value = elapsed;
+ frozenTimeRef.current = elapsed;
+ } else {
+ program.uniforms.iTime.value = frozenTimeRef.current;
+ }
+
+ if (pageLoadAnimation && loadAnimationStartRef.current > 0) {
+ const animationDuration = 2000;
+ const animationElapsed = t - loadAnimationStartRef.current;
+ const progress = Math.min(animationElapsed / animationDuration, 1);
+ program.uniforms.uPageLoadProgress.value = progress;
+ }
+
+ if (mouseReact) {
+ const dampingFactor = 0.08;
+ const smoothMouse = smoothMouseRef.current;
+ const mouse = mouseRef.current;
+ smoothMouse.x += (mouse.x - smoothMouse.x) * dampingFactor;
+ smoothMouse.y += (mouse.y - smoothMouse.y) * dampingFactor;
+
+ const mouseUniform = program.uniforms.uMouse.value;
+ mouseUniform[0] = smoothMouse.x;
+ mouseUniform[1] = smoothMouse.y;
+ }
+
+ renderer.render({ scene: mesh });
+ };
+ rafRef.current = requestAnimationFrame(update);
+ ctn.appendChild(gl.canvas);
+
+ if (mouseReact) window.addEventListener('mousemove', handleMouseMove);
+
+ return () => {
+ cancelAnimationFrame(rafRef.current);
+ resizeObserver.disconnect();
+ if (mouseReact) window.removeEventListener('mousemove', handleMouseMove);
+ if (gl.canvas.parentElement === ctn) ctn.removeChild(gl.canvas);
+ gl.getExtension('WEBGL_lose_context')?.loseContext();
+ loadAnimationStartRef.current = 0;
+ timeOffsetRef.current = Math.random() * 100;
+ };
+ }, [
+ dpr,
+ pause,
+ timeScale,
+ scale,
+ gridMul,
+ digitSize,
+ scanlineIntensity,
+ glitchAmount,
+ flickerAmount,
+ noiseAmp,
+ chromaticAberration,
+ ditherValue,
+ curvature,
+ tintVec,
+ mouseReact,
+ mouseStrength,
+ pageLoadAnimation,
+ brightness,
+ handleMouseMove
+ ]);
+
+ return
;
+}
diff --git a/frontend/components/PixelBlast.css b/frontend/components/PixelBlast.css
new file mode 100644
index 00000000..5a38a905
--- /dev/null
+++ b/frontend/components/PixelBlast.css
@@ -0,0 +1,6 @@
+.pixel-blast-container {
+ width: 100%;
+ height: 100%;
+ position: relative;
+ overflow: hidden;
+}
diff --git a/frontend/components/PixelBlast.tsx b/frontend/components/PixelBlast.tsx
new file mode 100644
index 00000000..bca79ea2
--- /dev/null
+++ b/frontend/components/PixelBlast.tsx
@@ -0,0 +1,607 @@
+import { useEffect, useRef } from 'react';
+import * as THREE from 'three';
+import { EffectComposer, EffectPass, RenderPass, Effect } from 'postprocessing';
+import './PixelBlast.css';
+
+const createTouchTexture = () => {
+ const size = 64;
+ const canvas = document.createElement('canvas');
+ canvas.width = size;
+ canvas.height = size;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) throw new Error('2D context not available');
+ ctx.fillStyle = 'black';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ const texture = new THREE.Texture(canvas);
+ texture.minFilter = THREE.LinearFilter;
+ texture.magFilter = THREE.LinearFilter;
+ texture.generateMipmaps = false;
+ const trail = [];
+ let last = null;
+ const maxAge = 64;
+ let radius = 0.1 * size;
+ const speed = 1 / maxAge;
+ const clear = () => {
+ ctx.fillStyle = 'black';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ };
+ const drawPoint = p => {
+ const pos = { x: p.x * size, y: (1 - p.y) * size };
+ let intensity = 1;
+ const easeOutSine = t => Math.sin((t * Math.PI) / 2);
+ const easeOutQuad = t => -t * (t - 2);
+ if (p.age < maxAge * 0.3) intensity = easeOutSine(p.age / (maxAge * 0.3));
+ else intensity = easeOutQuad(1 - (p.age - maxAge * 0.3) / (maxAge * 0.7)) || 0;
+ intensity *= p.force;
+ const color = `${((p.vx + 1) / 2) * 255}, ${((p.vy + 1) / 2) * 255}, ${intensity * 255}`;
+ const offset = size * 5;
+ ctx.shadowOffsetX = offset;
+ ctx.shadowOffsetY = offset;
+ ctx.shadowBlur = radius;
+ ctx.shadowColor = `rgba(${color},${0.22 * intensity})`;
+ ctx.beginPath();
+ ctx.fillStyle = 'rgba(255,0,0,1)';
+ ctx.arc(pos.x - offset, pos.y - offset, radius, 0, Math.PI * 2);
+ ctx.fill();
+ };
+ const addTouch = norm => {
+ let force = 0;
+ let vx = 0;
+ let vy = 0;
+ if (last) {
+ const dx = norm.x - last.x;
+ const dy = norm.y - last.y;
+ if (dx === 0 && dy === 0) return;
+ const dd = dx * dx + dy * dy;
+ const d = Math.sqrt(dd);
+ vx = dx / (d || 1);
+ vy = dy / (d || 1);
+ force = Math.min(dd * 10000, 1);
+ }
+ last = { x: norm.x, y: norm.y };
+ trail.push({ x: norm.x, y: norm.y, age: 0, force, vx, vy });
+ };
+ const update = () => {
+ clear();
+ for (let i = trail.length - 1; i >= 0; i--) {
+ const point = trail[i];
+ const f = point.force * speed * (1 - point.age / maxAge);
+ point.x += point.vx * f;
+ point.y += point.vy * f;
+ point.age++;
+ if (point.age > maxAge) trail.splice(i, 1);
+ }
+ for (let i = 0; i < trail.length; i++) drawPoint(trail[i]);
+ texture.needsUpdate = true;
+ };
+ return {
+ canvas,
+ texture,
+ addTouch,
+ update,
+ set radiusScale(v) {
+ radius = 0.1 * size * v;
+ },
+ get radiusScale() {
+ return radius / (0.1 * size);
+ },
+ size
+ };
+};
+
+const createLiquidEffect = (texture, opts) => {
+ const fragment = `
+ uniform sampler2D uTexture;
+ uniform float uStrength;
+ uniform float uTime;
+ uniform float uFreq;
+
+ void mainUv(inout vec2 uv) {
+ vec4 tex = texture2D(uTexture, uv);
+ float vx = tex.r * 2.0 - 1.0;
+ float vy = tex.g * 2.0 - 1.0;
+ float intensity = tex.b;
+
+ float wave = 0.5 + 0.5 * sin(uTime * uFreq + intensity * 6.2831853);
+
+ float amt = uStrength * intensity * wave;
+
+ uv += vec2(vx, vy) * amt;
+ }
+ `;
+ return new Effect('LiquidEffect', fragment, {
+ uniforms: new Map([
+ ['uTexture', new THREE.Uniform(texture)],
+ ['uStrength', new THREE.Uniform(opts?.strength ?? 0.025)],
+ ['uTime', new THREE.Uniform(0)],
+ ['uFreq', new THREE.Uniform(opts?.freq ?? 4.5)]
+ ])
+ });
+};
+
+const SHAPE_MAP = {
+ square: 0,
+ circle: 1,
+ triangle: 2,
+ diamond: 3
+};
+
+const VERTEX_SRC = `
+void main() {
+ gl_Position = vec4(position, 1.0);
+}
+`;
+
+const FRAGMENT_SRC = `
+precision highp float;
+
+uniform vec3 uColor;
+uniform vec2 uResolution;
+uniform float uTime;
+uniform float uPixelSize;
+uniform float uScale;
+uniform float uDensity;
+uniform float uPixelJitter;
+uniform int uEnableRipples;
+uniform float uRippleSpeed;
+uniform float uRippleThickness;
+uniform float uRippleIntensity;
+uniform float uEdgeFade;
+
+uniform int uShapeType;
+const int SHAPE_SQUARE = 0;
+const int SHAPE_CIRCLE = 1;
+const int SHAPE_TRIANGLE = 2;
+const int SHAPE_DIAMOND = 3;
+
+const int MAX_CLICKS = 10;
+
+uniform vec2 uClickPos [MAX_CLICKS];
+uniform float uClickTimes[MAX_CLICKS];
+
+out vec4 fragColor;
+
+float Bayer2(vec2 a) {
+ a = floor(a);
+ return fract(a.x / 2. + a.y * a.y * .75);
+}
+#define Bayer4(a) (Bayer2(.5*(a))*0.25 + Bayer2(a))
+#define Bayer8(a) (Bayer4(.5*(a))*0.25 + Bayer2(a))
+
+#define FBM_OCTAVES 5
+#define FBM_LACUNARITY 1.25
+#define FBM_GAIN 1.0
+
+float hash11(float n){ return fract(sin(n)*43758.5453); }
+
+float vnoise(vec3 p){
+ vec3 ip = floor(p);
+ vec3 fp = fract(p);
+ float n000 = hash11(dot(ip + vec3(0.0,0.0,0.0), vec3(1.0,57.0,113.0)));
+ float n100 = hash11(dot(ip + vec3(1.0,0.0,0.0), vec3(1.0,57.0,113.0)));
+ float n010 = hash11(dot(ip + vec3(0.0,1.0,0.0), vec3(1.0,57.0,113.0)));
+ float n110 = hash11(dot(ip + vec3(1.0,1.0,0.0), vec3(1.0,57.0,113.0)));
+ float n001 = hash11(dot(ip + vec3(0.0,0.0,1.0), vec3(1.0,57.0,113.0)));
+ float n101 = hash11(dot(ip + vec3(1.0,0.0,1.0), vec3(1.0,57.0,113.0)));
+ float n011 = hash11(dot(ip + vec3(0.0,1.0,1.0), vec3(1.0,57.0,113.0)));
+ float n111 = hash11(dot(ip + vec3(1.0,1.0,1.0), vec3(1.0,57.0,113.0)));
+ vec3 w = fp*fp*fp*(fp*(fp*6.0-15.0)+10.0);
+ float x00 = mix(n000, n100, w.x);
+ float x10 = mix(n010, n110, w.x);
+ float x01 = mix(n001, n101, w.x);
+ float x11 = mix(n011, n111, w.x);
+ float y0 = mix(x00, x10, w.y);
+ float y1 = mix(x01, x11, w.y);
+ return mix(y0, y1, w.z) * 2.0 - 1.0;
+}
+
+float fbm2(vec2 uv, float t){
+ vec3 p = vec3(uv * uScale, t);
+ float amp = 1.0;
+ float freq = 1.0;
+ float sum = 1.0;
+ for (int i = 0; i < FBM_OCTAVES; ++i){
+ sum += amp * vnoise(p * freq);
+ freq *= FBM_LACUNARITY;
+ amp *= FBM_GAIN;
+ }
+ return sum * 0.5 + 0.5;
+}
+
+float maskCircle(vec2 p, float cov){
+ float r = sqrt(cov) * .25;
+ float d = length(p - 0.5) - r;
+ float aa = 0.5 * fwidth(d);
+ return cov * (1.0 - smoothstep(-aa, aa, d * 2.0));
+}
+
+float maskTriangle(vec2 p, vec2 id, float cov){
+ bool flip = mod(id.x + id.y, 2.0) > 0.5;
+ if (flip) p.x = 1.0 - p.x;
+ float r = sqrt(cov);
+ float d = p.y - r*(1.0 - p.x);
+ float aa = fwidth(d);
+ return cov * clamp(0.5 - d/aa, 0.0, 1.0);
+}
+
+float maskDiamond(vec2 p, float cov){
+ float r = sqrt(cov) * 0.564;
+ return step(abs(p.x - 0.49) + abs(p.y - 0.49), r);
+}
+
+void main(){
+ float pixelSize = uPixelSize;
+ vec2 fragCoord = gl_FragCoord.xy - uResolution * .5;
+ float aspectRatio = uResolution.x / uResolution.y;
+
+ vec2 pixelId = floor(fragCoord / pixelSize);
+ vec2 pixelUV = fract(fragCoord / pixelSize);
+
+ float cellPixelSize = 8.0 * pixelSize;
+ vec2 cellId = floor(fragCoord / cellPixelSize);
+ vec2 cellCoord = cellId * cellPixelSize;
+ vec2 uv = cellCoord / uResolution * vec2(aspectRatio, 1.0);
+
+ float base = fbm2(uv, uTime * 0.05);
+ base = base * 0.5 - 0.65;
+
+ float feed = base + (uDensity - 0.5) * 0.3;
+
+ float speed = uRippleSpeed;
+ float thickness = uRippleThickness;
+ const float dampT = 1.0;
+ const float dampR = 10.0;
+
+ if (uEnableRipples == 1) {
+ for (int i = 0; i < MAX_CLICKS; ++i){
+ vec2 pos = uClickPos[i];
+ if (pos.x < 0.0) continue;
+ float cellPixelSize = 8.0 * pixelSize;
+ vec2 cuv = (((pos - uResolution * .5 - cellPixelSize * .5) / (uResolution))) * vec2(aspectRatio, 1.0);
+ float t = max(uTime - uClickTimes[i], 0.0);
+ float r = distance(uv, cuv);
+ float waveR = speed * t;
+ float ring = exp(-pow((r - waveR) / thickness, 2.0));
+ float atten = exp(-dampT * t) * exp(-dampR * r);
+ feed = max(feed, ring * atten * uRippleIntensity);
+ }
+ }
+
+ float bayer = Bayer8(fragCoord / uPixelSize) - 0.5;
+ float bw = step(0.5, feed + bayer);
+
+ float h = fract(sin(dot(floor(fragCoord / uPixelSize), vec2(127.1, 311.7))) * 43758.5453);
+ float jitterScale = 1.0 + (h - 0.5) * uPixelJitter;
+ float coverage = bw * jitterScale;
+ float M;
+ if (uShapeType == SHAPE_CIRCLE) M = maskCircle (pixelUV, coverage);
+ else if (uShapeType == SHAPE_TRIANGLE) M = maskTriangle(pixelUV, pixelId, coverage);
+ else if (uShapeType == SHAPE_DIAMOND) M = maskDiamond(pixelUV, coverage);
+ else M = coverage;
+
+ if (uEdgeFade > 0.0) {
+ vec2 norm = gl_FragCoord.xy / uResolution;
+ float edge = min(min(norm.x, norm.y), min(1.0 - norm.x, 1.0 - norm.y));
+ float fade = smoothstep(0.0, uEdgeFade, edge);
+ M *= fade;
+ }
+
+ vec3 color = uColor;
+
+ // sRGB gamma correction - convert linear to sRGB for accurate color output
+ vec3 srgbColor = mix(
+ color * 12.92,
+ 1.055 * pow(color, vec3(1.0 / 2.4)) - 0.055,
+ step(0.0031308, color)
+ );
+
+ fragColor = vec4(srgbColor, M);
+}
+`;
+
+const MAX_CLICKS = 10;
+
+const PixelBlast = ({
+ variant = 'square',
+ pixelSize = 3,
+ color = '#B19EEF',
+ className,
+ style,
+ antialias = true,
+ patternScale = 2,
+ patternDensity = 1,
+ liquid = false,
+ liquidStrength = 0.1,
+ liquidRadius = 1,
+ pixelSizeJitter = 0,
+ enableRipples = true,
+ rippleIntensityScale = 1,
+ rippleThickness = 0.1,
+ rippleSpeed = 0.3,
+ liquidWobbleSpeed = 4.5,
+ autoPauseOffscreen = true,
+ speed = 0.5,
+ transparent = true,
+ edgeFade = 0.5,
+ noiseAmount = 0
+}) => {
+ const containerRef = useRef(null);
+ const visibilityRef = useRef({ visible: true });
+ const speedRef = useRef(speed);
+
+ const threeRef = useRef(null);
+ const prevConfigRef = useRef(null);
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+ speedRef.current = speed;
+ const needsReinitKeys = ['antialias', 'liquid', 'noiseAmount'];
+ const cfg = { antialias, liquid, noiseAmount };
+ let mustReinit = false;
+ if (!threeRef.current) mustReinit = true;
+ else if (prevConfigRef.current) {
+ for (const k of needsReinitKeys)
+ if (prevConfigRef.current[k] !== cfg[k]) {
+ mustReinit = true;
+ break;
+ }
+ }
+ if (mustReinit) {
+ if (threeRef.current) {
+ const t = threeRef.current;
+ t.resizeObserver?.disconnect();
+ cancelAnimationFrame(t.raf);
+ t.quad?.geometry.dispose();
+ t.material.dispose();
+ t.composer?.dispose();
+ t.renderer.dispose();
+ if (t.renderer.domElement.parentElement === container) container.removeChild(t.renderer.domElement);
+ threeRef.current = null;
+ }
+ const canvas = document.createElement('canvas');
+ const renderer = new THREE.WebGLRenderer({
+ canvas,
+ antialias,
+ alpha: true,
+ powerPreference: 'high-performance'
+ });
+ renderer.domElement.style.width = '100%';
+ renderer.domElement.style.height = '100%';
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
+ container.appendChild(renderer.domElement);
+ if (transparent) renderer.setClearAlpha(0);
+ else renderer.setClearColor(0x000000, 1);
+ const uniforms = {
+ uResolution: { value: new THREE.Vector2(0, 0) },
+ uTime: { value: 0 },
+ uColor: { value: new THREE.Color(color) },
+ uClickPos: {
+ value: Array.from({ length: MAX_CLICKS }, () => new THREE.Vector2(-1, -1))
+ },
+ uClickTimes: { value: new Float32Array(MAX_CLICKS) },
+ uShapeType: { value: SHAPE_MAP[variant] ?? 0 },
+ uPixelSize: { value: pixelSize * renderer.getPixelRatio() },
+ uScale: { value: patternScale },
+ uDensity: { value: patternDensity },
+ uPixelJitter: { value: pixelSizeJitter },
+ uEnableRipples: { value: enableRipples ? 1 : 0 },
+ uRippleSpeed: { value: rippleSpeed },
+ uRippleThickness: { value: rippleThickness },
+ uRippleIntensity: { value: rippleIntensityScale },
+ uEdgeFade: { value: edgeFade }
+ };
+ const scene = new THREE.Scene();
+ const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
+ const material = new THREE.ShaderMaterial({
+ vertexShader: VERTEX_SRC,
+ fragmentShader: FRAGMENT_SRC,
+ uniforms,
+ transparent: true,
+ depthTest: false,
+ depthWrite: false,
+ glslVersion: THREE.GLSL3
+ });
+ const quadGeom = new THREE.PlaneGeometry(2, 2);
+ const quad = new THREE.Mesh(quadGeom, material);
+ scene.add(quad);
+ const clock = new THREE.Clock();
+ const setSize = () => {
+ const w = container.clientWidth || 1;
+ const h = container.clientHeight || 1;
+ renderer.setSize(w, h, false);
+ uniforms.uResolution.value.set(renderer.domElement.width, renderer.domElement.height);
+ if (threeRef.current?.composer)
+ threeRef.current.composer.setSize(renderer.domElement.width, renderer.domElement.height);
+ uniforms.uPixelSize.value = pixelSize * renderer.getPixelRatio();
+ };
+ setSize();
+ const ro = new ResizeObserver(setSize);
+ ro.observe(container);
+ const randomFloat = () => {
+ if (typeof window !== 'undefined' && window.crypto?.getRandomValues) {
+ const u32 = new Uint32Array(1);
+ window.crypto.getRandomValues(u32);
+ return u32[0] / 0xffffffff;
+ }
+ return Math.random();
+ };
+ const timeOffset = randomFloat() * 1000;
+ let composer;
+ let touch;
+ let liquidEffect;
+ if (liquid) {
+ touch = createTouchTexture();
+ touch.radiusScale = liquidRadius;
+ composer = new EffectComposer(renderer);
+ const renderPass = new RenderPass(scene, camera);
+ liquidEffect = createLiquidEffect(touch.texture, {
+ strength: liquidStrength,
+ freq: liquidWobbleSpeed
+ });
+ const effectPass = new EffectPass(camera, liquidEffect);
+ effectPass.renderToScreen = true;
+ composer.addPass(renderPass);
+ composer.addPass(effectPass);
+ }
+ if (noiseAmount > 0) {
+ if (!composer) {
+ composer = new EffectComposer(renderer);
+ composer.addPass(new RenderPass(scene, camera));
+ }
+ const noiseEffect = new Effect(
+ 'NoiseEffect',
+ `uniform float uTime; uniform float uAmount; float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7))) * 43758.5453);} void mainUv(inout vec2 uv){} void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor){ float n=hash(floor(uv*vec2(1920.0,1080.0))+floor(uTime*60.0)); float g=(n-0.5)*uAmount; outputColor=inputColor+vec4(vec3(g),0.0);} `,
+ {
+ uniforms: new Map([
+ ['uTime', new THREE.Uniform(0)],
+ ['uAmount', new THREE.Uniform(noiseAmount)]
+ ])
+ }
+ );
+ const noisePass = new EffectPass(camera, noiseEffect);
+ noisePass.renderToScreen = true;
+ if (composer && composer.passes.length > 0) composer.passes.forEach(p => (p.renderToScreen = false));
+ composer.addPass(noisePass);
+ }
+ if (composer) composer.setSize(renderer.domElement.width, renderer.domElement.height);
+ const mapToPixels = e => {
+ const rect = renderer.domElement.getBoundingClientRect();
+ const scaleX = renderer.domElement.width / rect.width;
+ const scaleY = renderer.domElement.height / rect.height;
+ const fx = (e.clientX - rect.left) * scaleX;
+ const fy = (rect.height - (e.clientY - rect.top)) * scaleY;
+ return {
+ fx,
+ fy,
+ w: renderer.domElement.width,
+ h: renderer.domElement.height
+ };
+ };
+ const onPointerDown = e => {
+ const { fx, fy } = mapToPixels(e);
+ const ix = threeRef.current?.clickIx ?? 0;
+ uniforms.uClickPos.value[ix].set(fx, fy);
+ uniforms.uClickTimes.value[ix] = uniforms.uTime.value;
+ if (threeRef.current) threeRef.current.clickIx = (ix + 1) % MAX_CLICKS;
+ };
+ const onPointerMove = e => {
+ if (!touch) return;
+ const { fx, fy, w, h } = mapToPixels(e);
+ touch.addTouch({ x: fx / w, y: fy / h });
+ };
+ renderer.domElement.addEventListener('pointerdown', onPointerDown, {
+ passive: true
+ });
+ renderer.domElement.addEventListener('pointermove', onPointerMove, {
+ passive: true
+ });
+ let raf = 0;
+ const animate = () => {
+ if (autoPauseOffscreen && !visibilityRef.current.visible) {
+ raf = requestAnimationFrame(animate);
+ return;
+ }
+ uniforms.uTime.value = timeOffset + clock.getElapsedTime() * speedRef.current;
+ if (liquidEffect) liquidEffect.uniforms.get('uTime').value = uniforms.uTime.value;
+ if (composer) {
+ if (touch) touch.update();
+ composer.passes.forEach(p => {
+ const effs = p.effects;
+ if (effs)
+ effs.forEach(eff => {
+ const u = eff.uniforms?.get('uTime');
+ if (u) u.value = uniforms.uTime.value;
+ });
+ });
+ composer.render();
+ } else renderer.render(scene, camera);
+ raf = requestAnimationFrame(animate);
+ };
+ raf = requestAnimationFrame(animate);
+ threeRef.current = {
+ renderer,
+ scene,
+ camera,
+ material,
+ clock,
+ clickIx: 0,
+ uniforms,
+ resizeObserver: ro,
+ raf,
+ quad,
+ timeOffset,
+ composer,
+ touch,
+ liquidEffect
+ };
+ } else {
+ const t = threeRef.current;
+ t.uniforms.uShapeType.value = SHAPE_MAP[variant] ?? 0;
+ t.uniforms.uPixelSize.value = pixelSize * t.renderer.getPixelRatio();
+ t.uniforms.uColor.value.set(color);
+ t.uniforms.uScale.value = patternScale;
+ t.uniforms.uDensity.value = patternDensity;
+ t.uniforms.uPixelJitter.value = pixelSizeJitter;
+ t.uniforms.uEnableRipples.value = enableRipples ? 1 : 0;
+ t.uniforms.uRippleIntensity.value = rippleIntensityScale;
+ t.uniforms.uRippleThickness.value = rippleThickness;
+ t.uniforms.uRippleSpeed.value = rippleSpeed;
+ t.uniforms.uEdgeFade.value = edgeFade;
+ if (transparent) t.renderer.setClearAlpha(0);
+ else t.renderer.setClearColor(0x000000, 1);
+ if (t.liquidEffect) {
+ const uStrength = t.liquidEffect;
+ if (uStrength) uStrength.value = liquidStrength;
+ const uFreq = t.liquidEffect.uniforms.get('uFreq');
+ if (uFreq) uFreq.value = liquidWobbleSpeed;
+ }
+ if (t.touch) t.touch.radiusScale = liquidRadius;
+ }
+ prevConfigRef.current = cfg;
+ return () => {
+ if (threeRef.current && mustReinit) return;
+ if (!threeRef.current) return;
+ const t = threeRef.current;
+ t.resizeObserver?.disconnect();
+ cancelAnimationFrame(t.raf);
+ t.quad?.geometry.dispose();
+ t.material.dispose();
+ t.composer?.dispose();
+ t.renderer.dispose();
+ if (t.renderer.domElement.parentElement === container) container.removeChild(t.renderer.domElement);
+ threeRef.current = null;
+ };
+ }, [
+ antialias,
+ liquid,
+ noiseAmount,
+ pixelSize,
+ patternScale,
+ patternDensity,
+ enableRipples,
+ rippleIntensityScale,
+ rippleThickness,
+ rippleSpeed,
+ pixelSizeJitter,
+ edgeFade,
+ transparent,
+ liquidStrength,
+ liquidRadius,
+ liquidWobbleSpeed,
+ autoPauseOffscreen,
+ variant,
+ color,
+ speed
+ ]);
+
+ return (
+
+ );
+};
+
+export default PixelBlast;
diff --git a/frontend/components/Shuffle.css b/frontend/components/Shuffle.css
new file mode 100644
index 00000000..2bd8e83f
--- /dev/null
+++ b/frontend/components/Shuffle.css
@@ -0,0 +1,30 @@
+.shuffle-parent {
+ display: inline-block;
+ white-space: normal;
+ word-wrap: break-word;
+ will-change: transform;
+ line-height: 1.2;
+ visibility: hidden;
+}
+
+.shuffle-parent.is-ready {
+ visibility: visible;
+}
+
+.shuffle-char-wrapper {
+ display: inline-block;
+ overflow: hidden;
+ vertical-align: baseline;
+ position: relative;
+}
+
+.shuffle-char-wrapper > span {
+ display: inline-flex;
+ will-change: transform;
+}
+
+.shuffle-char {
+ line-height: 1;
+ display: inline-block;
+ text-align: center;
+}
diff --git a/frontend/components/Shuffle.tsx b/frontend/components/Shuffle.tsx
new file mode 100644
index 00000000..1c99cfe8
--- /dev/null
+++ b/frontend/components/Shuffle.tsx
@@ -0,0 +1,418 @@
+import React, { useRef, useEffect, useState, useMemo } from 'react';
+import { gsap } from 'gsap';
+import { ScrollTrigger } from 'gsap/ScrollTrigger';
+import { SplitText as GSAPSplitText } from 'gsap/SplitText';
+import { useGSAP } from '@gsap/react';
+import './Shuffle.css';
+
+gsap.registerPlugin(ScrollTrigger, GSAPSplitText, useGSAP);
+
+interface ShuffleProps {
+ text: string;
+ className?: string;
+ style?: React.CSSProperties;
+ shuffleDirection?: 'up' | 'down' | 'left' | 'right';
+ duration?: number;
+ maxDelay?: number;
+ ease?: string;
+ threshold?: number;
+ rootMargin?: string;
+ tag?: keyof JSX.IntrinsicElements;
+ textAlign?: 'left' | 'center' | 'right';
+ onShuffleComplete?: () => void;
+ shuffleTimes?: number;
+ animationMode?: 'evenodd' | 'random';
+ loop?: boolean;
+ loopDelay?: number;
+ stagger?: number;
+ scrambleCharset?: string;
+ colorFrom?: string;
+ colorTo?: string;
+ triggerOnce?: boolean;
+ respectReducedMotion?: boolean;
+ triggerOnHover?: boolean;
+}
+
+const Shuffle: React.FC
= ({
+ text,
+ className = '',
+ style = {},
+ shuffleDirection = 'right',
+ duration = 0.35,
+ maxDelay = 0,
+ ease = 'power3.out',
+ threshold = 0.1,
+ rootMargin = '-100px',
+ tag = 'p',
+ textAlign = 'center',
+ onShuffleComplete,
+ shuffleTimes = 1,
+ animationMode = 'evenodd',
+ loop = false,
+ loopDelay = 0,
+ stagger = 0.03,
+ scrambleCharset = '',
+ colorFrom,
+ colorTo,
+ triggerOnce = true,
+ respectReducedMotion = true,
+ triggerOnHover = true
+}) => {
+ const ref = useRef(null);
+ const [fontsLoaded, setFontsLoaded] = useState(false);
+ const [ready, setReady] = useState(false);
+
+ const splitRef = useRef(null);
+ const wrappersRef = useRef([]);
+ const tlRef = useRef(null);
+ const playingRef = useRef(false);
+ const hoverHandlerRef = useRef(null);
+
+ useEffect(() => {
+ if ('fonts' in document) {
+ if (document.fonts.status === 'loaded') setFontsLoaded(true);
+ else document.fonts.ready.then(() => setFontsLoaded(true));
+ } else setFontsLoaded(true);
+ }, []);
+
+ const scrollTriggerStart = useMemo(() => {
+ const startPct = (1 - threshold) * 100;
+ const mm = /^(-?\d+(?:\.\d+)?)(px|em|rem|%)?$/.exec(rootMargin || '');
+ const mv = mm ? parseFloat(mm[1]) : 0;
+ const mu = mm ? mm[2] || 'px' : 'px';
+ const sign = mv === 0 ? '' : mv < 0 ? `-=${Math.abs(mv)}${mu}` : `+=${mv}${mu}`;
+ return `top ${startPct}%${sign}`;
+ }, [threshold, rootMargin]);
+
+ useGSAP(
+ () => {
+ if (!ref.current || !text || !fontsLoaded) return;
+ if (respectReducedMotion && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ setReady(true);
+ onShuffleComplete?.();
+ return;
+ }
+
+ const el = ref.current;
+
+ const start = scrollTriggerStart;
+
+ const removeHover = () => {
+ if (hoverHandlerRef.current && ref.current) {
+ ref.current.removeEventListener('mouseenter', hoverHandlerRef.current);
+ hoverHandlerRef.current = null;
+ }
+ };
+
+ const teardown = () => {
+ if (tlRef.current) {
+ tlRef.current.kill();
+ tlRef.current = null;
+ }
+ if (wrappersRef.current.length) {
+ wrappersRef.current.forEach(wrap => {
+ const inner = wrap.firstElementChild;
+ const orig = inner?.querySelector('[data-orig="1"]');
+ if (orig && wrap.parentNode) wrap.parentNode.replaceChild(orig, wrap);
+ });
+ wrappersRef.current = [];
+ }
+ try {
+ splitRef.current?.revert();
+ } catch {
+ /* noop */
+ }
+ splitRef.current = null;
+ playingRef.current = false;
+ };
+
+ const build = () => {
+ teardown();
+
+ splitRef.current = new GSAPSplitText(el, {
+ type: 'chars',
+ charsClass: 'shuffle-char',
+ wordsClass: 'shuffle-word',
+ linesClass: 'shuffle-line',
+ smartWrap: true,
+ reduceWhiteSpace: false
+ });
+
+ const chars = splitRef.current.chars || [];
+ wrappersRef.current = [];
+
+ const rolls = Math.max(1, Math.floor(shuffleTimes));
+ const rand = set => set.charAt(Math.floor(Math.random() * set.length)) || '';
+
+ chars.forEach(ch => {
+ const parent = ch.parentElement;
+ if (!parent) return;
+
+ const w = ch.getBoundingClientRect().width;
+ const h = ch.getBoundingClientRect().height;
+ if (!w) return;
+
+ const wrap = document.createElement('span');
+ Object.assign(wrap.style, {
+ display: 'inline-block',
+ overflow: 'hidden',
+ width: w + 'px',
+ height: shuffleDirection === 'up' || shuffleDirection === 'down' ? h + 'px' : 'auto',
+ verticalAlign: 'bottom'
+ });
+
+ const inner = document.createElement('span');
+ Object.assign(inner.style, {
+ display: 'inline-block',
+ whiteSpace: shuffleDirection === 'up' || shuffleDirection === 'down' ? 'normal' : 'nowrap',
+ willChange: 'transform'
+ });
+
+ parent.insertBefore(wrap, ch);
+ wrap.appendChild(inner);
+
+ const firstOrig = ch.cloneNode(true);
+ Object.assign(firstOrig.style, {
+ display: shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block',
+ width: w + 'px',
+ textAlign: 'center'
+ });
+
+ ch.setAttribute('data-orig', '1');
+ Object.assign(ch.style, {
+ display: shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block',
+ width: w + 'px',
+ textAlign: 'center'
+ });
+
+ inner.appendChild(firstOrig);
+ for (let k = 0; k < rolls; k++) {
+ const c = ch.cloneNode(true);
+ if (scrambleCharset) c.textContent = rand(scrambleCharset);
+ Object.assign(c.style, {
+ display: shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block',
+ width: w + 'px',
+ textAlign: 'center'
+ });
+ inner.appendChild(c);
+ }
+ inner.appendChild(ch);
+
+ const steps = rolls + 1;
+
+ if (shuffleDirection === 'right' || shuffleDirection === 'down') {
+ const firstCopy = inner.firstElementChild;
+ const real = inner.lastElementChild;
+ if (real) inner.insertBefore(real, inner.firstChild);
+ if (firstCopy) inner.appendChild(firstCopy);
+ }
+
+ let startX = 0;
+ let finalX = 0;
+ let startY = 0;
+ let finalY = 0;
+
+ if (shuffleDirection === 'right') {
+ startX = -steps * w;
+ finalX = 0;
+ } else if (shuffleDirection === 'left') {
+ startX = 0;
+ finalX = -steps * w;
+ } else if (shuffleDirection === 'down') {
+ startY = -steps * h;
+ finalY = 0;
+ } else if (shuffleDirection === 'up') {
+ startY = 0;
+ finalY = -steps * h;
+ }
+
+ if (shuffleDirection === 'left' || shuffleDirection === 'right') {
+ gsap.set(inner, { x: startX, y: 0, force3D: true });
+ inner.setAttribute('data-start-x', String(startX));
+ inner.setAttribute('data-final-x', String(finalX));
+ } else {
+ gsap.set(inner, { x: 0, y: startY, force3D: true });
+ inner.setAttribute('data-start-y', String(startY));
+ inner.setAttribute('data-final-y', String(finalY));
+ }
+
+ if (colorFrom) inner.style.color = colorFrom;
+ wrappersRef.current.push(wrap);
+ });
+ };
+
+ const inners = () => wrappersRef.current.map(w => w.firstElementChild);
+
+ const randomizeScrambles = () => {
+ if (!scrambleCharset) return;
+ wrappersRef.current.forEach(w => {
+ const strip = w.firstElementChild;
+ if (!strip) return;
+ const kids = Array.from(strip.children);
+ for (let i = 1; i < kids.length - 1; i++) {
+ kids[i].textContent = scrambleCharset.charAt(Math.floor(Math.random() * scrambleCharset.length));
+ }
+ });
+ };
+
+ const cleanupToStill = () => {
+ wrappersRef.current.forEach(w => {
+ const strip = w.firstElementChild;
+ if (!strip) return;
+ const real = strip.querySelector('[data-orig="1"]');
+ if (!real) return;
+ strip.replaceChildren(real);
+ strip.style.transform = 'none';
+ strip.style.willChange = 'auto';
+ });
+ };
+
+ const play = () => {
+ const strips = inners();
+ if (!strips.length) return;
+
+ playingRef.current = true;
+ const isVertical = shuffleDirection === 'up' || shuffleDirection === 'down';
+
+ const tl = gsap.timeline({
+ smoothChildTiming: true,
+ repeat: loop ? -1 : 0,
+ repeatDelay: loop ? loopDelay : 0,
+ onRepeat: () => {
+ if (scrambleCharset) randomizeScrambles();
+ if (isVertical) {
+ gsap.set(strips, { y: (i, t) => parseFloat(t.getAttribute('data-start-y') || '0') });
+ } else {
+ gsap.set(strips, { x: (i, t) => parseFloat(t.getAttribute('data-start-x') || '0') });
+ }
+ onShuffleComplete?.();
+ },
+ onComplete: () => {
+ playingRef.current = false;
+ if (!loop) {
+ cleanupToStill();
+ if (colorTo) gsap.set(strips, { color: colorTo });
+ onShuffleComplete?.();
+ armHover();
+ }
+ }
+ });
+
+ const addTween = (targets, at) => {
+ const vars = {
+ duration,
+ ease,
+ force3D: true,
+ stagger: animationMode === 'evenodd' ? stagger : 0
+ };
+ if (isVertical) {
+ vars.y = (i, t) => parseFloat(t.getAttribute('data-final-y') || '0');
+ } else {
+ vars.x = (i, t) => parseFloat(t.getAttribute('data-final-x') || '0');
+ }
+
+ tl.to(targets, vars, at);
+
+ if (colorFrom && colorTo) {
+ tl.to(targets, { color: colorTo, duration, ease }, at);
+ }
+ };
+
+ if (animationMode === 'evenodd') {
+ const odd = strips.filter((_, i) => i % 2 === 1);
+ const even = strips.filter((_, i) => i % 2 === 0);
+ const oddTotal = duration + Math.max(0, odd.length - 1) * stagger;
+ const evenStart = odd.length ? oddTotal * 0.7 : 0;
+ if (odd.length) addTween(odd, 0);
+ if (even.length) addTween(even, evenStart);
+ } else {
+ strips.forEach(strip => {
+ const d = Math.random() * maxDelay;
+ const vars = {
+ duration,
+ ease,
+ force3D: true
+ };
+ if (isVertical) {
+ vars.y = parseFloat(strip.getAttribute('data-final-y') || '0');
+ } else {
+ vars.x = parseFloat(strip.getAttribute('data-final-x') || '0');
+ }
+ tl.to(strip, vars, d);
+ if (colorFrom && colorTo) tl.fromTo(strip, { color: colorFrom }, { color: colorTo, duration, ease }, d);
+ });
+ }
+
+ tlRef.current = tl;
+ };
+
+ const armHover = () => {
+ if (!triggerOnHover || !ref.current) return;
+ removeHover();
+ const handler = () => {
+ if (playingRef.current) return;
+ build();
+ if (scrambleCharset) randomizeScrambles();
+ play();
+ };
+ hoverHandlerRef.current = handler;
+ ref.current.addEventListener('mouseenter', handler);
+ };
+
+ const create = () => {
+ build();
+ if (scrambleCharset) randomizeScrambles();
+ play();
+ armHover();
+ setReady(true);
+ };
+
+ const st = ScrollTrigger.create({
+ trigger: el,
+ start,
+ once: triggerOnce,
+ onEnter: create
+ });
+
+ return () => {
+ st.kill();
+ removeHover();
+ teardown();
+ setReady(false);
+ };
+ },
+ {
+ dependencies: [
+ text,
+ duration,
+ maxDelay,
+ ease,
+ scrollTriggerStart,
+ fontsLoaded,
+ shuffleDirection,
+ shuffleTimes,
+ animationMode,
+ loop,
+ loopDelay,
+ stagger,
+ scrambleCharset,
+ colorFrom,
+ colorTo,
+ triggerOnce,
+ respectReducedMotion,
+ triggerOnHover,
+ onShuffleComplete
+ ],
+ scope: ref
+ }
+ );
+
+ const commonStyle = useMemo(() => ({ textAlign, ...style }), [textAlign, style]);
+
+ const classes = useMemo(() => `shuffle-parent ${ready ? 'is-ready' : ''} ${className}`, [ready, className]);
+
+ const Tag = tag || 'p';
+ return React.createElement(Tag, { ref, className: classes, style: commonStyle }, text);
+};
+
+export default Shuffle;
diff --git a/frontend/components/animate-ui/components/backgrounds/gravity-stars.tsx b/frontend/components/animate-ui/components/backgrounds/gravity-stars.tsx
new file mode 100644
index 00000000..caa3153b
--- /dev/null
+++ b/frontend/components/animate-ui/components/backgrounds/gravity-stars.tsx
@@ -0,0 +1,360 @@
+'use client';
+
+import * as React from 'react';
+
+import { cn } from '@/lib/utils';
+
+type MouseGravity = 'attract' | 'repel';
+type GlowAnimation = 'instant' | 'ease' | 'spring';
+type StarsInteractionType = 'bounce' | 'merge';
+
+type GravityStarsProps = {
+ starsCount?: number;
+ starsSize?: number;
+ starsOpacity?: number;
+ glowIntensity?: number;
+ glowAnimation?: GlowAnimation;
+ movementSpeed?: number;
+ mouseInfluence?: number;
+ mouseGravity?: MouseGravity;
+ gravityStrength?: number;
+ starsInteraction?: boolean;
+ starsInteractionType?: StarsInteractionType;
+} & React.ComponentProps<'div'>;
+
+type Particle = {
+ x: number;
+ y: number;
+ vx: number;
+ vy: number;
+ size: number;
+ opacity: number;
+ baseOpacity: number;
+ mass: number;
+ glowMultiplier?: number;
+ glowVelocity?: number;
+};
+
+function GravityStarsBackground({
+ starsCount = 75,
+ starsSize = 2,
+ starsOpacity = 0.75,
+ glowIntensity = 15,
+ glowAnimation = 'ease',
+ movementSpeed = 0.3,
+ mouseInfluence = 100,
+ mouseGravity = 'attract',
+ gravityStrength = 75,
+ starsInteraction = false,
+ starsInteractionType = 'bounce',
+ className,
+ ...props
+}: GravityStarsProps) {
+ const containerRef = React.useRef(null);
+ const canvasRef = React.useRef(null);
+ const animRef = React.useRef(null);
+ const starsRef = React.useRef([]);
+ const mouseRef = React.useRef<{ x: number; y: number }>({ x: 0, y: 0 });
+ const [dpr, setDpr] = React.useState(1);
+ const [canvasSize, setCanvasSize] = React.useState({
+ width: 800,
+ height: 600,
+ });
+
+ const readColor = React.useCallback(() => {
+ const el = containerRef.current;
+ if (!el) return '#ffffff';
+ const cs = getComputedStyle(el);
+ return cs.color || '#ffffff';
+ }, []);
+
+ const initStars = React.useCallback(
+ (w: number, h: number) => {
+ starsRef.current = Array.from({ length: starsCount }).map(() => {
+ const angle = Math.random() * Math.PI * 2;
+ const speed = movementSpeed * (0.5 + Math.random() * 0.5);
+ return {
+ x: Math.random() * w,
+ y: Math.random() * h,
+ vx: Math.cos(angle) * speed,
+ vy: Math.sin(angle) * speed,
+ size: Math.random() * starsSize + 1,
+ opacity: starsOpacity,
+ baseOpacity: starsOpacity,
+ mass: Math.random() * 0.5 + 0.5,
+ glowMultiplier: 1,
+ glowVelocity: 0,
+ };
+ });
+ },
+ [starsCount, movementSpeed, starsOpacity, starsSize],
+ );
+
+ const redistributeStars = React.useCallback((w: number, h: number) => {
+ starsRef.current.forEach((p) => {
+ p.x = Math.random() * w;
+ p.y = Math.random() * h;
+ });
+ }, []);
+
+ const resizeCanvas = React.useCallback(() => {
+ const canvas = canvasRef.current;
+ const container = containerRef.current;
+ if (!canvas || !container) return;
+ const rect = container.getBoundingClientRect();
+ const nextDpr = Math.max(1, Math.min(window.devicePixelRatio || 1, 2));
+ setDpr(nextDpr);
+ canvas.width = Math.max(1, Math.floor(rect.width * nextDpr));
+ canvas.height = Math.max(1, Math.floor(rect.height * nextDpr));
+ canvas.style.width = `${rect.width}px`;
+ canvas.style.height = `${rect.height}px`;
+ setCanvasSize({ width: rect.width, height: rect.height });
+ if (starsRef.current.length === 0) {
+ initStars(rect.width, rect.height);
+ } else {
+ redistributeStars(rect.width, rect.height);
+ }
+ }, [initStars, redistributeStars]);
+
+ const handlePointerMove = React.useCallback(
+ (e: React.MouseEvent | React.TouchEvent) => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const rect = canvas.getBoundingClientRect();
+ let clientX = 0;
+ let clientY = 0;
+ if ('touches' in e) {
+ const t = e.touches[0];
+ if (!t) return;
+ clientX = t.clientX;
+ clientY = t.clientY;
+ } else {
+ clientX = e.clientX;
+ clientY = e.clientY;
+ }
+ mouseRef.current = { x: clientX - rect.left, y: clientY - rect.top };
+ },
+ [],
+ );
+
+ const updateStars = React.useCallback(() => {
+ const w = canvasSize.width;
+ const h = canvasSize.height;
+ const mouse = mouseRef.current;
+
+ for (let i = 0; i < starsRef.current.length; i++) {
+ const p = starsRef.current[i];
+
+ const dx = mouse.x - p.x;
+ const dy = mouse.y - p.y;
+ const dist = Math.hypot(dx, dy);
+
+ if (dist < mouseInfluence && dist > 0) {
+ const force = (mouseInfluence - dist) / mouseInfluence;
+ const nx = dx / dist;
+ const ny = dy / dist;
+ const g = force * (gravityStrength * 0.001);
+
+ if (mouseGravity === 'attract') {
+ p.vx += nx * g;
+ p.vy += ny * g;
+ } else if (mouseGravity === 'repel') {
+ p.vx -= nx * g;
+ p.vy -= ny * g;
+ }
+
+ p.opacity = Math.min(1, p.baseOpacity + force * 0.4);
+
+ const targetGlow = 1 + force * 2;
+ const currentGlow = p.glowMultiplier || 1;
+
+ if (glowAnimation === 'instant') {
+ p.glowMultiplier = targetGlow;
+ } else if (glowAnimation === 'ease') {
+ const ease = 0.15;
+ p.glowMultiplier = currentGlow + (targetGlow - currentGlow) * ease;
+ } else {
+ const spring = (targetGlow - currentGlow) * 0.2;
+ const damping = 0.85;
+ p.glowVelocity = (p.glowVelocity || 0) * damping + spring;
+ p.glowMultiplier = currentGlow + (p.glowVelocity || 0);
+ }
+ } else {
+ p.opacity = Math.max(p.baseOpacity * 0.3, p.opacity - 0.02);
+ const targetGlow = 1;
+ const currentGlow = p.glowMultiplier || 1;
+ if (glowAnimation === 'instant') {
+ p.glowMultiplier = targetGlow;
+ } else if (glowAnimation === 'ease') {
+ const ease = 0.08;
+ p.glowMultiplier = Math.max(
+ 1,
+ currentGlow + (targetGlow - currentGlow) * ease,
+ );
+ } else {
+ const spring = (targetGlow - currentGlow) * 0.15;
+ const damping = 0.9;
+ p.glowVelocity = (p.glowVelocity || 0) * damping + spring;
+ p.glowMultiplier = Math.max(1, currentGlow + (p.glowVelocity || 0));
+ }
+ }
+
+ if (starsInteraction) {
+ for (let j = i + 1; j < starsRef.current.length; j++) {
+ const o = starsRef.current[j];
+ const dx2 = o.x - p.x;
+ const dy2 = o.y - p.y;
+ const d = Math.hypot(dx2, dy2);
+ const minD = p.size + o.size + 5;
+ if (d < minD && d > 0) {
+ if (starsInteractionType === 'bounce') {
+ const nx = dx2 / d;
+ const ny = dy2 / d;
+ const rvx = p.vx - o.vx;
+ const rvy = p.vy - o.vy;
+ const speed = rvx * nx + rvy * ny;
+ if (speed < 0) continue;
+ const impulse = (2 * speed) / (p.mass + o.mass);
+ p.vx -= impulse * o.mass * nx;
+ p.vy -= impulse * o.mass * ny;
+ o.vx += impulse * p.mass * nx;
+ o.vy += impulse * p.mass * ny;
+ const overlap = minD - d;
+ const sx = nx * overlap * 0.5;
+ const sy = ny * overlap * 0.5;
+ p.x -= sx;
+ p.y -= sy;
+ o.x += sx;
+ o.y += sy;
+ } else {
+ const mergeForce = (minD - d) / minD;
+ p.glowMultiplier = (p.glowMultiplier || 1) + mergeForce * 0.5;
+ o.glowMultiplier = (o.glowMultiplier || 1) + mergeForce * 0.5;
+ const af = mergeForce * 0.01;
+ p.vx += dx2 * af;
+ p.vy += dy2 * af;
+ o.vx -= dx2 * af;
+ o.vy -= dy2 * af;
+ }
+ }
+ }
+ }
+
+ p.x += p.vx;
+ p.y += p.vy;
+
+ p.vx += (Math.random() - 0.5) * 0.001;
+ p.vy += (Math.random() - 0.5) * 0.001;
+
+ p.vx *= 0.999;
+ p.vy *= 0.999;
+
+ if (p.x < 0) p.x = w;
+ if (p.x > w) p.x = 0;
+ if (p.y < 0) p.y = h;
+ if (p.y > h) p.y = 0;
+ }
+ }, [
+ canvasSize.width,
+ canvasSize.height,
+ mouseInfluence,
+ mouseGravity,
+ gravityStrength,
+ glowAnimation,
+ starsInteraction,
+ starsInteractionType,
+ ]);
+
+ const drawStars = React.useCallback(
+ (ctx: CanvasRenderingContext2D) => {
+ ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
+ const color = readColor();
+ for (const p of starsRef.current) {
+ ctx.save();
+ ctx.shadowColor = color;
+ ctx.shadowBlur = glowIntensity * (p.glowMultiplier || 1) * 2;
+ ctx.globalAlpha = p.opacity;
+ ctx.fillStyle = color;
+ ctx.beginPath();
+ ctx.arc(p.x * dpr, p.y * dpr, p.size * dpr, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+ }
+ },
+ [dpr, glowIntensity, readColor],
+ );
+
+ const animate = React.useCallback(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+ updateStars();
+ drawStars(ctx);
+ animRef.current = requestAnimationFrame(animate);
+ }, [updateStars, drawStars]);
+
+ React.useEffect(() => {
+ resizeCanvas();
+ const container = containerRef.current;
+ const ro =
+ typeof ResizeObserver !== 'undefined'
+ ? new ResizeObserver(resizeCanvas)
+ : null;
+ if (container && ro) ro.observe(container);
+ const onResize = () => resizeCanvas();
+ window.addEventListener('resize', onResize);
+ return () => {
+ window.removeEventListener('resize', onResize);
+ if (ro && container) ro.disconnect();
+ };
+ }, [resizeCanvas]);
+
+ React.useEffect(() => {
+ if (starsRef.current.length === 0) {
+ initStars(canvasSize.width, canvasSize.height);
+ } else {
+ starsRef.current.forEach((p) => {
+ p.baseOpacity = starsOpacity;
+ p.opacity = starsOpacity;
+ const spd = Math.hypot(p.vx, p.vy);
+ if (spd > 0) {
+ const ratio = movementSpeed / spd;
+ p.vx *= ratio;
+ p.vy *= ratio;
+ }
+ });
+ }
+ }, [
+ starsCount,
+ starsOpacity,
+ movementSpeed,
+ canvasSize.width,
+ canvasSize.height,
+ initStars,
+ ]);
+
+ React.useEffect(() => {
+ if (animRef.current) cancelAnimationFrame(animRef.current);
+ animRef.current = requestAnimationFrame(animate);
+ return () => {
+ if (animRef.current) cancelAnimationFrame(animRef.current);
+ animRef.current = null;
+ };
+ }, [animate]);
+
+ return (
+ handlePointerMove(e)}
+ onTouchMove={(e) => handlePointerMove(e)}
+ {...props}
+ >
+
+
+ );
+}
+
+export { GravityStarsBackground, type GravityStarsProps };
diff --git a/frontend/components/ui/terminal-login.tsx b/frontend/components/ui/terminal-login.tsx
new file mode 100644
index 00000000..e392d052
--- /dev/null
+++ b/frontend/components/ui/terminal-login.tsx
@@ -0,0 +1,313 @@
+"use client"
+
+import * as React from "react"
+import dynamic from "next/dynamic"
+import { cn } from "@/lib/utils"
+
+// Dynamic import to avoid SSR issues with GSAP
+const Shuffle = dynamic(() => import("@/components/Shuffle"), { ssr: false })
+
+type LoginStep = "username" | "password" | "authenticating" | "success" | "error"
+
+interface TerminalLoginTranslations {
+ title: string
+ subtitle: string
+ usernamePrompt: string
+ passwordPrompt: string
+ authenticating: string
+ processing: string
+ accessGranted: string
+ welcomeMessage: string
+ authFailed: string
+ invalidCredentials: string
+}
+
+interface TerminalLine {
+ text: string
+ type: "prompt" | "input" | "info" | "success" | "error" | "warning"
+}
+
+interface TerminalLoginProps {
+ onLogin: (username: string, password: string) => Promise
+ isPending?: boolean
+ className?: string
+ translations: TerminalLoginTranslations
+}
+
+export function TerminalLogin({
+ onLogin,
+ isPending = false,
+ className,
+ translations: t,
+}: TerminalLoginProps) {
+ const [step, setStep] = React.useState("username")
+ const [username, setUsername] = React.useState("")
+ const [password, setPassword] = React.useState("")
+ const [lines, setLines] = React.useState([])
+ const [cursorPosition, setCursorPosition] = React.useState(0)
+ const inputRef = React.useRef(null)
+ const containerRef = React.useRef(null)
+
+ // Focus input on mount and when step changes
+ React.useEffect(() => {
+ inputRef.current?.focus()
+ }, [step])
+
+ // Click anywhere to focus input
+ const handleContainerClick = () => {
+ inputRef.current?.focus()
+ }
+
+ const addLine = (line: TerminalLine) => {
+ setLines((prev) => [...prev, line])
+ }
+
+ const getCurrentValue = () => {
+ if (step === "username") return username
+ if (step === "password") return password
+ return ""
+ }
+
+ const setCurrentValue = (value: string) => {
+ if (step === "username") {
+ setUsername(value)
+ setCursorPosition(value.length)
+ } else if (step === "password") {
+ setPassword(value)
+ setCursorPosition(value.length)
+ }
+ }
+
+ const handleKeyDown = async (e: React.KeyboardEvent) => {
+ const value = getCurrentValue()
+
+ // Ctrl+C - Cancel/Clear current input
+ if (e.ctrlKey && e.key === "c") {
+ e.preventDefault()
+ if (step === "username" || step === "password") {
+ addLine({ text: `^C`, type: "warning" })
+ setCurrentValue("")
+ setCursorPosition(0)
+ }
+ return
+ }
+
+ // Ctrl+U - Clear line (delete from cursor to start)
+ if (e.ctrlKey && e.key === "u") {
+ e.preventDefault()
+ setCurrentValue("")
+ setCursorPosition(0)
+ return
+ }
+
+ // Ctrl+A - Move cursor to start
+ if (e.ctrlKey && e.key === "a") {
+ e.preventDefault()
+ setCursorPosition(0)
+ if (inputRef.current) {
+ inputRef.current.setSelectionRange(0, 0)
+ }
+ return
+ }
+
+ // Ctrl+E - Move cursor to end
+ if (e.ctrlKey && e.key === "e") {
+ e.preventDefault()
+ setCursorPosition(value.length)
+ if (inputRef.current) {
+ inputRef.current.setSelectionRange(value.length, value.length)
+ }
+ return
+ }
+
+ // Ctrl+W - Delete word before cursor
+ if (e.ctrlKey && e.key === "w") {
+ e.preventDefault()
+ const beforeCursor = value.slice(0, cursorPosition)
+ const afterCursor = value.slice(cursorPosition)
+ const lastSpace = beforeCursor.trimEnd().lastIndexOf(" ")
+ const newBefore = lastSpace === -1 ? "" : beforeCursor.slice(0, lastSpace + 1)
+ setCurrentValue(newBefore + afterCursor)
+ setCursorPosition(newBefore.length)
+ return
+ }
+
+ // Enter - Submit
+ if (e.key === "Enter") {
+ if (step === "username") {
+ if (!username.trim()) return
+ addLine({ text: `> ${t.usernamePrompt}: `, type: "prompt" })
+ addLine({ text: username, type: "input" })
+ setStep("password")
+ setCursorPosition(0)
+ } else if (step === "password") {
+ if (!password.trim()) return
+ addLine({ text: `> ${t.passwordPrompt}: `, type: "prompt" })
+ addLine({ text: "*".repeat(password.length), type: "input" })
+ addLine({ text: "", type: "info" })
+ addLine({ text: `> ${t.authenticating}`, type: "warning" })
+ setStep("authenticating")
+
+ try {
+ await onLogin(username, password)
+ addLine({ text: `> ${t.accessGranted}`, type: "success" })
+ addLine({ text: `> ${t.welcomeMessage}`, type: "success" })
+ setStep("success")
+ } catch {
+ addLine({ text: `> ${t.authFailed}`, type: "error" })
+ addLine({ text: `> ${t.invalidCredentials}`, type: "error" })
+ addLine({ text: "", type: "info" })
+ setStep("error")
+ setTimeout(() => {
+ setUsername("")
+ setPassword("")
+ setLines([])
+ setCursorPosition(0)
+ setStep("username")
+ }, 2000)
+ }
+ }
+ return
+ }
+ }
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ const value = e.target.value
+ setCurrentValue(value)
+ setCursorPosition(e.target.selectionStart || value.length)
+ }
+
+ const handleSelect = (e: React.SyntheticEvent) => {
+ const target = e.target as HTMLInputElement
+ setCursorPosition(target.selectionStart || 0)
+ }
+
+ const isInputDisabled = step === "authenticating" || step === "success" || isPending
+
+ const getCurrentPrompt = () => {
+ if (step === "username") return `> ${t.usernamePrompt}: `
+ if (step === "password") return `> ${t.passwordPrompt}: `
+ return "> "
+ }
+
+ const getDisplayValue = () => {
+ if (step === "username") return username
+ if (step === "password") return "*".repeat(password.length)
+ return ""
+ }
+
+ // Render cursor at position
+ const renderInputWithCursor = () => {
+ const displayValue = getDisplayValue()
+ const before = displayValue.slice(0, cursorPosition)
+ const after = displayValue.slice(cursorPosition)
+ const cursorChar = after[0] || ""
+
+ return (
+ <>
+ {before}
+
+ {cursorChar || "\u00A0"}
+
+ {after.slice(1)}
+ >
+ )
+ }
+
+ return (
+
+ {/* Terminal header */}
+
+
+ {/* Terminal content */}
+
+ {/* Shuffle Title Banner */}
+
+
+
+ ─────────── {t.subtitle} ───────────
+
+
+
+ {/* Previous lines */}
+ {lines.map((line, index) => (
+
+ {line.text}
+ {(line.type === "prompt" || line.text === "") ? "" : "\n"}
+
+ ))}
+
+ {/* Current input line */}
+ {(step === "username" || step === "password") && (
+
+ {getCurrentPrompt()}
+ {renderInputWithCursor()}
+
+
+ )}
+
+ {/* Loading indicator */}
+ {step === "authenticating" && (
+
+ {t.processing}
+
+ )}
+
+ {/* Keyboard shortcuts hint */}
+ {(step === "username" || step === "password") && (
+
+ Shortcuts:{" "}
+ Ctrl+C cancel{" "}
+ Ctrl+U clear{" "}
+ Ctrl+A/E start/end
+
+ )}
+
+
+ )
+}
diff --git a/frontend/components/ui/terminal.tsx b/frontend/components/ui/terminal.tsx
new file mode 100644
index 00000000..0b0ce7e1
--- /dev/null
+++ b/frontend/components/ui/terminal.tsx
@@ -0,0 +1,255 @@
+"use client"
+
+import {
+ Children,
+ createContext,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react"
+import { motion, MotionProps, useInView } from "motion/react"
+
+import { cn } from "@/lib/utils"
+
+interface SequenceContextValue {
+ completeItem: (index: number) => void
+ activeIndex: number
+ sequenceStarted: boolean
+}
+
+const SequenceContext = createContext(null)
+
+const useSequence = () => useContext(SequenceContext)
+
+const ItemIndexContext = createContext(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(null)
+ const isInView = useInView(elementRef as React.RefObject, {
+ 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 (
+ {
+ if (!sequence) return
+ if (itemIndex === null) return
+ sequence.completeItem(itemIndex)
+ }}
+ {...props}
+ >
+ {children}
+
+ )
+}
+
+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("")
+ const [started, setStarted] = useState(false)
+ const elementRef = useRef(null)
+ const isInView = useInView(elementRef as React.RefObject, {
+ 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 (
+
+ {displayedText}
+
+ )
+}
+
+interface TerminalProps {
+ children: React.ReactNode
+ className?: string
+ sequence?: boolean
+ startOnView?: boolean
+}
+
+export const Terminal = ({
+ children,
+ className,
+ sequence = true,
+ startOnView = true,
+}: TerminalProps) => {
+ const containerRef = useRef(null)
+ const isInView = useInView(containerRef as React.RefObject, {
+ amount: 0.3,
+ once: true,
+ })
+
+ const [activeIndex, setActiveIndex] = useState(0)
+ const sequenceHasStarted = sequence ? !startOnView || isInView : false
+
+ const contextValue = useMemo(() => {
+ 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) => (
+
+ {child as React.ReactNode}
+
+ ))
+ }, [children, sequence])
+
+ const content = (
+
+
+
+ {wrappedChildren}
+
+
+ )
+
+ if (!sequence) return content
+
+ return (
+
+ {content}
+
+ )
+}
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index b8eff498..46124961 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -536,6 +536,18 @@
"loginFailed": "Login failed",
"logoutSuccess": "Logged out successfully",
"sessionExpired": "Session expired, please login again",
+ "terminal": {
+ "title": "Star Patrol ASM Platform",
+ "subtitle": "Secure Authentication Terminal",
+ "usernamePrompt": "Username",
+ "passwordPrompt": "Password",
+ "authenticating": "Authenticating...",
+ "processing": "Processing...",
+ "accessGranted": "Access granted.",
+ "welcomeMessage": "Welcome back, commander.",
+ "authFailed": "ERROR: Authentication failed.",
+ "invalidCredentials": "Invalid credentials. Please try again."
+ },
"changePassword": {
"title": "Change Password",
"desc": "Enter your current password and new password",
diff --git a/frontend/messages/zh.json b/frontend/messages/zh.json
index d8e5c54b..a3a3d12d 100644
--- a/frontend/messages/zh.json
+++ b/frontend/messages/zh.json
@@ -550,6 +550,18 @@
"loginFailed": "登录失败",
"logoutSuccess": "已退出登录",
"sessionExpired": "会话已过期,请重新登录",
+ "terminal": {
+ "title": "星巡攻击面管理平台",
+ "subtitle": "安全认证终端",
+ "usernamePrompt": "用户名",
+ "passwordPrompt": "密码",
+ "authenticating": "正在认证...",
+ "processing": "处理中...",
+ "accessGranted": "认证成功。",
+ "welcomeMessage": "欢迎回来,指挥官。",
+ "authFailed": "错误:认证失败。",
+ "invalidCredentials": "凭据无效,请重试。"
+ },
"changePassword": {
"title": "修改密码",
"desc": "请输入当前密码和新密码",
diff --git a/frontend/package.json b/frontend/package.json
index bbebcd2f..4207f8c1 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -16,6 +16,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/noto-sans-sc": "^5.2.8",
+ "@gsap/react": "^2.1.2",
"@hookform/resolvers": "^5.2.2",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
@@ -58,14 +59,18 @@
"date-fns": "^4.1.0",
"framer-motion": "^12.23.26",
"geist": "^1.5.1",
+ "gsap": "^3.14.2",
"is-ip": "^5.0.1",
"js-yaml": "^4.1.0",
"lottie-react": "^2.4.1",
"lucide-react": "^0.544.0",
+ "motion": "^12.26.2",
"next": "^15.5.9",
"next-intl": "^4.6.1",
"next-themes": "^0.4.6",
"nextjs-toploader": "^3.9.17",
+ "ogl": "^1.0.11",
+ "postprocessing": "^6.38.2",
"psl": "^1.15.0",
"react": "19.1.2",
"react-day-picker": "^9.11.2",
@@ -76,6 +81,7 @@
"snakecase-keys": "^9.0.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
+ "three": "^0.167.1",
"tldts": "^6.1.86",
"validator": "^13.15.15",
"vaul": "^1.1.2",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 2fa22728..07918c58 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -23,6 +23,9 @@ importers:
'@fontsource/noto-sans-sc':
specifier: ^5.2.8
version: 5.2.8
+ '@gsap/react':
+ specifier: ^2.1.2
+ version: 2.1.2(gsap@3.14.2)(react@19.1.2)
'@hookform/resolvers':
specifier: ^5.2.2
version: 5.2.2(react-hook-form@7.65.0(react@19.1.2))
@@ -149,6 +152,9 @@ importers:
geist:
specifier: ^1.5.1
version: 1.5.1(next@15.5.9(react-dom@19.1.2(react@19.1.2))(react@19.1.2))
+ gsap:
+ specifier: ^3.14.2
+ version: 3.14.2
is-ip:
specifier: ^5.0.1
version: 5.0.1
@@ -161,6 +167,9 @@ importers:
lucide-react:
specifier: ^0.544.0
version: 0.544.0(react@19.1.2)
+ motion:
+ specifier: ^12.26.2
+ version: 12.26.2(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
next:
specifier: ^15.5.9
version: 15.5.9(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
@@ -173,6 +182,12 @@ importers:
nextjs-toploader:
specifier: ^3.9.17
version: 3.9.17(next@15.5.9(react-dom@19.1.2(react@19.1.2))(react@19.1.2))(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
+ ogl:
+ specifier: ^1.0.11
+ version: 1.0.11
+ postprocessing:
+ specifier: ^6.38.2
+ version: 6.38.2(three@0.167.1)
psl:
specifier: ^1.15.0
version: 1.15.0
@@ -203,6 +218,9 @@ importers:
tailwind-merge:
specifier: ^3.3.1
version: 3.3.1
+ three:
+ specifier: ^0.167.1
+ version: 0.167.1
tldts:
specifier: ^6.1.86
version: 6.1.86
@@ -383,6 +401,12 @@ packages:
'@formatjs/intl-localematcher@0.6.2':
resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==}
+ '@gsap/react@2.1.2':
+ resolution: {integrity: sha512-JqliybO1837UcgH2hVOM4VO+38APk3ECNrsuSM4MuXp+rbf+/2IG2K1YJiqfTcXQHH7XlA0m3ykniFYstfq0Iw==}
+ peerDependencies:
+ gsap: ^3.12.5
+ react: '>=17'
+
'@hookform/resolvers@5.2.2':
resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==}
peerDependencies:
@@ -2347,6 +2371,20 @@ packages:
react-dom:
optional: true
+ framer-motion@12.26.2:
+ resolution: {integrity: sha512-lflOQEdjquUi9sCg5Y1LrsZDlsjrHw7m0T9Yedvnk7Bnhqfkc89/Uha10J3CFhkL+TCZVCRw9eUGyM/lyYhXQA==}
+ peerDependencies:
+ '@emotion/is-prop-valid': '*'
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@emotion/is-prop-valid':
+ optional: true
+ react:
+ optional: true
+ react-dom:
+ optional: true
+
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
@@ -2423,6 +2461,9 @@ packages:
resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==}
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
+ gsap@3.14.2:
+ resolution: {integrity: sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==}
+
has-bigints@1.1.0:
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
engines: {node: '>= 0.4'}
@@ -2806,9 +2847,29 @@ packages:
motion-dom@12.23.23:
resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==}
+ motion-dom@12.26.2:
+ resolution: {integrity: sha512-KLMT1BroY8oKNeliA3JMNJ+nbCIsTKg6hJpDb4jtRAJ7nCKnnpg/LTq/NGqG90Limitz3kdAnAVXecdFVGlWTw==}
+
motion-utils@12.23.6:
resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==}
+ motion-utils@12.24.10:
+ resolution: {integrity: sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==}
+
+ motion@12.26.2:
+ resolution: {integrity: sha512-2Q6g0zK1gUJKhGT742DAe42LgietcdiJ3L3OcYAHCQaC1UkLnn6aC8S/obe4CxYTLAgid2asS1QdQ/blYfo5dw==}
+ peerDependencies:
+ '@emotion/is-prop-valid': '*'
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@emotion/is-prop-valid':
+ optional: true
+ react:
+ optional: true
+ react-dom:
+ optional: true
+
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -2928,6 +2989,9 @@ packages:
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
engines: {node: '>= 0.4'}
+ ogl@1.0.11:
+ resolution: {integrity: sha512-kUpC154AFfxi16pmZUK4jk3J+8zxwTWGPo03EoYA8QPbzikHoaC82n6pNTbd+oEaJonaE8aPWBlX7ad9zrqLsA==}
+
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -2991,6 +3055,11 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
+ postprocessing@6.38.2:
+ resolution: {integrity: sha512-7DwuT7Tkst41ZjSj287g7C9c5/D3Xx5rMgBosg0dadbUPoZD2HNzkadKPol1d2PJAoI9f+Jeh1/v9YfLzpFGVw==}
+ peerDependencies:
+ three: '>= 0.157.0 < 0.183.0'
+
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -3315,6 +3384,9 @@ packages:
resolution: {integrity: sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==}
engines: {node: '>=18'}
+ three@0.167.1:
+ resolution: {integrity: sha512-gYTLJA/UQip6J/tJvl91YYqlZF47+D/kxiWrbTon35ZHlXEN0VOo+Qke2walF1/x92v55H6enomymg4Dak52kw==}
+
time-span@5.1.0:
resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==}
engines: {node: '>=12'}
@@ -3660,6 +3732,11 @@ snapshots:
dependencies:
tslib: 2.8.1
+ '@gsap/react@2.1.2(gsap@3.14.2)(react@19.1.2)':
+ dependencies:
+ gsap: 3.14.2
+ react: 19.1.2
+
'@hookform/resolvers@5.2.2(react-hook-form@7.65.0(react@19.1.2))':
dependencies:
'@standard-schema/utils': 0.3.0
@@ -5645,6 +5722,15 @@ snapshots:
react: 19.1.2
react-dom: 19.1.2(react@19.1.2)
+ framer-motion@12.26.2(react-dom@19.1.2(react@19.1.2))(react@19.1.2):
+ dependencies:
+ motion-dom: 12.26.2
+ motion-utils: 12.24.10
+ tslib: 2.8.1
+ optionalDependencies:
+ react: 19.1.2
+ react-dom: 19.1.2(react@19.1.2)
+
function-bind@1.1.2: {}
function-timeout@0.1.1: {}
@@ -5721,6 +5807,8 @@ snapshots:
graphql@16.11.0: {}
+ gsap@3.14.2: {}
+
has-bigints@1.1.0: {}
has-flag@4.0.0: {}
@@ -6068,8 +6156,22 @@ snapshots:
dependencies:
motion-utils: 12.23.6
+ motion-dom@12.26.2:
+ dependencies:
+ motion-utils: 12.24.10
+
motion-utils@12.23.6: {}
+ motion-utils@12.24.10: {}
+
+ motion@12.26.2(react-dom@19.1.2(react@19.1.2))(react@19.1.2):
+ dependencies:
+ framer-motion: 12.26.2(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
+ tslib: 2.8.1
+ optionalDependencies:
+ react: 19.1.2
+ react-dom: 19.1.2(react@19.1.2)
+
ms@2.1.3: {}
msw@2.11.6(@types/node@20.19.19)(typescript@5.9.3):
@@ -6207,6 +6309,8 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
+ ogl@1.0.11: {}
+
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -6266,6 +6370,10 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
+ postprocessing@6.38.2(three@0.167.1):
+ dependencies:
+ three: 0.167.1
+
prelude-ls@1.2.1: {}
prop-types@15.8.1:
@@ -6656,6 +6764,8 @@ snapshots:
minizlib: 3.1.0
yallist: 5.0.0
+ three@0.167.1: {}
+
time-span@5.1.0:
dependencies:
convert-hrtime: 5.0.0