Deepfake Authenticator
feat: replace vanilla frontend with React + Vite UI
70348ce
import { useEffect, useRef, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { gsap } from 'gsap';
import './PillNav.css';
export interface PillNavItem {
label: string;
href?: string;
ariaLabel?: string;
onClick?: () => void;
}
interface PillNavProps {
logo?: string;
logoText?: string;
logoAlt?: string;
items: PillNavItem[];
activeHref?: string;
className?: string;
ease?: string;
baseColor?: string;
pillColor?: string;
hoveredPillTextColor?: string;
pillTextColor?: string;
onMobileMenuClick?: () => void;
initialLoadAnimation?: boolean;
}
const PillNav = ({
logo,
logoText,
logoAlt = 'Logo',
items,
activeHref,
className = '',
ease = 'power3.easeOut',
baseColor = '#0d0720',
pillColor = '#1e0f3a',
hoveredPillTextColor = '#e9d5ff',
pillTextColor,
onMobileMenuClick,
initialLoadAnimation = true,
}: PillNavProps) => {
const resolvedPillTextColor = pillTextColor ?? '#c084fc';
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const circleRefs = useRef<(HTMLSpanElement | null)[]>([]);
const tlRefs = useRef<gsap.core.Timeline[]>([]);
const activeTweenRefs = useRef<gsap.core.Tween[]>([]);
const logoImgRef = useRef<HTMLImageElement>(null);
const logoTweenRef = useRef<gsap.core.Tween | null>(null);
const hamburgerRef = useRef<HTMLButtonElement>(null);
const mobileMenuRef = useRef<HTMLDivElement>(null);
const navItemsRef = useRef<HTMLDivElement>(null);
const logoRef = useRef<HTMLAnchorElement | null>(null);
useEffect(() => {
const layout = () => {
circleRefs.current.forEach((circle, index) => {
if (!circle?.parentElement) return;
const pill = circle.parentElement;
const rect = pill.getBoundingClientRect();
const { width: w, height: h } = rect;
const R = ((w * w) / 4 + h * h) / (2 * h);
const D = Math.ceil(2 * R) + 2;
const delta = Math.ceil(R - Math.sqrt(Math.max(0, R * R - (w * w) / 4))) + 1;
const originY = D - delta;
circle.style.width = `${D}px`;
circle.style.height = `${D}px`;
circle.style.bottom = `-${delta}px`;
gsap.set(circle, { xPercent: -50, scale: 0, transformOrigin: `50% ${originY}px` });
const label = pill.querySelector('.pill-label');
const white = pill.querySelector('.pill-label-hover');
if (label) gsap.set(label, { y: 0 });
if (white) gsap.set(white, { y: h + 12, opacity: 0 });
tlRefs.current[index]?.kill();
const tl = gsap.timeline({ paused: true });
tl.to(circle, { scale: 1.2, xPercent: -50, duration: 2, ease, overwrite: 'auto' }, 0);
if (label) tl.to(label, { y: -(h + 8), duration: 2, ease, overwrite: 'auto' }, 0);
if (white) {
gsap.set(white, { y: Math.ceil(h + 100), opacity: 0 });
tl.to(white, { y: 0, opacity: 1, duration: 2, ease, overwrite: 'auto' }, 0);
}
tlRefs.current[index] = tl;
});
};
layout();
window.addEventListener('resize', layout);
document.fonts?.ready?.then(layout).catch(() => {});
const menu = mobileMenuRef.current;
if (menu) gsap.set(menu, { visibility: 'hidden', opacity: 0, scaleY: 1 });
if (initialLoadAnimation) {
const logoEl = logoRef.current;
const navItems = navItemsRef.current;
if (logoEl) { gsap.set(logoEl, { scale: 0 }); gsap.to(logoEl, { scale: 1, duration: 0.6, ease }); }
if (navItems) { gsap.set(navItems, { width: 0, overflow: 'hidden' }); gsap.to(navItems, { width: 'auto', duration: 0.6, ease }); }
}
return () => window.removeEventListener('resize', layout);
}, [items, ease, initialLoadAnimation]);
const handleEnter = (i: number) => {
const tl = tlRefs.current[i];
if (!tl) return;
activeTweenRefs.current[i]?.kill();
activeTweenRefs.current[i] = tl.tweenTo(tl.duration(), { duration: 0.3, ease, overwrite: 'auto' }) as gsap.core.Tween;
};
const handleLeave = (i: number) => {
const tl = tlRefs.current[i];
if (!tl) return;
activeTweenRefs.current[i]?.kill();
activeTweenRefs.current[i] = tl.tweenTo(0, { duration: 0.2, ease, overwrite: 'auto' }) as gsap.core.Tween;
};
const handleLogoEnter = () => {
const img = logoImgRef.current;
if (!img) return;
logoTweenRef.current?.kill();
gsap.set(img, { rotate: 0 });
logoTweenRef.current = gsap.to(img, { rotate: 360, duration: 0.4, ease, overwrite: 'auto' });
};
const toggleMobileMenu = () => {
const newState = !isMobileMenuOpen;
setIsMobileMenuOpen(newState);
const hamburger = hamburgerRef.current;
const menu = mobileMenuRef.current;
if (hamburger) {
const lines = hamburger.querySelectorAll('.hamburger-line');
if (newState) {
gsap.to(lines[0], { rotation: 45, y: 3, duration: 0.3, ease });
gsap.to(lines[1], { rotation: -45, y: -3, duration: 0.3, ease });
} else {
gsap.to(lines[0], { rotation: 0, y: 0, duration: 0.3, ease });
gsap.to(lines[1], { rotation: 0, y: 0, duration: 0.3, ease });
}
}
if (menu) {
if (newState) {
gsap.set(menu, { visibility: 'visible' });
gsap.fromTo(menu, { opacity: 0, y: 10 }, { opacity: 1, y: 0, duration: 0.3, ease });
} else {
gsap.to(menu, { opacity: 0, y: 10, duration: 0.2, ease, onComplete: () => gsap.set(menu, { visibility: 'hidden' }) });
}
}
onMobileMenuClick?.();
};
const cssVars = {
['--base']: baseColor,
['--pill-bg']: pillColor,
['--hover-text']: hoveredPillTextColor,
['--pill-text']: resolvedPillTextColor,
} as React.CSSProperties;
const location = useLocation();
const currentHref = activeHref ?? location.pathname;
const renderPill = (item: PillNavItem, i: number) => {
const isActive = item.href ? currentHref === item.href : false;
const pillClass = `pill${isActive ? ' is-active' : ''}`;
const inner = (
<>
<span className="hover-circle" aria-hidden="true" ref={el => { circleRefs.current[i] = el; }} />
<span className="label-stack">
<span className="pill-label">{item.label}</span>
<span className="pill-label-hover" aria-hidden="true">{item.label}</span>
</span>
</>
);
if (item.onClick) {
return (
<button role="menuitem" className={pillClass}
aria-label={item.ariaLabel || item.label}
onMouseEnter={() => handleEnter(i)} onMouseLeave={() => handleLeave(i)}
onClick={item.onClick}>
{inner}
</button>
);
}
const isInternal = item.href && !item.href.startsWith('http') && !item.href.startsWith('mailto');
if (isInternal) {
return (
<Link role="menuitem" to={item.href!} className={pillClass}
aria-label={item.ariaLabel || item.label}
onMouseEnter={() => handleEnter(i)} onMouseLeave={() => handleLeave(i)}>
{inner}
</Link>
);
}
return (
<a role="menuitem" href={item.href || '#'} className={pillClass}
aria-label={item.ariaLabel || item.label}
onMouseEnter={() => handleEnter(i)} onMouseLeave={() => handleLeave(i)}>
{inner}
</a>
);
};
return (
<div className="pill-nav-container">
<nav className={`pill-nav ${className}`} aria-label="Primary" style={cssVars}>
{/* Logo / brand */}
<a
className="pill-logo"
href="#"
aria-label="Home"
onMouseEnter={handleLogoEnter}
ref={logoRef}
style={{ background: baseColor, color: resolvedPillTextColor }}
>
{logo
? <img src={logo} alt={logoAlt} ref={logoImgRef} />
: <span style={{ fontWeight: 800, fontSize: 14, letterSpacing: '0.05em', color: '#c084fc' }}>{logoText ?? 'AUTHRIX'}</span>
}
</a>
{/* Desktop nav items */}
<div className="pill-nav-items desktop-only" ref={navItemsRef}>
<ul className="pill-list" role="menubar">
{items.map((item, i) => (
<li key={i} role="none">{renderPill(item, i)}</li>
))}
</ul>
</div>
{/* Mobile hamburger */}
<button
className="mobile-menu-button mobile-only"
onClick={toggleMobileMenu}
aria-label="Toggle menu"
ref={hamburgerRef}
>
<span className="hamburger-line" />
<span className="hamburger-line" />
</button>
</nav>
{/* Mobile popover */}
<div className="mobile-menu-popover mobile-only" ref={mobileMenuRef} style={cssVars}>
<ul className="mobile-menu-list">
{items.map((item, i) => (
<li key={i}>
{item.onClick
? <button className={`mobile-menu-link${currentHref === item.href ? ' is-active' : ''}`} onClick={() => { item.onClick?.(); setIsMobileMenuOpen(false); }}>{item.label}</button>
: item.href && !item.href.startsWith('http')
? <Link to={item.href} className={`mobile-menu-link${currentHref === item.href ? ' is-active' : ''}`} onClick={() => setIsMobileMenuOpen(false)}>{item.label}</Link>
: <a href={item.href || '#'} className={`mobile-menu-link${currentHref === item.href ? ' is-active' : ''}`} onClick={() => setIsMobileMenuOpen(false)}>{item.label}</a>
}
</li>
))}
</ul>
</div>
</div>
);
};
export default PillNav;