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
This commit is contained in:
yyhuni
2026-01-14 10:48:41 +08:00
parent 679dff9037
commit 191ff9837b
16 changed files with 2595 additions and 80 deletions

View File

@@ -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 (
<div className="login-bg flex min-h-svh flex-col p-6 md:p-10">
{/* Main content area */}
<div className="flex-1 flex items-center justify-center">
<div className="w-full max-w-sm md:max-w-4xl">
<Card className="overflow-hidden p-0">
<CardContent className="grid p-0 md:grid-cols-2">
<form className="p-6 md:p-8" onSubmit={handleSubmit}>
<FieldGroup>
{/* Fingerprint identifier - for FOFA/Shodan and other search engines to identify */}
<meta name="generator" content="Star Patrol ASM Platform" />
<div className="flex flex-col items-center gap-2 text-center">
<h1 className="text-2xl font-bold">{t("title")}</h1>
<p className="text-sm text-muted-foreground mt-1">
{t("subtitle")}
</p>
</div>
<Field>
<FieldLabel htmlFor="username">{t("username")}</FieldLabel>
<Input
id="username"
type="text"
placeholder={t("usernamePlaceholder")}
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
/>
</Field>
<Field>
<FieldLabel htmlFor="password">{t("password")}</FieldLabel>
<Input
id="password"
type="password"
placeholder={t("passwordPlaceholder")}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</Field>
<Field>
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? t("loggingIn") : t("login")}
</Button>
</Field>
</FieldGroup>
</form>
<div className="bg-primary/5 relative hidden md:flex md:items-center md:justify-center">
<div className="text-center p-4">
<Lottie
animationData={securityAnimation}
loop={true}
className="w-96 h-96 mx-auto"
/>
</div>
</div>
</CardContent>
</Card>
</div>
<div className="relative flex min-h-svh flex-col bg-black">
<div className="fixed inset-0 z-0">
<PixelBlast
style={{}}
pixelSize={6}
patternScale={4.5}
color="#06b6d4"
/>
</div>
{/* Fingerprint identifier - for FOFA/Shodan and other search engines to identify */}
<meta name="generator" content="Star Patrol ASM Platform" />
{/* Main content area */}
<div className="relative z-10 flex-1 flex items-center justify-center p-6">
<TerminalLogin
onLogin={handleLogin}
isPending={isPending}
translations={{
title: t("title"),
subtitle: t("subtitle"),
usernamePrompt: t("usernamePrompt"),
passwordPrompt: t("passwordPrompt"),
authenticating: t("authenticating"),
processing: t("processing"),
accessGranted: t("accessGranted"),
welcomeMessage: t("welcomeMessage"),
authFailed: t("authFailed"),
invalidCredentials: t("invalidCredentials"),
}}
/>
</div>
{/* Version number - fixed at the bottom of the page */}
<div className="flex-shrink-0 text-center py-4">
<div className="relative z-10 flex-shrink-0 text-center py-4">
<p className="text-xs text-muted-foreground">
{process.env.NEXT_PUBLIC_VERSION || 'dev'}
{process.env.NEXT_PUBLIC_VERSION || "dev"}
</p>
</div>
</div>

View File

@@ -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% {

View File

@@ -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"
}
}

View File

@@ -0,0 +1,6 @@
.faulty-terminal-container {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}

View File

@@ -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 <div ref={containerRef} className={`faulty-terminal-container ${className}`} style={style} {...rest} />;
}

View File

@@ -0,0 +1,6 @@
.pixel-blast-container {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}

View File

@@ -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 (
<div
ref={containerRef}
className={`pixel-blast-container ${className ?? ''}`}
style={style}
aria-label="PixelBlast interactive background"
/>
);
};
export default PixelBlast;

View File

@@ -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;
}

View File

