BillTracker/client/components/layout/Sidebar.jsx

212 lines
7.9 KiB
React
Raw Normal View History

2026-05-09 13:03:36 -05:00
import { useState, useMemo } from 'react';
2026-05-04 20:12:57 -05:00
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
2026-05-03 19:51:57 -05:00
import {
Activity, BarChart3, CalendarDays, ChevronDown, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt,
2026-05-03 19:51:57 -05:00
Settings, ShieldCheck, Tag, User, X,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/hooks/useAuth';
import { ThemeToggle } from '@/components/ui/theme-toggle';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
2026-05-09 13:03:36 -05:00
import { NavPill } from './NavPill';
import { BrandBlock } from './BrandBlock';
2026-05-03 19:51:57 -05:00
const userNavItems = [
2026-05-04 13:14:32 -05:00
{ to: '/calendar', icon: CalendarDays, label: 'Calendar' },
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
2026-05-03 19:51:57 -05:00
];
const adminNavItems = [
2026-05-04 20:12:57 -05:00
{ to: '/admin', icon: ShieldCheck, label: 'Admin Panel', end: true },
{ to: '/admin/status', icon: Activity, label: 'System Status' },
{ to: '/admin/roadmap', icon: Map, label: 'Roadmap' },
2026-05-04 20:12:57 -05:00
];
const trackerItems = [
{ to: '/', icon: LayoutGrid, label: 'Overview', end: true },
{ to: '/summary', icon: ClipboardList, label: 'Summary' },
{ to: '/bills', icon: Receipt, label: 'Bills' },
{ to: '/categories', icon: Tag, label: 'Categories' },
2026-05-03 19:51:57 -05:00
];
2026-05-04 20:12:57 -05:00
function TrackerMenu({ onNavigate }) {
const location = useLocation();
const navigate = useNavigate();
2026-05-09 13:03:36 -05:00
const isTrackerActive = useMemo(() => trackerItems.some(item => (
2026-05-04 20:12:57 -05:00
item.end ? location.pathname === item.to : location.pathname.startsWith(item.to)
2026-05-09 13:03:36 -05:00
)), [location.pathname]);
2026-05-04 20:12:57 -05:00
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
'inline-flex items-center gap-2 rounded-full px-3 py-2 text-sm font-medium transition-all',
'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
isTrackerActive
? 'bg-primary text-primary-foreground shadow-sm shadow-primary/20'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground hover:shadow-sm',
)}
aria-expanded={isTrackerActive}
aria-haspopup="menu"
2026-05-04 20:12:57 -05:00
>
<LayoutGrid className="h-4 w-4" />
Tracker
<ChevronDown className="h-3.5 w-3.5 opacity-75" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
{trackerItems.map(item => {
const Icon = item.icon;
return (
<DropdownMenuItem key={item.to} onSelect={() => { navigate(item.to); onNavigate?.(); }}>
<Icon className="h-4 w-4" />
{item.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
2026-05-03 19:51:57 -05:00
function UserMenu({ adminMode = false }) {
const { user, logout } = useAuth();
const navigate = useNavigate();
2026-05-09 13:03:36 -05:00
const name = useMemo(() =>
user?.display_name || user?.username || (adminMode ? 'Admin' : 'Profile'),
[user, adminMode]
);
const accountToolsAllowed = useMemo(() => !user?.is_default_admin, [user]);
const userRole = useMemo(() => user?.role, [user]);
2026-05-03 19:51:57 -05:00
const handleLogout = async () => {
try { await logout(); } catch {}
navigate('/login', { replace: true });
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="inline-flex h-9 items-center gap-2 rounded-full border border-border/70 bg-card/90 px-2.5 text-sm font-medium text-foreground shadow-sm transition-all hover:bg-accent hover:text-accent-foreground hover:shadow-md focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
aria-label="Open user menu"
>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-primary">
<User className="h-3.5 w-3.5" />
</span>
<span className="hidden max-w-[140px] truncate sm:inline">{name}</span>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuLabel className="truncate">{name}</DropdownMenuLabel>
2026-05-03 20:40:48 -05:00
<DropdownMenuSeparator />
2026-05-09 13:03:36 -05:00
{userRole === 'admin' && !adminMode && (
2026-05-04 20:12:57 -05:00
<>
<DropdownMenuItem onSelect={() => navigate('/admin')}>
<ShieldCheck className="h-4 w-4" />
Admin Panel
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => navigate('/admin/status')}>
<Activity className="h-4 w-4" />
System Status
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
2026-05-04 23:34:24 -05:00
{accountToolsAllowed && (
<>
<DropdownMenuItem onSelect={() => navigate('/profile')}>
<User className="h-4 w-4" />
Profile
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => navigate('/settings')}>
<Settings className="h-4 w-4" />
Settings
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => navigate('/data')}>
<Database className="h-4 w-4" />
Data
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
2026-05-04 20:12:57 -05:00
<DropdownMenuItem onSelect={() => navigate('/about')}>
<Info className="h-4 w-4" />
About
</DropdownMenuItem>
{user?.role === 'admin' && (
<DropdownMenuItem onSelect={() => navigate('/admin/roadmap')}>
<Map className="h-4 w-4" />
Roadmap
</DropdownMenuItem>
)}
2026-05-03 19:51:57 -05:00
<DropdownMenuSeparator />
<DropdownMenuItem destructive onSelect={handleLogout}>
<LogOut className="h-4 w-4" />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export default function Sidebar({ adminMode = false }) {
const [mobileOpen, setMobileOpen] = useState(false);
2026-05-03 20:40:48 -05:00
const { user } = useAuth();
2026-05-09 13:03:36 -05:00
const items = useMemo(() => adminMode ? adminNavItems : userNavItems, [adminMode]);
2026-05-03 19:51:57 -05:00
return (
<header className="sticky top-0 z-40 border-b border-border/70 bg-background/85 shadow-sm shadow-foreground/5 backdrop-blur-xl supports-[backdrop-filter]:bg-background/70">
<div className="mx-auto flex h-16 w-full max-w-[1500px] items-center gap-4 px-4 sm:px-6 lg:px-8">
<BrandBlock adminMode={adminMode} />
2026-05-04 13:14:32 -05:00
<nav className="hidden items-center gap-1 lg:flex">
2026-05-04 20:12:57 -05:00
{!adminMode && <TrackerMenu />}
2026-05-03 19:51:57 -05:00
{items.map(item => (
<NavPill key={item.to} item={item} />
))}
</nav>
<div className="ml-auto flex items-center gap-2">
<ThemeToggle className="rounded-full border border-border/70 bg-card/90 shadow-sm" />
<UserMenu adminMode={adminMode} />
<Button
type="button"
variant="outline"
size="icon"
2026-05-04 13:14:32 -05:00
className="lg:hidden rounded-full bg-card/90"
2026-05-03 19:51:57 -05:00
aria-label={mobileOpen ? 'Close navigation menu' : 'Open navigation menu'}
aria-expanded={mobileOpen}
onClick={() => setMobileOpen(v => !v)}
>
{mobileOpen ? <X className="h-4 w-4" /> : <Menu className="h-4 w-4" />}
</Button>
</div>
</div>
{mobileOpen && (
2026-05-09 13:03:36 -05:00
<div className="border-t border-border/60 bg-background/95 px-4 py-3 shadow-lg shadow-foreground/5 lg:hidden max-h-[70vh] overflow-y-auto">
2026-05-03 19:51:57 -05:00
<nav className="mx-auto grid max-w-[1500px] gap-1">
2026-05-04 20:12:57 -05:00
{!adminMode && trackerItems.map(item => (
<NavPill key={item.to} item={item} onNavigate={() => setMobileOpen(false)} />
))}
2026-05-03 19:51:57 -05:00
{items.map(item => (
<NavPill key={item.to} item={item} onNavigate={() => setMobileOpen(false)} />
))}
</nav>
</div>
)}
</header>
);
}