mirror of
https://github.com/yyhuni/xingrin.git
synced 2026-01-31 11:46:16 +08:00
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:
@@ -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>
|
||||
|
||||
@@ -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% {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
6
frontend/components/FaultyTerminal.css
Normal file
6
frontend/components/FaultyTerminal.css
Normal file
@@ -0,0 +1,6 @@
|
||||
.faulty-terminal-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
400
frontend/components/FaultyTerminal.tsx
Normal file
400
frontend/components/FaultyTerminal.tsx
Normal 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} />;
|
||||
}
|
||||
6
frontend/components/PixelBlast.css
Normal file
6
frontend/components/PixelBlast.css
Normal file
@@ -0,0 +1,6 @@
|
||||
.pixel-blast-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
607
frontend/components/PixelBlast.tsx
Normal file
607
frontend/components/PixelBlast.tsx
Normal 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;
|
||||
30
frontend/components/Shuffle.css
Normal file
30
frontend/components/Shuffle.css
Normal 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;
|
||||
}
|
||||
418
frontend/components/Shuffle.tsx
Normal file
418
frontend/components/Shuffle.tsx
Normal 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;
|
||||
@@ -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 };
|
||||
313
frontend/components/ui/terminal-login.tsx
Normal file
313
frontend/components/ui/terminal-login.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
255
frontend/components/ui/terminal.tsx
Normal file
255
frontend/components/ui/terminal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -550,6 +550,18 @@
|
||||
"loginFailed": "登录失败",
|
||||
"logoutSuccess": "已退出登录",
|
||||
"sessionExpired": "会话已过期,请重新登录",
|
||||
"terminal": {
|
||||
"title": "星巡攻击面管理平台",
|
||||
"subtitle": "安全认证终端",
|
||||
"usernamePrompt": "用户名",
|
||||
"passwordPrompt": "密码",
|
||||
"authenticating": "正在认证...",
|
||||
"processing": "处理中...",
|
||||
"accessGranted": "认证成功。",
|
||||
"welcomeMessage": "欢迎回来,指挥官。",
|
||||
"authFailed": "错误:认证失败。",
|
||||
"invalidCredentials": "凭据无效,请重试。"
|
||||
},
|
||||
"changePassword": {
|
||||
"title": "修改密码",
|
||||
"desc": "请输入当前密码和新密码",
|
||||
|
||||
@@ -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
110
frontend/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user