@@ -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<ShuffleProps> = ({
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;

View File

@@ -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<HTMLDivElement | null>(null);
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
const animRef = React.useRef<number | null>(null);
const starsRef = React.useRef<Particle[]>([]);
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 (
<div
ref={containerRef}
data-slot="gravity-stars-background"
className={cn('relative size-full overflow-hidden', className)}
onMouseMove={(e) => handlePointerMove(e)}
onTouchMove={(e) => handlePointerMove(e)}
{...props}
>
<canvas ref={canvasRef} className="block w-full h-full" />
</div>
);
}
export { GravityStarsBackground, type GravityStarsProps };

View File

@@ -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<void>
isPending?: boolean
className?: string
translations: TerminalLoginTranslations
}
export function TerminalLogin({
onLogin,
isPending = false,
className,
translations: t,
}: TerminalLoginProps) {
const [step, setStep] = React.useState<LoginStep>("username")
const [username, setUsername] = React.useState("")
const [password, setPassword] = React.useState("")
const [lines, setLines] = React.useState<TerminalLine[]>([])
const [cursorPosition, setCursorPosition] = React.useState(0)
const inputRef = React.useRef<HTMLInputElement>(null)
const containerRef = React.useRef<HTMLDivElement>(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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
const value = e.target.value
setCurrentValue(value)
setCursorPosition(e.target.selectionStart || value.length)
}
const handleSelect = (e: React.SyntheticEvent<HTMLInputElement>) => {
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 (
<>
<span className="text-foreground">{before}</span>
<span className="animate-blink inline-block min-w-[0.6em] bg-green-500 text-black">
{cursorChar || "\u00A0"}
</span>
<span className="text-foreground">{after.slice(1)}</span>
</>
)
}
return (
<div
ref={containerRef}
onClick={handleContainerClick}
className={cn(
"border-border bg-background/80 backdrop-blur-sm z-0 w-full max-w-2xl rounded-xl border cursor-text",
className
)}
>
{/* Terminal header */}
<div className="border-border flex items-center gap-x-2 border-b px-4 py-3">
<div className="flex flex-row gap-x-2">
<div className="h-3 w-3 rounded-full bg-red-500"></div>
<div className="h-3 w-3 rounded-full bg-yellow-500"></div>
<div className="h-3 w-3 rounded-full bg-green-500"></div>
</div>
<span className="ml-2 text-xs text-muted-foreground font-mono">{t.title}</span>
</div>
{/* Terminal content */}
<div className="p-4 font-mono text-sm min-h-[280px]">
{/* Shuffle Title Banner */}
<div className="mb-6 text-center">
<Shuffle
text="STAR PATROL"
className="!text-4xl sm:!text-5xl md:!text-6xl !font-bold text-cyan-500"
shuffleDirection="up"
duration={0.5}
stagger={0.04}
shuffleTimes={2}
triggerOnHover={true}
triggerOnce={false}
/>
<div className="text-muted-foreground text-sm mt-3">
{t.subtitle}
</div>
</div>
{/* Previous lines */}
{lines.map((line, index) => (
<span
key={index}
className={cn(
"whitespace-pre-wrap",
line.type === "prompt" && "text-green-500",
line.type === "input" && "text-foreground",
line.type === "info" && "text-muted-foreground",
line.type === "success" && "text-green-500",
line.type === "error" && "text-red-500",
line.type === "warning" && "text-yellow-500"
)}
>
{line.text}
{(line.type === "prompt" || line.text === "") ? "" : "\n"}
</span>
))}
{/* Current input line */}
{(step === "username" || step === "password") && (
<div className="flex items-center">
<span className="text-green-500">{getCurrentPrompt()}</span>
{renderInputWithCursor()}
<input
ref={inputRef}
type={step === "password" ? "password" : "text"}
value={getCurrentValue()}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onSelect={handleSelect}
disabled={isInputDisabled}
className="absolute opacity-0 pointer-events-none"
autoComplete={step === "username" ? "username" : "current-password"}
autoFocus
/>
</div>
)}
{/* Loading indicator */}
{step === "authenticating" && (
<div className="flex items-center text-yellow-500">
<span className="animate-pulse">{t.processing}</span>
</div>
)}
{/* Keyboard shortcuts hint */}
{(step === "username" || step === "password") && (
<div className="mt-6 text-xs text-muted-foreground/50">
<span className="text-muted-foreground/70">Shortcuts:</span>{" "}
<span className="text-cyan-500/50">Ctrl+C</span> cancel{" "}
<span className="text-cyan-500/50">Ctrl+U</span> clear{" "}
<span className="text-cyan-500/50">Ctrl+A/E</span> start/end
</div>
)}
</div>
</div>
)
}

View File

