perf(frontend): optimize login page and animations with memoization and accessibility

- Memoize translations object in login page to prevent unnecessary re-renders
- Add support for prefers-reduced-motion media query in PixelBlast component
- Implement IntersectionObserver and Page Visibility API for intelligent animation pausing
- Limit device pixel ratio based on device type (mobile vs desktop) for better performance
- Add maxPixelRatio parameter to PixelBlast for fine-grained performance control
- Add autoPlay prop to Shuffle component for flexible animation control
- Disable autoPlay on Shuffle text animations in terminal login for better UX
- Add accessibility label to PixelBlast container when reduced motion is enabled
- Improve mobile performance by capping pixel ratio to 1.5 on mobile devices
- Respect user accessibility preferences while maintaining visual quality on desktop
This commit is contained in:
yyhuni
2026-01-14 11:33:11 +08:00
parent 08a4807bef
commit e699842492
4 changed files with 228 additions and 78 deletions

View File

@@ -20,6 +20,25 @@ export default function LoginPage() {
const { mutateAsync: login, isPending } = useLogin()
const t = useTranslations("auth.terminal")
// Memoize translations object to avoid recreating on every render
const translations = React.useMemo(() => ({
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"),
shortcuts: t("shortcuts"),
submit: t("submit"),
cancel: t("cancel"),
clear: t("clear"),
startEnd: t("startEnd"),
}), [t])
// If already logged in, redirect to dashboard
React.useEffect(() => {
if (auth?.authenticated) {
@@ -67,23 +86,7 @@ export default function LoginPage() {
<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"),
shortcuts: t("shortcuts"),
submit: t("submit"),
cancel: t("cancel"),
clear: t("clear"),
startEnd: t("startEnd"),
}}
translations={translations}
/>
</div>

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState, useMemo } from 'react';
import * as THREE from 'three';
import { EffectComposer, EffectPass, RenderPass, Effect } from 'postprocessing';
import './PixelBlast.css';
@@ -323,15 +323,71 @@ const PixelBlast = ({
speed = 0.5,
transparent = true,
edgeFade = 0.5,
noiseAmount = 0
noiseAmount = 0,
respectReducedMotion = true,
maxPixelRatio = 2
}) => {
const containerRef = useRef(null);
const visibilityRef = useRef({ visible: true });
const speedRef = useRef(speed);
const threeRef = useRef(null);
const prevConfigRef = useRef(null);
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
// Limit pixel ratio for performance (lower on mobile)
const effectivePixelRatio = useMemo(() => {
if (typeof window === 'undefined') return 1;
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
const dpr = window.devicePixelRatio || 1;
if (isMobile) return Math.min(dpr, 1.5, maxPixelRatio);
return Math.min(dpr, maxPixelRatio);
}, [maxPixelRatio]);
// Check for prefers-reduced-motion
useEffect(() => {
if (!respectReducedMotion) return;
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
setPrefersReducedMotion(mq.matches);
const handler = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, [respectReducedMotion]);
// Pause animation when page is not visible or element is offscreen
useEffect(() => {
if (!autoPauseOffscreen || prefersReducedMotion) return;
const container = containerRef.current;
if (!container) return;
// IntersectionObserver for offscreen detection
const io = new IntersectionObserver(
([entry]) => {
visibilityRef.current.visible = entry.isIntersecting;
},
{ threshold: 0 }
);
io.observe(container);
// Page Visibility API
const handleVisibility = () => {
if (document.hidden) {
visibilityRef.current.visible = false;
}
};
document.addEventListener('visibilitychange', handleVisibility);
return () => {
io.disconnect();
document.removeEventListener('visibilitychange', handleVisibility);
};
}, [autoPauseOffscreen, prefersReducedMotion]);
// Main WebGL setup effect
useEffect(() => {
// Skip WebGL setup if user prefers reduced motion
if (prefersReducedMotion) return;
const container = containerRef.current;
if (!container) return;
speedRef.current = speed;
@@ -367,7 +423,7 @@ const PixelBlast = ({
});
renderer.domElement.style.width = '100%';
renderer.domElement.style.height = '100%';
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setPixelRatio(effectivePixelRatio);
container.appendChild(renderer.domElement);
if (transparent) renderer.setClearAlpha(0);
else renderer.setClearColor(0x000000, 1);
@@ -591,9 +647,23 @@ const PixelBlast = ({
autoPauseOffscreen,
variant,
color,
speed
speed,
prefersReducedMotion,
effectivePixelRatio
]);
// Render empty container if user prefers reduced motion
if (prefersReducedMotion) {
return (
<div
ref={containerRef}
className={`pixel-blast-container ${className ?? ''}`}
style={{ ...style, backgroundColor: 'transparent' }}
aria-label="PixelBlast background (disabled for reduced motion)"
/>
);
}
return (
<div
ref={containerRef}

View File

@@ -31,6 +31,7 @@ interface ShuffleProps {
triggerOnce?: boolean;
respectReducedMotion?: boolean;
triggerOnHover?: boolean;
autoPlay?: boolean;
}
const Shuffle: React.FC<ShuffleProps> = ({
@@ -56,7 +57,8 @@ const Shuffle: React.FC<ShuffleProps> = ({
colorTo,
triggerOnce = true,
respectReducedMotion = true,
triggerOnHover = true
triggerOnHover = true,
autoPlay = true
}) => {
const ref = useRef(null);
const [fontsLoaded, setFontsLoaded] = useState(false);
@@ -362,7 +364,9 @@ const Shuffle: React.FC<ShuffleProps> = ({
const create = () => {
build();
if (scrambleCharset) randomizeScrambles();
play();
if (autoPlay) {
play();
}
armHover();
setReady(true);
};
@@ -401,7 +405,8 @@ const Shuffle: React.FC<ShuffleProps> = ({
triggerOnce,
respectReducedMotion,
triggerOnHover,
onShuffleComplete
onShuffleComplete,
autoPlay
],
scope: ref
}

View File

@@ -251,68 +251,140 @@ export function TerminalLogin({
shuffleTimes={2}
triggerOnHover={true}
triggerOnce={false}
autoPlay={false}
/>
<div className="text-zinc-400 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-zinc-100",
line.type === "info" && "text-zinc-500",
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>
))}
{/* ========== Mobile Form ========== */}
<div className="sm:hidden">
{(step === "username" || step === "password" || step === "error") && (
<form
onSubmit={async (e) => {
e.preventDefault()
if (!username.trim() || !password.trim()) return
setStep("authenticating")
try {
await onLogin(username, password)
setStep("success")
} catch {
setStep("error")
setTimeout(() => {
setUsername("")
setPassword("")
setStep("username")
}, 2000)
}
}}
className="space-y-4"
>
<div>
<label className="text-green-500 text-xs mb-1 block">{t.usernamePrompt}</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={isInputDisabled}
className="w-full bg-zinc-800 border border-zinc-600 rounded px-3 py-2 text-zinc-100 outline-none focus:border-green-500 font-mono text-sm"
autoComplete="username"
/>
</div>
<div>
<label className="text-green-500 text-xs mb-1 block">{t.passwordPrompt}</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isInputDisabled}
className="w-full bg-zinc-800 border border-zinc-600 rounded px-3 py-2 text-zinc-100 outline-none focus:border-green-500 font-mono text-sm"
autoComplete="current-password"
/>
</div>
{step === "error" && (
<p className="text-red-500 text-sm">{t.invalidCredentials}</p>
)}
<button
type="submit"
disabled={isInputDisabled}
className="w-full py-2 px-4 bg-green-600 hover:bg-green-500 disabled:opacity-50 text-black font-mono text-sm rounded transition-colors"
>
{t.submit}
</button>
</form>
)}
{step === "authenticating" && (
<div className="text-yellow-500 text-center py-4">
<span className="animate-pulse">{t.processing}</span>
</div>
)}
{step === "success" && (
<div className="text-green-500 text-center py-4">
{t.accessGranted}
</div>
)}
</div>
{/* 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>
)}
{/* ========== Desktop Terminal ========== */}
<div className="hidden sm:block">
{/* Previous lines */}
{lines.map((line, index) => (
<span
key={index}
className={cn(
"whitespace-pre-wrap",
line.type === "prompt" && "text-green-500",
line.type === "input" && "text-zinc-100",
line.type === "info" && "text-zinc-500",
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>
))}
{/* Loading indicator */}
{step === "authenticating" && (
<div className="flex items-center text-yellow-500">
<span className="animate-pulse">{t.processing}</span>
</div>
)}
{/* 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>
)}
{/* Keyboard shortcuts hint */}
{(step === "username" || step === "password") && (
<div className="mt-6 text-xs text-zinc-600">
<span className="text-zinc-500">{t.shortcuts}:</span>{" "}
<span className="text-cyan-600">Enter</span> {t.submit}{" "}
<span className="text-cyan-600">Ctrl+C</span> {t.cancel}{" "}
<span className="text-cyan-600">Ctrl+U</span> {t.clear}{" "}
<span className="text-cyan-600">Ctrl+A/E</span> {t.startEnd}
</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-zinc-600">
<span className="text-zinc-500">{t.shortcuts}:</span>{" "}
<span className="text-cyan-600">Enter</span> {t.submit}{" "}
<span className="text-cyan-600">Ctrl+C</span> {t.cancel}{" "}
<span className="text-cyan-600">Ctrl+U</span> {t.clear}{" "}
<span className="text-cyan-600">Ctrl+A/E</span> {t.startEnd}
</div>
)}
</div>
</div>
</div>
)