MeshiTrack/packages/web/src/components/layout/Sidebar.tsx
2026-05-14 18:38:50 +09:00

287 lines
7.7 KiB
TypeScript

'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Icon } from '@/components/ui/Icon';
import type { IconName } from '@/components/ui/Icon';
import { Avatar } from '@/components/ui/Avatar';
import { useApi } from '@/lib/useApi';
interface NavItem {
id: string;
label: string;
href: string;
icon: IconName;
section?: string;
badge?: number;
}
const NAV: NavItem[] = [
{ id: 'dashboard', label: 'Dashboard', href: '/dashboard', icon: 'dashboard' },
{
id: 'cabinet',
label: 'Cabinet',
href: '/medicines/cabinet',
icon: 'cabinet',
section: 'Medicines',
},
{
id: 'schedule',
label: 'Schedule & Log',
href: '/medicines/schedule',
icon: 'clock',
section: 'Medicines',
},
{
id: 'regimens',
label: 'Regimens',
href: '/medicines/regimens',
icon: 'list',
section: 'Medicines',
},
{
id: 'organizer',
label: 'Pill Organizer',
href: '/medicines/organizer',
icon: 'calendar',
section: 'Medicines',
},
{
id: 'library',
label: 'Library',
href: '/medicines/library',
icon: 'pill',
section: 'Medicines',
},
{
id: 'refills',
label: 'Shopping list',
href: '/refills',
icon: 'refresh',
section: 'Medicines',
},
{ id: 'purchases', label: 'Purchases', href: '/purchases', icon: 'truck', section: 'Medicines' },
{ id: 'prices', label: 'Prices', href: '/medicine-prices', icon: 'tag', section: 'Medicines' },
{ id: 'stores', label: 'Stores', href: '/stores', icon: 'store', section: 'Medicines' },
{
id: 'activity',
label: 'Activity & Spend',
href: '/medicines/activity',
icon: 'trend',
section: 'Medicines',
},
{ id: 'settings', label: 'Settings', href: '/settings', icon: 'settings' },
{
id: 'meal-plans',
label: 'Meal Planner',
href: '/meal-plans',
icon: 'calendar',
section: 'Food',
},
{
id: 'recipes',
label: 'Recipes',
href: '/recipes',
icon: 'list',
section: 'Food',
},
{
id: 'pantry',
label: 'Pantry',
href: '/pantry',
icon: 'fridge',
section: 'Food',
},
{
id: 'products',
label: 'Product Library',
href: '/products',
icon: 'list',
section: 'Food',
},
];
function groupNav(items: NavItem[]): [string, NavItem[]][] {
const map = new Map<string, NavItem[]>();
for (const item of items) {
const key = item.section ?? '__root__';
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(item);
}
return Array.from(map.entries());
}
export function Sidebar() {
const pathname = usePathname();
const { profile } = useApi();
const displayName = profile?.displayName ?? 'User';
const groups = groupNav(NAV);
return (
<aside
style={{
background: 'var(--bg-elev)',
borderRight: '1px solid var(--border)',
display: 'flex',
flexDirection: 'column',
position: 'sticky',
top: 0,
height: '100vh',
width: 248,
flexShrink: 0,
}}
>
{/* Brand */}
<div
style={{
display: 'flex',
gap: 10,
alignItems: 'center',
padding: '18px 18px 14px',
borderBottom: '1px solid var(--border)',
}}
>
<div
style={{
width: 34,
height: 34,
borderRadius: 10,
display: 'grid',
placeItems: 'center',
background: 'var(--brand-soft)',
flexShrink: 0,
}}
>
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="5" fill="var(--brand)" />
<path
d="M7 15l3-6 2 4 2-3 3 5"
stroke="var(--brand-ink)"
strokeWidth="1.8"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<div>
<div
style={{
fontFamily: 'var(--font-display)',
fontSize: 17,
fontWeight: 600,
letterSpacing: '-0.02em',
color: 'var(--ink-strong)',
}}
>
MeshiTrack
</div>
</div>
</div>
{/* Navigation */}
<nav
style={{ flex: 1, overflowY: 'auto', padding: '10px 10px 16px' }}
aria-label="Main navigation"
>
{groups.map(([section, items]) => (
<div key={section} style={{ marginBottom: 14 }}>
{section !== '__root__' && (
<div
style={{
fontSize: 10,
fontWeight: 600,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: 'var(--ink-faint)',
padding: '10px 10px 6px',
}}
>
{section}
</div>
)}
{items.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
return (
<Link
key={item.id}
href={item.href}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
width: '100%',
padding: '7px 10px',
borderRadius: 'var(--r-sm)',
fontSize: 13,
color: isActive ? 'var(--brand-soft-ink)' : 'var(--ink-muted)',
background: isActive ? 'var(--brand-soft)' : 'transparent',
fontWeight: isActive ? 500 : 400,
transition: 'background 0.1s, color 0.1s',
whiteSpace: 'nowrap',
overflow: 'hidden',
textDecoration: 'none',
}}
>
<Icon
name={item.icon}
size={16}
style={{ opacity: isActive ? 1 : 0.8, flexShrink: 0 }}
/>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{item.label}
</span>
{item.badge != null && (
<span
style={{
background: 'var(--danger-soft)',
color: 'var(--danger)',
fontSize: 10,
fontWeight: 600,
padding: '1px 6px',
borderRadius: 8,
minWidth: 18,
textAlign: 'center',
fontVariantNumeric: 'tabular-nums',
}}
>
{item.badge}
</span>
)}
</Link>
);
})}
</div>
))}
</nav>
{/* Footer */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '12px 14px',
borderTop: '1px solid var(--border)',
}}
>
<Avatar name={displayName} size={32} />
<div style={{ minWidth: 0 }}>
<div
style={{
fontSize: 13,
fontWeight: 500,
color: 'var(--ink-strong)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{displayName}
</div>
<div style={{ fontSize: 11, color: 'var(--ink-muted)' }}>Household owner</div>
</div>
</div>
</aside>
);
}