@@ -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<SequenceContextValue | null>(null)
const useSequence = () => useContext(SequenceContext)
const ItemIndexContext = createContext<number | null>(null)
const useItemIndex = () => useContext(ItemIndexContext)
interface AnimatedSpanProps extends MotionProps {
children: React.ReactNode
delay?: number
className?: string
startOnView?: boolean
}
export const AnimatedSpan = ({
children,
delay = 0,
className,
startOnView = false,
...props
}: AnimatedSpanProps) => {
const elementRef = useRef<HTMLDivElement | null>(null)
const isInView = useInView(elementRef as React.RefObject<Element>, {
amount: 0.3,
once: true,
})
const sequence = useSequence()
const itemIndex = useItemIndex()
const [hasStarted, setHasStarted] = useState(false)
useEffect(() => {
if (!sequence || itemIndex === null) return
if (!sequence.sequenceStarted) return
if (hasStarted) return
if (sequence.activeIndex === itemIndex) {
setHasStarted(true)
}
}, [sequence?.activeIndex, sequence?.sequenceStarted, hasStarted, itemIndex])
const shouldAnimate = sequence ? hasStarted : startOnView ? isInView : true
return (
<motion.div
ref={elementRef}
initial={{ opacity: 0, y: -5 }}
animate={shouldAnimate ? { opacity: 1, y: 0 } : { opacity: 0, y: -5 }}
transition={{ duration: 0.3, delay: sequence ? 0 : delay / 1000 }}
className={cn("grid text-sm font-normal tracking-tight", className)}
onAnimationComplete={() => {
if (!sequence) return
if (itemIndex === null) return
sequence.completeItem(itemIndex)
}}
{...props}
>
{children}
</motion.div>
)
}
interface TypingAnimationProps extends MotionProps {
children: string
className?: string
duration?: number
delay?: number
as?: React.ElementType
startOnView?: boolean
}
export const TypingAnimation = ({
children,
className,
duration = 60,
delay = 0,
as: Component = "span",
startOnView = true,
...props
}: TypingAnimationProps) => {
if (typeof children !== "string") {
throw new Error("TypingAnimation: children must be a string. Received:")
}
const MotionComponent = useMemo(
() =>
motion.create(Component, {
forwardMotionProps: true,
}),
[Component]
)
const [displayedText, setDisplayedText] = useState<string>("")
const [started, setStarted] = useState(false)
const elementRef = useRef<HTMLElement | null>(null)
const isInView = useInView(elementRef as React.RefObject<Element>, {
amount: 0.3,
once: true,
})
const sequence = useSequence()
const itemIndex = useItemIndex()
useEffect(() => {
if (sequence && itemIndex !== null) {
if (!sequence.sequenceStarted) return
if (started) return
if (sequence.activeIndex === itemIndex) {
setStarted(true)
}
return
}
if (!startOnView) {
const startTimeout = setTimeout(() => setStarted(true), delay)
return () => clearTimeout(startTimeout)
}
if (!isInView) return
const startTimeout = setTimeout(() => setStarted(true), delay)
return () => clearTimeout(startTimeout)
}, [
delay,
startOnView,
isInView,
started,
sequence?.activeIndex,
sequence?.sequenceStarted,
itemIndex,
])
useEffect(() => {
if (!started) return
let i = 0
const typingEffect = setInterval(() => {
if (i < children.length) {
setDisplayedText(children.substring(0, i + 1))
i++
} else {
clearInterval(typingEffect)
if (sequence && itemIndex !== null) {
sequence.completeItem(itemIndex)
}
}
}, duration)
return () => {
clearInterval(typingEffect)
}
}, [children, duration, started])
return (
<MotionComponent
ref={elementRef}
className={cn("text-sm font-normal tracking-tight", className)}
{...props}
>
{displayedText}
</MotionComponent>
)
}
interface TerminalProps {
children: React.ReactNode
className?: string
sequence?: boolean
startOnView?: boolean
}
export const Terminal = ({
children,
className,
sequence = true,
startOnView = true,
}: TerminalProps) => {
const containerRef = useRef<HTMLDivElement | null>(null)
const isInView = useInView(containerRef as React.RefObject<Element>, {
amount: 0.3,
once: true,
})
const [activeIndex, setActiveIndex] = useState(0)
const sequenceHasStarted = sequence ? !startOnView || isInView : false
const contextValue = useMemo<SequenceContextValue | null>(() => {
if (!sequence) return null
return {
completeItem: (index: number) => {
setActiveIndex((current) => (index === current ? current + 1 : current))
},
activeIndex,
sequenceStarted: sequenceHasStarted,
}
}, [sequence, activeIndex, sequenceHasStarted])
const wrappedChildren = useMemo(() => {
if (!sequence) return children
const array = Children.toArray(children)
return array.map((child, index) => (
<ItemIndexContext.Provider key={index} value={index}>
{child as React.ReactNode}
</ItemIndexContext.Provider>
))
}, [children, sequence])
const content = (
<div
ref={containerRef}
className={cn(
"border-border bg-background z-0 h-full max-h-[400px] w-full max-w-lg rounded-xl border",
className
)}
>
<div className="border-border flex flex-col gap-y-2 border-b p-4">
<div className="flex flex-row gap-x-2">
<div className="h-2 w-2 rounded-full bg-red-500"></div>
<div className="h-2 w-2 rounded-full bg-yellow-500"></div>
<div className="h-2 w-2 rounded-full bg-green-500"></div>
</div>
</div>
<pre className="p-4">
<code className="grid gap-y-1 overflow-auto">{wrappedChildren}</code>
</pre>
</div>
)
if (!sequence) return content
return (
<SequenceContext.Provider value={contextValue}>
{content}
</SequenceContext.Provider>
)
}

View File

@@ -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",

View File

@@ -550,6 +550,18 @@
"loginFailed": "登录失败",
"logoutSuccess": "已退出登录",
"sessionExpired": "会话已过期,请重新登录",
"terminal": {
"title": "星巡攻击面管理平台",
"subtitle": "安全认证终端",
"usernamePrompt": "用户名",
"passwordPrompt": "密码",
"authenticating": "正在认证...",
"processing": "处理中...",
"accessGranted": "认证成功。",
"welcomeMessage": "欢迎回来,指挥官。",
"authFailed": "错误:认证失败。",
"invalidCredentials": "凭据无效,请重试。"
},
"changePassword": {
"title": "修改密码",
"desc": "请输入当前密码和新密码",

View File

@@ -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",

110
frontend/pnpm-lock.yaml generated
View File

@@ -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