diff --git a/src/app/components/curvedLoop.tsx b/src/app/components/curvedLoop.tsx new file mode 100644 index 0000000..07b22e4 --- /dev/null +++ b/src/app/components/curvedLoop.tsx @@ -0,0 +1,153 @@ +'use client' + +import { useRef, useEffect, useState, useMemo, useId, FC, PointerEvent } from 'react'; + +interface CurvedLoopProps { + marqueeText?: string; + speed?: number; + className?: string; + curveAmount?: number; + direction?: 'left' | 'right'; + interactive?: boolean; +} + +const CurvedLoop: FC = ({ + marqueeText = '', + speed = 2, + className, + curveAmount = -50, + direction = 'left', + interactive = true +}) => { + const text = useMemo(() => { + const hasTrailing = /\s|\u00A0$/.test(marqueeText); + return (hasTrailing ? marqueeText.replace(/\s+$/, '') : marqueeText) + '\u00A0'; + }, [marqueeText]); + + const measureRef = useRef(null); + const textPathRef = useRef(null); + const pathRef = useRef(null); + const [spacing, setSpacing] = useState(0); + const [offset, setOffset] = useState(0); + const uid = useId(); + const pathId = `curve-${uid}`; + const pathD = `M-100,40 Q500,${40 + curveAmount} 1540,40`; + + const dragRef = useRef(false); + const lastXRef = useRef(0); + const dirRef = useRef<'left' | 'right'>(direction); + const velRef = useRef(0); + + const textLength = spacing; + const totalText = textLength + ? Array(Math.ceil(1800 / textLength) + 2) + .fill(text) + .join('') + : text; + const ready = spacing > 0; + + useEffect(() => { + if (measureRef.current) setSpacing(measureRef.current.getComputedTextLength()); + }, [text, className]); + + useEffect(() => { + if (!spacing) return; + if (textPathRef.current) { + const initial = -spacing; + textPathRef.current.setAttribute('startOffset', initial + 'px'); + setOffset(initial); + } + }, [spacing]); + + useEffect(() => { + if (!spacing || !ready) return; + let frame = 0; + const step = () => { + if (!dragRef.current && textPathRef.current) { + const delta = dirRef.current === 'right' ? speed : -speed; + const currentOffset = parseFloat(textPathRef.current.getAttribute('startOffset') || '0'); + let newOffset = currentOffset + delta; + const wrapPoint = spacing; + if (newOffset <= -wrapPoint) newOffset += wrapPoint; + if (newOffset > 0) newOffset -= wrapPoint; + textPathRef.current.setAttribute('startOffset', newOffset + 'px'); + setOffset(newOffset); + } + frame = requestAnimationFrame(step); + }; + frame = requestAnimationFrame(step); + return () => cancelAnimationFrame(frame); + }, [spacing, speed, ready]); + + const onPointerDown = (e: PointerEvent) => { + if (!interactive) return; + dragRef.current = true; + lastXRef.current = e.clientX; + velRef.current = 0; + (e.target as HTMLElement).setPointerCapture(e.pointerId); + }; + + const onPointerMove = (e: PointerEvent) => { + if (!interactive || !dragRef.current || !textPathRef.current) return; + const dx = e.clientX - lastXRef.current; + lastXRef.current = e.clientX; + velRef.current = dx; + const currentOffset = parseFloat(textPathRef.current.getAttribute('startOffset') || '0'); + let newOffset = currentOffset + dx; + const wrapPoint = spacing; + if (newOffset <= -wrapPoint) newOffset += wrapPoint; + if (newOffset > 0) newOffset -= wrapPoint; + textPathRef.current.setAttribute('startOffset', newOffset + 'px'); + setOffset(newOffset); + }; + + const endDrag = () => { + if (!interactive) return; + dragRef.current = false; + dirRef.current = velRef.current > 0 ? 'right' : 'left'; + }; + + const cursorStyle = interactive ? (dragRef.current ? 'grabbing' : 'grab') : 'auto'; + + return ( +
+ + + {text} + + + + + {ready && ( + + + {totalText} + + + )} + +
+ ); +}; + +export default CurvedLoop; diff --git a/src/app/components/nav/Header.tsx b/src/app/components/nav/Header.tsx index 3a5f846..8d7dba0 100644 --- a/src/app/components/nav/Header.tsx +++ b/src/app/components/nav/Header.tsx @@ -68,7 +68,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) { const refreshAuthToken = useAuthStore(s => s.refreshAuthToken) const router = useRouter() const pathname = usePathname() - const isHome = pathname === '/' + const isParallaxPage = pathname === '/' || pathname === '/login' const [hasReferralPerm, setHasReferralPerm] = useState(false) const [adminMgmtOpen, setAdminMgmtOpen] = useState(false) @@ -119,12 +119,12 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) { setAnimateIn(true) }, []) - // Home-page scroll listener: reveal header after first scroll with slight parallax + // Home + login scroll listener: reveal header after first scroll with slight parallax useEffect(() => { if (!mounted) return - if (!isHome) { - // non-home: header always visible, no scroll listeners + if (!isParallaxPage) { + // non-parallax pages: header always visible, no scroll listeners setScrollY(100) return } @@ -149,7 +149,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) { window.removeEventListener('scroll', handleScroll) window.removeEventListener('wheel', handleWheel) } - }, [mounted, isHome]) + }, [mounted, isParallaxPage]) // Fetch user permissions and set hasReferralPerm useEffect(() => { @@ -310,13 +310,13 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) { const isAdmin = mounted && rawIsAdmin // Only gate visibility by scroll on home; elsewhere just use animateIn - const headerVisible = isHome ? animateIn && scrollY > 24 : animateIn - const parallaxOffset = isHome ? Math.max(-16, -scrollY * 0.15) : 0 + const headerVisible = isParallaxPage ? animateIn && scrollY > 24 : animateIn + const parallaxOffset = isParallaxPage ? Math.max(-16, -scrollY * 0.15) : 0 return (