ca-grow-ops-manager/frontend/src/components/aura/Navbar.tsx
fullsizemalt ca8a3e8cee
Some checks are pending
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run
refactor: Rebrand from 777wolfpack/CA Grow Ops to Veridian
- Update all frontend branding (Login, Splash, Layout, Navbar, etc.)
- Update page titles and breadcrumbs
- Update visitor components (Badge, CheckIn)
- Update deploy.sh and README
- Update test fixtures with new email domain
2025-12-27 11:24:26 -08:00

263 lines
13 KiB
TypeScript

import { useState, useRef, useEffect } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import {
Menu,
ChevronDown,
Command,
Bell,
LogOut,
Search,
Settings,
} from 'lucide-react';
import { usePermissions } from '../../hooks/usePermissions';
import { getFilteredNavSections, type NavSection } from '../../lib/navigation';
import { useAuth } from '../../context/AuthContext';
import ThemeToggle from '../ThemeToggle';
import { cn } from '../../lib/utils';
interface NavbarProps {
onOpenMobileMenu?: () => void;
}
export function Navbar({ onOpenMobileMenu }: NavbarProps) {
const { role } = usePermissions();
const sections = getFilteredNavSections(role);
const location = useLocation();
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
const handleScroll = () => setScrolled(window.scrollY > 20);
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return (
<header
className={`
sticky top-0 z-40 w-full border-b backdrop-blur-xl transition-all duration-500
${scrolled
? 'bg-white/80 border-slate-200 dark:bg-[#050505]/80 dark:border-slate-800 shadow-2xl shadow-black/5'
: 'bg-transparent border-transparent'
}
`}
>
<div className="max-w-[1920px] mx-auto px-4 sm:px-6 lg:px-10">
<div className="flex items-center justify-between h-16">
{/* Left: Branding & Navigation */}
<div className="flex items-center gap-12">
<Link to="/dashboard" className="flex items-center gap-3 group relative z-50">
<div className="relative">
<img
src="/assets/logo-veridian.jpg"
alt="Veridian"
className="w-9 h-9 rounded-lg shadow-md ring-1 ring-slate-900/5 group-hover:scale-105 transition-transform duration-500"
/>
<div className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-emerald-500 rounded-full border-2 border-white dark:border-[#050505] animate-pulse" />
</div>
<div className="hidden sm:block">
<h1 className="text-sm font-bold text-slate-900 dark:text-white leading-tight tracking-tighter uppercase italic">
Veridian
</h1>
<p className="text-[9px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-[0.3em] leading-none">
Cultivation Platform
</p>
</div>
</Link>
{/* Desktop Navigation */}
<nav className="hidden lg:flex items-center gap-2">
{sections.map(section => (
<NavDropdown
key={section.id}
section={section}
currentPath={location.pathname}
/>
))}
</nav>
</div>
{/* Right: Actions */}
<div className="flex items-center gap-4">
{/* Search Mock */}
<button
onClick={() => dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))}
className="hidden md:flex items-center gap-3 px-4 py-2 text-xs font-bold text-slate-500 bg-slate-100/50 hover:bg-white dark:bg-slate-900/50 dark:hover:bg-slate-800 transition-all rounded-full border border-slate-200/50 dark:border-slate-800 group"
>
<Search size={12} className="group-hover:text-indigo-500 transition-colors" />
<span className="uppercase tracking-widest">Search...</span>
<kbd className="hidden lg:inline-flex h-5 items-center gap-1 rounded bg-slate-200 dark:bg-slate-800 px-1.5 font-mono text-[9px] text-slate-500">
K
</kbd>
</button>
<div className="w-px h-6 bg-slate-200 dark:bg-slate-800 mx-1 hidden sm:block" />
<button className="relative p-2 text-slate-400 hover:text-indigo-500 transition-colors rounded-xl hover:bg-slate-100 dark:hover:bg-slate-900">
<Bell size={18} />
<span className="absolute top-2.5 right-2.5 w-1.5 h-1.5 bg-rose-500 rounded-full ring-2 ring-white dark:ring-[#050505]" />
</button>
<ThemeToggle />
<div className="pl-1">
<UserDropdown />
</div>
<button
onClick={onOpenMobileMenu}
className="lg:hidden p-2 text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
>
<Menu size={24} />
</button>
</div>
</div>
</div>
</header>
);
}
function UserDropdown() {
const { user, logout } = useAuth();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 p-1 rounded-full hover:bg-slate-100 dark:hover:bg-slate-900 transition-colors"
>
<div className="w-8 h-8 rounded-xl bg-slate-900 text-white dark:bg-white dark:text-slate-900 flex items-center justify-center font-bold text-xs ring-2 ring-transparent hover:ring-indigo-500/20 transition-all">
{user?.email?.[0]?.toUpperCase() || 'E'}
</div>
</button>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
transition={{ duration: 0.1 }}
className="absolute top-full right-0 mt-3 w-64 p-3 bg-white/95 dark:bg-slate-900/95 backdrop-blur-2xl border border-slate-200 dark:border-slate-800 rounded-2xl shadow-2xl z-50 ring-1 ring-black/5"
>
<div className="px-3 py-3 border-b border-slate-100 dark:border-slate-800 mb-2">
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">Authenticated</p>
<p className="text-sm font-bold text-slate-900 dark:text-white truncate">
{user?.name || 'Administrator'}
</p>
<p className="text-[10px] text-slate-500 truncate font-mono mt-1">
{user?.email}
</p>
</div>
<Link
to="/settings"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 px-3 py-2.5 text-xs font-bold uppercase tracking-widest text-slate-600 hover:text-indigo-600 hover:bg-indigo-50 dark:text-slate-400 dark:hover:text-indigo-400 dark:hover:bg-indigo-500/10 rounded-xl transition-all"
>
<Settings size={14} />
Settings
</Link>
<button
onClick={() => {
setIsOpen(false);
logout();
}}
className="w-full flex items-center gap-3 px-3 py-2.5 text-xs font-bold uppercase tracking-widest text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-950/20 rounded-xl transition-all"
>
<LogOut size={14} />
Sign Out
</button>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function NavDropdown({ section, currentPath }: { section: NavSection, currentPath: string }) {
const [isOpen, setIsOpen] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const isActive = section.items.some(item => item.path === currentPath);
return (
<div
className="relative px-0.5"
onMouseEnter={() => { if (timeoutRef.current) clearTimeout(timeoutRef.current); setIsOpen(true); }}
onMouseLeave={() => { timeoutRef.current = setTimeout(() => setIsOpen(false), 150); }}
>
<button
className={`
flex items-center gap-2 px-3.5 py-2 text-[11px] font-bold uppercase tracking-[0.15em] rounded-lg transition-all
${isActive || isOpen
? 'text-indigo-600 bg-indigo-500/5 dark:text-indigo-400 dark:bg-indigo-500/10'
: 'text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white'
}
`}
>
{section.label}
<ChevronDown
size={10}
className={`transition-transform duration-300 ${isOpen ? 'rotate-180 text-indigo-500' : 'opacity-40'}`}
/>
</button>
<AnimatePresence mode="wait">
{isOpen && (
<motion.div
initial={{ opacity: 0, y: 12, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.98 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="absolute top-full left-0 mt-2 w-72 p-3 bg-white/95 dark:bg-slate-900/95 backdrop-blur-2xl border border-slate-200 dark:border-slate-800 rounded-2xl shadow-2xl z-50"
>
<div className="grid gap-1">
<p className="px-3 py-1.5 text-[9px] font-bold text-slate-400 uppercase tracking-[0.2em]">{section.label}</p>
{section.items.map(item => (
<Link
key={item.id}
to={item.path}
className={`
group flex items-center gap-4 px-3 py-3 rounded-xl transition-all
${item.path === currentPath
? 'bg-indigo-500/10 text-indigo-600 dark:text-indigo-400'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100'
}
`}
>
<div className={cn(
"p-2 rounded-lg transition-all group-hover:scale-110",
item.path === currentPath ? "bg-indigo-500 text-white" : "bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400"
)}>
<item.icon size={14} />
</div>
<div className="flex-1">
<div className="text-xs font-bold leading-none tracking-tight">{item.label}</div>
</div>
{item.path === currentPath && (
<div className="w-1 h-1 rounded-full bg-indigo-500" />
)}
</Link>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export default Navbar;