Spaces:
Running
Running
| import React, { useState, useRef, useEffect } from 'react'; | |
| import { useRouter, usePathname } from 'next/navigation'; | |
| import { useAuth } from '@/contexts/AuthContext'; | |
| import { Menu, X, LogOut, Camera, Upload, Trash2, Moon, Sun, MessageSquare, UserCircle, LayoutDashboard, Volume2, VolumeX } from 'lucide-react'; | |
| import { apiLayer } from '@/lib/api'; | |
| import FeedbackModal from '@/components/shared/FeedbackModal'; | |
| interface NavbarProps { | |
| onAnalyzeClick?: () => void; | |
| } | |
| export default function Navbar({ onAnalyzeClick }: NavbarProps) { | |
| const router = useRouter(); | |
| const pathname = usePathname(); | |
| const { isAuthenticated, user, logout, updateUser } = useAuth(); | |
| const [mobileMenuOpen, setMobileMenuOpen] = useState(false); | |
| const [dropdownOpen, setDropdownOpen] = useState(false); | |
| const [dropdownPinned, setDropdownPinned] = useState(false); | |
| const [soundEnabled, setSoundEnabled] = useState(true); | |
| const [navLoading, setNavLoading] = useState(false); | |
| const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null); | |
| const handleMouseEnter = () => { | |
| if (!dropdownPinned) { | |
| if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current); | |
| setDropdownOpen(true); | |
| } | |
| }; | |
| const handleMouseLeave = () => { | |
| if (!dropdownPinned) { | |
| closeTimeoutRef.current = setTimeout(() => { | |
| setDropdownOpen(false); | |
| }, 300); // 300ms grace period | |
| } | |
| }; | |
| useEffect(() => { | |
| if (typeof window !== 'undefined') { | |
| const saved = localStorage.getItem('spotix_sound'); | |
| if (saved !== null) { | |
| setSoundEnabled(saved === 'true'); | |
| } | |
| } | |
| }, []); | |
| const toggleSound = (e: React.MouseEvent) => { | |
| e.stopPropagation(); | |
| const newState = !soundEnabled; | |
| setSoundEnabled(newState); | |
| localStorage.setItem('spotix_sound', String(newState)); | |
| }; | |
| // Avatar logic | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| const dropdownRef = useRef<HTMLDivElement>(null); | |
| const [localAvatar, setLocalAvatar] = useState<string | null>(null); | |
| // UI State | |
| const [feedbackModalOpen, setFeedbackModalOpen] = useState(false); | |
| useEffect(() => { | |
| if (user?.avatar_url) { | |
| setLocalAvatar(user.avatar_url); | |
| } else if (user?.google_avatar_url) { | |
| setLocalAvatar(user.google_avatar_url); | |
| } else { | |
| setLocalAvatar(null); | |
| } | |
| }, [user?.avatar_url, user?.google_avatar_url]); | |
| useEffect(() => { | |
| const handleClickOutside = (event: MouseEvent) => { | |
| if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { | |
| setDropdownOpen(false); | |
| setDropdownPinned(false); | |
| } | |
| }; | |
| const handleEsc = (event: KeyboardEvent) => { | |
| if (event.key === 'Escape') { | |
| setDropdownOpen(false); | |
| setDropdownPinned(false); | |
| } | |
| }; | |
| if (dropdownOpen) { | |
| document.addEventListener('mousedown', handleClickOutside); | |
| document.addEventListener('keydown', handleEsc); | |
| } | |
| return () => { | |
| document.removeEventListener('mousedown', handleClickOutside); | |
| document.removeEventListener('keydown', handleEsc); | |
| }; | |
| }, [dropdownOpen]); | |
| const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => { | |
| const file = e.target.files?.[0]; | |
| if (!file) return; | |
| if (!file.type.startsWith('image/')) { | |
| alert("Only image files are allowed for avatars."); | |
| return; | |
| } | |
| const reader = new FileReader(); | |
| reader.onloadend = async () => { | |
| const base64String = reader.result as string; | |
| setLocalAvatar(base64String); | |
| try { | |
| await apiLayer.updateAvatar(base64String); | |
| updateUser({ avatar_url: base64String }); | |
| } catch (err) { | |
| console.error("Failed to update avatar:", err); | |
| } | |
| }; | |
| reader.readAsDataURL(file); | |
| }; | |
| const handleRemoveAvatar = async () => { | |
| if (user?.google_avatar_url) { | |
| setLocalAvatar(user.google_avatar_url); | |
| } else { | |
| setLocalAvatar(null); | |
| } | |
| try { | |
| await apiLayer.updateAvatar(""); | |
| updateUser({ avatar_url: null }); | |
| } catch (err) { | |
| console.error("Failed to remove avatar:", err); | |
| } | |
| setDropdownOpen(false); | |
| }; | |
| return ( | |
| <> | |
| <nav className="fixed top-6 left-1/2 -translate-x-1/2 z-[60] w-[92%] max-w-5xl"> | |
| <div className="bg-[var(--theme-bg)]/60 backdrop-blur-xl border border-[#4d453e]/15 rounded-full px-6 md:px-8 py-3 md:py-4 flex justify-between items-center shadow-[0_20px_50px_rgba(0,0,0,0.4)] transition-all duration-500"> | |
| <div onClick={() => router.push('/')} className="cursor-pointer text-base md:text-lg font-black tracking-tighter text-[var(--theme-text)] uppercase hover:opacity-80 transition-opacity">SPOTIX</div> | |
| <div className="hidden lg:flex items-center gap-10"> | |
| <a className={`font-['Inter'] tracking-tight font-medium text-[11px] uppercase transition-colors nav-link ${pathname === '/' ? 'text-[var(--theme-text)]' : 'text-[#d0c4bb] hover:text-[var(--theme-text)]'}`} href="/">Platform</a> | |
| {isAuthenticated && ( | |
| <a onClick={() => router.push('/dashboard')} className={`cursor-pointer font-['Inter'] tracking-tight font-medium text-[11px] uppercase transition-colors nav-link ${pathname === '/dashboard' ? 'text-[var(--theme-text)]' : 'text-[#d0c4bb] hover:text-[var(--theme-text)]'}`}>Dashboard</a> | |
| )} | |
| {onAnalyzeClick && ( | |
| <button onClick={onAnalyzeClick} className="flex items-center gap-2 bg-[var(--theme-text)]/10 text-[var(--theme-text)] border border-[var(--theme-text)]/20 px-4 py-1.5 rounded-full text-[11px] font-bold tracking-tight hover:bg-[var(--theme-text)]/20 transition-all hover-scale"> | |
| <Upload className="w-3 h-3" /> Analyze New Media | |
| </button> | |
| )} | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| {!isAuthenticated ? ( | |
| <> | |
| <button | |
| onClick={() => { setNavLoading(true); router.push('/login'); }} | |
| disabled={navLoading} | |
| className="hidden sm:flex items-center gap-2 bg-transparent text-[var(--theme-text)] border border-[var(--theme-text)]/30 px-6 py-2 rounded-full text-[11px] font-bold tracking-tight hover:bg-[var(--theme-text)]/5 hover-scale disabled:opacity-60 transition-opacity" | |
| > | |
| {navLoading ? ( | |
| <svg className="animate-spin w-3 h-3" viewBox="0 0 24 24" fill="none"> | |
| <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/> | |
| <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/> | |
| </svg> | |
| ) : null} | |
| Register | |
| </button> | |
| <button | |
| onClick={() => { setNavLoading(true); router.push('/login'); }} | |
| disabled={navLoading} | |
| className="hidden sm:flex items-center gap-2 bg-[var(--theme-text)] text-[var(--theme-bg)] px-6 py-2 rounded-full text-[11px] font-bold tracking-tight hover:shadow-[0_0_20px_rgba(253,232,214,0.3)] hover-scale disabled:opacity-60 transition-opacity" | |
| > | |
| {navLoading ? ( | |
| <svg className="animate-spin w-3 h-3" viewBox="0 0 24 24" fill="none"> | |
| <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/> | |
| <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/> | |
| </svg> | |
| ) : null} | |
| Login | |
| </button> | |
| </> | |
| ) : ( | |
| <div | |
| className="flex items-center gap-3 relative group" | |
| ref={dropdownRef} | |
| onMouseEnter={handleMouseEnter} | |
| onMouseLeave={handleMouseLeave} | |
| > | |
| <div | |
| className="flex items-center gap-3 cursor-pointer hover:opacity-80 transition-opacity" | |
| onClick={() => { | |
| const newPinned = !dropdownPinned; | |
| setDropdownPinned(newPinned); | |
| setDropdownOpen(newPinned); | |
| }} | |
| > | |
| <span className="hidden sm:block text-[var(--theme-text)] opacity-80 text-sm font-mono tracking-tight hover-scale">{user?.name || "User"}</span> | |
| <div className="w-10 h-10 rounded-full border border-[var(--theme-border)] overflow-hidden bg-[var(--theme-text)]/5 flex items-center justify-center dash-border hover-scale"> | |
| {localAvatar ? ( | |
| <img src={localAvatar} alt="Avatar" className="w-full h-full object-cover" /> | |
| ) : ( | |
| <span className="text-[var(--theme-text)] font-bold text-sm">{(user?.name || "U").charAt(0).toUpperCase()}</span> | |
| )} | |
| </div> | |
| </div> | |
| {dropdownOpen && ( | |
| <div className="absolute right-0 top-full pt-3 w-56 flex flex-col z-[100] !cursor-none"> | |
| <div className="bg-[var(--theme-bg)] border border-[var(--theme-border)] rounded-xl py-2 shadow-2xl flex flex-col w-full h-full"> | |
| <button onClick={() => { setDropdownOpen(false); router.push('/dashboard'); }} className="px-4 py-3 flex items-center gap-3 text-sm text-[var(--theme-text)]/70 hover:text-[var(--theme-text)] hover:bg-[var(--theme-text)]/5 transition-colors text-left !cursor-none"> | |
| <LayoutDashboard className="w-4 h-4 !cursor-none" /> Dashboard | |
| </button> | |
| <button onClick={() => { setDropdownOpen(false); router.push('/profile'); }} className="px-4 py-3 flex items-center gap-3 text-sm text-[var(--theme-text)]/70 hover:text-[var(--theme-text)] hover:bg-[var(--theme-text)]/5 transition-colors text-left !cursor-none"> | |
| <UserCircle className="w-4 h-4 !cursor-none" /> My Profile | |
| </button> | |
| {localAvatar && ( | |
| <button | |
| className="px-4 py-3 flex items-center gap-3 text-sm text-yellow-500/80 hover:text-yellow-500 hover:bg-[var(--theme-text)]/5 transition-colors text-left !cursor-none" | |
| onClick={handleRemoveAvatar} | |
| > | |
| <Trash2 className="w-4 h-4 !cursor-none" /> Remove Avatar | |
| </button> | |
| )} | |
| <div className="h-px w-full bg-[var(--theme-border)] my-1"></div> | |
| <button | |
| onClick={toggleSound} | |
| className="px-4 py-3 flex items-center gap-3 text-sm text-[var(--theme-text)]/70 hover:text-[var(--theme-text)] hover:bg-[var(--theme-text)]/5 transition-colors text-left justify-between !cursor-none" | |
| > | |
| <div className="flex items-center gap-3"> | |
| {soundEnabled ? <Volume2 className="w-4 h-4 !cursor-none text-green-400" /> : <VolumeX className="w-4 h-4 !cursor-none text-red-400" />} | |
| Alerts Sound | |
| </div> | |
| <span className={`text-[10px] font-bold px-2 py-0.5 rounded-full uppercase tracking-wider ${soundEnabled ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}`}> | |
| {soundEnabled ? 'ON' : 'OFF'} | |
| </span> | |
| </button> | |
| <div className="h-px w-full bg-[var(--theme-border)] my-1"></div> | |
| <button | |
| className="px-4 py-3 flex items-center gap-3 text-sm text-[var(--theme-text)]/70 hover:text-[var(--theme-text)] hover:bg-[var(--theme-text)]/5 transition-colors text-left !cursor-none" | |
| onClick={() => { | |
| setDropdownOpen(false); | |
| setFeedbackModalOpen(true); | |
| }} | |
| > | |
| <MessageSquare className="w-4 h-4 !cursor-none" /> Report Issue | |
| </button> | |
| <div className="h-px w-full bg-[var(--theme-border)] my-1"></div> | |
| <button | |
| className="px-4 py-3 flex items-center gap-3 text-sm text-red-400 hover:text-red-300 hover:bg-[var(--theme-text)]/5 transition-colors text-left !cursor-none" | |
| onClick={() => { | |
| setDropdownOpen(false); | |
| logout(); | |
| }} | |
| > | |
| <LogOut className="w-4 h-4 !cursor-none" /> Logout | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| <button className="lg:hidden text-[var(--theme-text)] p-2" id="menu-toggle" onClick={() => setMobileMenuOpen(true)}> | |
| <Menu className="w-6 h-6" /> | |
| </button> | |
| </div> | |
| </div> | |
| </nav> | |
| {/* Mobile Menu */} | |
| <div className={`fixed inset-0 z-[70] bg-[#0d0e13] flex flex-col items-center justify-center p-12 text-center transition-all duration-500 ${mobileMenuOpen ? 'opacity-100 visible pointer-events-auto translate-y-0' : 'opacity-0 invisible pointer-events-none -translate-y-[20px]'}`} id="mobile-menu"> | |
| <button className="absolute top-8 right-8 text-[var(--theme-text)] pointer-events-auto" id="menu-close" onClick={() => setMobileMenuOpen(false)}> | |
| <X className="w-10 h-10" /> | |
| </button> | |
| <div className="flex flex-col gap-8 pointer-events-auto"> | |
| <a className="menu-link text-4xl font-black uppercase tracking-tighter text-[var(--theme-text)] hover:text-[var(--theme-text)]/70 transition-colors" href="/">Platform</a> | |
| {isAuthenticated && ( | |
| <a onClick={() => { setMobileMenuOpen(false); router.push('/dashboard'); }} className="cursor-pointer menu-link text-4xl font-black uppercase tracking-tighter text-[var(--theme-border)] hover:text-[var(--theme-text)]/70 transition-colors">Dashboard</a> | |
| )} | |
| </div> | |
| </div> | |
| <FeedbackModal isOpen={feedbackModalOpen} onClose={() => setFeedbackModalOpen(false)} /> | |
| </> | |
| ); | |
| } | |