BillTracker/client/components/layout/Sidebar.jsx

264 lines
10 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, Calculator, CalendarDays, ChevronDown, ClipboardCheck, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt,
2026-05-16 15:38:28 -05:00
Search, Settings, ShieldCheck, Tag, TrendingDown, User, X,
Repeat,
2026-05-03 19:51:57 -05:00
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/hooks/useAuth';
import { useOverdueCount } from '@/hooks/useQueries';
2026-05-03 19:51:57 -05:00
import { ThemeToggle } from '@/components/ui/theme-toggle';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
2026-05-03 19:51:57 -05:00
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 = [
{ to: '/', icon: LayoutGrid, label: 'Tracker', end: true },
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' },
2026-05-16 15:38:28 -05:00
{ to: '/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: '/subscriptions', icon: Repeat, label: 'Subscriptions' },
2026-05-04 20:12:57 -05:00
{ to: '/categories', icon: Tag, label: 'Categories' },
2026-05-16 10:56:56 -05:00
{ to: '/health', icon: ClipboardCheck, label: 'Health' },
2026-05-14 02:11:54 -05:00
{ to: '/snowball', icon: TrendingDown, label: 'Snowball' },
{ to: '/payoff', icon: Calculator, label: 'Payoff' },
2026-05-03 19:51:57 -05:00
];
function TrackerMenu({ onNavigate, badge, badgeNames = [] }) {
2026-05-04 20:12:57 -05:00
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
{badge > 0 && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-rose-500 px-1 text-[10px] font-bold leading-none text-white">
{badge > 99 ? '99+' : badge}
</span>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-[200px]">
<p className="font-semibold mb-1">{badge} past due</p>
{badgeNames.slice(0, 5).map(name => (
<p key={name} className="text-xs opacity-80">· {name}</p>
))}
{badgeNames.length > 5 && (
<p className="text-xs opacity-60">+{badgeNames.length - 5} more</p>
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
2026-05-04 20:12:57 -05:00
<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
2026-05-15 22:45:38 -05:00
className="inline-flex h-9 items-center gap-2 rounded-full border border-border/80 bg-muted px-2.5 text-sm font-medium text-foreground shadow-sm transition-all hover:bg-accent hover:text-accent-foreground hover:shadow focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
2026-05-03 19:51:57 -05:00
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' && (
2026-05-16 15:38:28 -05:00
<DropdownMenuItem onSelect={() => navigate('/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]);
const { data: overdueData } = useOverdueCount();
const overdueCount = (!adminMode && overdueData?.count) ? overdueData.count : 0;
const overdueNames = (!adminMode && overdueData?.names) ? overdueData.names : [];
2026-05-03 19:51:57 -05:00
return (
2026-05-15 22:45:38 -05:00
<header className="sticky top-0 z-40 border-b border-border/80 bg-card/95 shadow-sm shadow-foreground/10 backdrop-blur-md supports-[backdrop-filter]:bg-card/90">
<div className="mx-auto flex h-16 w-full max-w-[1500px] items-center gap-4 px-4 sm:px-6 lg:px-8">
2026-05-03 19:51:57 -05:00
<BrandBlock adminMode={adminMode} />
2026-05-04 13:14:32 -05:00
<nav className="hidden items-center gap-1 lg:flex">
{!adminMode && <TrackerMenu badge={overdueCount} badgeNames={overdueNames} />}
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">
2026-05-16 15:38:28 -05:00
{!adminMode && (
<Button
type="button"
variant="outline"
className="hidden h-9 gap-2 rounded-full bg-muted px-3 text-muted-foreground shadow-sm md:inline-flex"
onClick={() => window.dispatchEvent(new Event('command-palette:open'))}
title="Find a bill"
>
<Search className="h-4 w-4" />
<span className="text-sm">Find</span>
<kbd className="rounded border border-border bg-background px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground">
Ctrl K
</kbd>
</Button>
)}
2026-05-15 22:45:38 -05:00
<ThemeToggle className="rounded-full border border-border/80 bg-muted shadow-sm" />
2026-05-03 19:51:57 -05:00
<UserMenu adminMode={adminMode} />
<Button
type="button"
variant="outline"
size="icon"
2026-05-15 22:45:38 -05:00
className="lg:hidden rounded-full bg-muted"
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-15 22:45:38 -05:00
<div className="max-h-[70vh] overflow-y-auto border-t border-border/70 bg-card/95 px-4 py-3 shadow-lg shadow-foreground/10 lg:hidden">
<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)}
badge={item.to === '/' ? overdueCount : undefined}
badgeNames={item.to === '/' ? overdueNames : undefined}
/>
2026-05-04 20:12:57 -05:00
))}
2026-05-03 19:51:57 -05:00
{items.map(item => (
<NavPill key={item.to} item={item} onNavigate={() => setMobileOpen(false)} />
))}
</nav>
</div>
)}
</header>
);
}