From 191ff9837b4994e97e423c276547f9a6dea405f4 Mon Sep 17 00:00:00 2001 From: yyhuni Date: Wed, 14 Jan 2026 10:48:41 +0800 Subject: [PATCH] feat(frontend): redesign login page with terminal UI and pixel blast animation - Replace traditional card-based login form with immersive terminal-style interface - Add PixelBlast animated background component for cyberpunk aesthetic - Implement TerminalLogin component with typewriter and terminal effects - Add new animation components: FaultyTerminal, PixelBlast, Shuffle with CSS modules - Add gravity-stars background animation component from animate-ui - Add terminal cursor blink animation to global styles - Update login page translations to support terminal UI prompts and messages - Replace Lottie animation with dynamic WebGL-based PixelBlast component - Add dynamic imports to prevent SSR issues with WebGL rendering - Update component registry to include @magicui and @react-bits registries - Refactor login form state management to use async/await pattern - Add fingerprint meta tag for search engine identification (FOFA/Shodan) - Improve visual hierarchy with relative z-index layering for background and content --- frontend/app/[locale]/login/page.tsx | 122 ++-- frontend/app/globals.css | 14 + frontend/components.json | 4 +- frontend/components/FaultyTerminal.css | 6 + frontend/components/FaultyTerminal.tsx | 400 ++++++++++++ frontend/components/PixelBlast.css | 6 + frontend/components/PixelBlast.tsx | 607 ++++++++++++++++++ frontend/components/Shuffle.css | 30 + frontend/components/Shuffle.tsx | 418 ++++++++++++ .../components/backgrounds/gravity-stars.tsx | 360 +++++++++++ frontend/components/ui/terminal-login.tsx | 313 +++++++++ frontend/components/ui/terminal.tsx | 255 ++++++++ frontend/messages/en.json | 12 + frontend/messages/zh.json | 12 + frontend/package.json | 6 + frontend/pnpm-lock.yaml | 110 ++++ 16 files changed, 2595 insertions(+), 80 deletions(-) create mode 100644 frontend/components/FaultyTerminal.css create mode 100644 frontend/components/FaultyTerminal.tsx create mode 100644 frontend/components/PixelBlast.css create mode 100644 frontend/components/PixelBlast.tsx create mode 100644 frontend/components/Shuffle.css create mode 100644 frontend/components/Shuffle.tsx create mode 100644 frontend/components/animate-ui/components/backgrounds/gravity-stars.tsx create mode 100644 frontend/components/ui/terminal-login.tsx create mode 100644 frontend/components/ui/terminal.tsx diff --git a/frontend/app/[locale]/login/page.tsx b/frontend/app/[locale]/login/page.tsx index 4fd6d541..7fc16561 100644 --- a/frontend/app/[locale]/login/page.tsx +++ b/frontend/app/[locale]/login/page.tsx @@ -3,30 +3,22 @@ import React from "react" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" -import Lottie from "lottie-react" -import securityAnimation from "@/public/animations/Security000-Purple.json" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Card, CardContent } from "@/components/ui/card" -import { - Field, - FieldGroup, - FieldLabel, -} from "@/components/ui/field" +import dynamic from "next/dynamic" import { Spinner } from "@/components/ui/spinner" +import { TerminalLogin } from "@/components/ui/terminal-login" import { useLogin, useAuth } from "@/hooks/use-auth" import { useRoutePrefetch } from "@/hooks/use-route-prefetch" +// Dynamic import to avoid SSR issues with WebGL +const PixelBlast = dynamic(() => import("@/components/PixelBlast"), { ssr: false }) + export default function LoginPage() { // Preload all page components on login page useRoutePrefetch() const router = useRouter() const { data: auth, isLoading: authLoading } = useAuth() - const { mutate: login, isPending } = useLogin() - const t = useTranslations("auth") - - const [username, setUsername] = React.useState("") - const [password, setPassword] = React.useState("") + const { mutateAsync: login, isPending } = useLogin() + const t = useTranslations("auth.terminal") // If already logged in, redirect to dashboard React.useEffect(() => { @@ -35,9 +27,8 @@ export default function LoginPage() { } }, [auth, router]) - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault() - login({ username, password }) + const handleLogin = async (username: string, password: string) => { + await login({ username, password }) } // Show spinner while loading @@ -56,70 +47,43 @@ export default function LoginPage() { } return ( -
- {/* Main content area */} -
-
- - -
- - {/* Fingerprint identifier - for FOFA/Shodan and other search engines to identify */} - -
-

{t("title")}

-

- {t("subtitle")} -

-
- - {t("username")} - setUsername(e.target.value)} - required - autoFocus - /> - - - {t("password")} - setPassword(e.target.value)} - required - /> - - - - -
-
-
-
- -
-
-
-
-
+
+
+
- + + {/* 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 */} +
+
+
+
+
+
+ {t.title} +
+ + {/* 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