From e699842492be183a638aba7539ffb326f2641d81 Mon Sep 17 00:00:00 2001 From: yyhuni Date: Wed, 14 Jan 2026 11:33:11 +0800 Subject: [PATCH] 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 --- frontend/app/[locale]/login/page.tsx | 37 ++--- frontend/components/PixelBlast.tsx | 80 +++++++++- frontend/components/Shuffle.tsx | 11 +- frontend/components/ui/terminal-login.tsx | 178 +++++++++++++++------- 4 files changed, 228 insertions(+), 78 deletions(-) diff --git a/frontend/app/[locale]/login/page.tsx b/frontend/app/[locale]/login/page.tsx index 4e9b8df2..f3c9f819 100644 --- a/frontend/app/[locale]/login/page.tsx +++ b/frontend/app/[locale]/login/page.tsx @@ -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() { diff --git a/frontend/components/PixelBlast.tsx b/frontend/components/PixelBlast.tsx index bca79ea2..fd46c9e5 100644 --- a/frontend/components/PixelBlast.tsx +++ b/frontend/components/PixelBlast.tsx @@ -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 ( +
+ ); + } + return (
= ({ @@ -56,7 +57,8 @@ const Shuffle: React.FC = ({ 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 = ({ const create = () => { build(); if (scrambleCharset) randomizeScrambles(); - play(); + if (autoPlay) { + play(); + } armHover(); setReady(true); }; @@ -401,7 +405,8 @@ const Shuffle: React.FC = ({ triggerOnce, respectReducedMotion, triggerOnHover, - onShuffleComplete + onShuffleComplete, + autoPlay ], scope: ref } diff --git a/frontend/components/ui/terminal-login.tsx b/frontend/components/ui/terminal-login.tsx index d18c5a24..0a0bbd5c 100644 --- a/frontend/components/ui/terminal-login.tsx +++ b/frontend/components/ui/terminal-login.tsx @@ -251,68 +251,140 @@ export function TerminalLogin({ shuffleTimes={2} triggerOnHover={true} triggerOnce={false} + autoPlay={false} />
─────────── {t.subtitle} ───────────
- {/* Previous lines */} - {lines.map((line, index) => ( - - {line.text} - {(line.type === "prompt" || line.text === "") ? "" : "\n"} - - ))} + {/* ========== Mobile Form ========== */} +
+ {(step === "username" || step === "password" || step === "error") && ( +
{ + 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" + > +
+ + 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" + /> +
+
+ + 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" + /> +
+ {step === "error" && ( +

{t.invalidCredentials}

+ )} + +
+ )} + {step === "authenticating" && ( +
+ {t.processing} +
+ )} + {step === "success" && ( +
+ {t.accessGranted} +
+ )} +
- {/* Current input line */} - {(step === "username" || step === "password") && ( -
- {getCurrentPrompt()} - {renderInputWithCursor()} - -
- )} + {/* ========== Desktop Terminal ========== */} +
+ {/* Previous lines */} + {lines.map((line, index) => ( + + {line.text} + {(line.type === "prompt" || line.text === "") ? "" : "\n"} + + ))} - {/* Loading indicator */} - {step === "authenticating" && ( -
- {t.processing} -
- )} + {/* Current input line */} + {(step === "username" || step === "password") && ( +
+ {getCurrentPrompt()} + {renderInputWithCursor()} + +
+ )} - {/* Keyboard shortcuts hint */} - {(step === "username" || step === "password") && ( -
- {t.shortcuts}:{" "} - Enter {t.submit}{" "} - Ctrl+C {t.cancel}{" "} - Ctrl+U {t.clear}{" "} - Ctrl+A/E {t.startEnd} -
- )} + {/* Loading indicator */} + {step === "authenticating" && ( +
+ {t.processing} +
+ )} + + {/* Keyboard shortcuts hint */} + {(step === "username" || step === "password") && ( +
+ {t.shortcuts}:{" "} + Enter {t.submit}{" "} + Ctrl+C {t.cancel}{" "} + Ctrl+U {t.clear}{" "} + Ctrl+A/E {t.startEnd} +
+ )} +
)