BillTracker/client/components/layout/Sidebar.jsx

169 lines
6.4 KiB
React
Raw Normal View History

2026-05-03 19:51:57 -05:00
import { useState } from 'react';
import { NavLink, useNavigate } from 'react-router-dom';
import {
Activity, ChevronDown, LayoutGrid, LogOut, Menu, Receipt,
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';
import { APP_VERSION } from '@/lib/version';
const userNavItems = [
{ to: '/', icon: LayoutGrid, label: 'Tracker', end: true },
{ to: '/bills', icon: Receipt, label: 'Bills' },
{ to: '/categories', icon: Tag, label: 'Categories' },
{ to: '/settings', icon: Settings, label: 'Settings' },
{ to: '/status', icon: Activity, label: 'Status' },
];
const adminNavItems = [
{ to: '/admin', icon: ShieldCheck, label: 'Admin', end: true },
];
function BrandBlock({ adminMode = false }) {
return (
<NavLink to={adminMode ? '/admin' : '/'} className="flex items-center gap-3 rounded-xl focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50">
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-primary text-primary-foreground font-bold text-sm shadow-sm shadow-primary/25">
$
</div>
<div className="min-w-0 leading-tight">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold tracking-tight text-foreground">BillTracker</span>
{adminMode && (
<span className="hidden sm:inline-flex rounded-full border border-destructive/25 bg-destructive/10 px-2 py-0.5 text-[10px] font-semibold uppercase text-destructive">
Admin
</span>
)}
</div>
<span className="text-[10px] text-muted-foreground/70 tabular-nums">v{APP_VERSION}</span>
</div>
</NavLink>
);
}
function NavPill({ item, onNavigate }) {
const Icon = item.icon;
return (
<NavLink
to={item.to}
end={item.end}
onClick={onNavigate}
className={({ isActive }) => 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',
isActive
? 'bg-primary text-primary-foreground shadow-sm shadow-primary/20'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground hover:shadow-sm'
)}
>
<Icon className="h-4 w-4" />
<span>{item.label}</span>
</NavLink>
);
}
function UserMenu({ adminMode = false }) {
const { user, logout } = useAuth();
const navigate = useNavigate();
const name = user?.display_name || user?.username || (adminMode ? 'Admin' : 'Profile');
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 />
<DropdownMenuItem onSelect={() => navigate('/profile')}>
<User className="h-4 w-4" />
Profile
</DropdownMenuItem>
{user?.role === 'admin' && !adminMode && (
<DropdownMenuItem onSelect={() => navigate('/admin')}>
<ShieldCheck className="h-4 w-4" />
Admin
</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();
const items = user?.role === 'admin'
? [...userNavItems, ...adminNavItems]
: userNavItems;
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} />
<nav className="hidden items-center gap-1 md:flex">
{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"
className="md:hidden rounded-full bg-card/90"
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 && (
<div className="border-t border-border/60 bg-background/95 px-4 py-3 shadow-lg shadow-foreground/5 md:hidden">
<nav className="mx-auto grid max-w-[1500px] gap-1">
{items.map(item => (
<NavPill key={item.to} item={item} onNavigate={() => setMobileOpen(false)} />
))}
</nav>
</div>
)}
</header>
);
}