168 lines
6.0 KiB
JavaScript
168 lines
6.0 KiB
JavaScript
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';
|
|
|
|
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' : '/'}
|
|
aria-label="BillTracker"
|
|
className="flex items-center gap-2 rounded-xl focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
|
>
|
|
<img
|
|
src="/img/logo.png"
|
|
alt="BillTracker"
|
|
className="h-16 w-auto max-w-[9rem] object-contain drop-shadow-[0_1px_2px_rgba(0,0,0,0.45)]"
|
|
/>
|
|
{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>
|
|
)}
|
|
</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>
|
|
<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>
|
|
)}
|
|
<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);
|
|
const { user } = useAuth();
|
|
const items = user?.role === 'admin'
|
|
? [...userNavItems, ...adminNavItems]
|
|
: userNavItems;
|
|
|
|
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>
|
|
);
|
|
}
|