- Home sidebar icon: always cyan, bg-accent-dim only when route is "/" - Mobile TopBar: add left padding so hamburger isn't hidden behind logo - Landing page: bump card border color (#1e2130 → #2a2f3d) for better contrast - Replace all font-label references (40 occurrences, 19 files) with font-mono or font-sans - Remove deprecated --font-label CSS variable from index.css - Convert hardcoded hex in layout inline styles to CSS variables (light-mode ready) - Add @types/react-syntax-highlighter for script builder types Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
132 lines
4.9 KiB
TypeScript
132 lines
4.9 KiB
TypeScript
import { Link, useLocation } from 'react-router-dom'
|
|
import type { LucideIcon } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
import { prefetchForRoute } from '@/lib/routePrefetch'
|
|
|
|
interface NavSubItem {
|
|
href: string
|
|
label: string
|
|
count?: number
|
|
isActive?: boolean
|
|
}
|
|
|
|
interface NavItemProps {
|
|
href: string
|
|
icon: LucideIcon
|
|
label: string
|
|
badge?: number | 'dot'
|
|
iconColor?: string
|
|
matchPaths?: string[]
|
|
collapsed?: boolean
|
|
children?: NavSubItem[]
|
|
}
|
|
|
|
export function NavItem({ href, icon: Icon, label, badge, iconColor, matchPaths, collapsed, children }: NavItemProps) {
|
|
const location = useLocation()
|
|
const fullPath = location.pathname + location.search
|
|
const isActive = matchPaths
|
|
? matchPaths.some(p => location.pathname.startsWith(p))
|
|
: href === '/'
|
|
? location.pathname === '/'
|
|
: location.pathname.startsWith(href)
|
|
|
|
// Check if any child is specifically active
|
|
const activeChild = children?.find(c => fullPath === c.href || fullPath.startsWith(c.href + '&'))
|
|
const isParentDimmed = !!activeChild && isActive
|
|
|
|
if (collapsed) {
|
|
return (
|
|
<Link
|
|
to={href}
|
|
onMouseEnter={() => prefetchForRoute(href)}
|
|
className={cn(
|
|
'group relative flex items-center justify-center rounded-lg p-2 transition-all duration-120',
|
|
isActive
|
|
? 'bg-[var(--sidebar-active)] text-foreground'
|
|
: 'text-muted-foreground hover:bg-[var(--sidebar-hover)] hover:text-foreground'
|
|
)}
|
|
title={label}
|
|
>
|
|
{isActive && (
|
|
<div className="absolute left-0 top-1/2 h-6 w-[3px] -translate-y-1/2 rounded-r-full bg-primary" />
|
|
)}
|
|
<Icon size={18} className={cn('shrink-0', isActive ? 'opacity-100' : 'opacity-70')} style={iconColor ? { color: iconColor } : undefined} />
|
|
{badge !== undefined && badge !== 0 && badge !== 'dot' && (
|
|
<span className="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[0.5rem] font-bold text-primary-foreground">
|
|
{badge}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="group/nav">
|
|
<Link
|
|
to={href}
|
|
onMouseEnter={() => prefetchForRoute(href)}
|
|
className={cn(
|
|
'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-[0.8125rem] font-medium transition-all duration-120',
|
|
isActive
|
|
? isParentDimmed
|
|
? 'bg-[var(--sidebar-active)]/50 text-foreground/70'
|
|
: 'bg-[var(--sidebar-active)] text-foreground'
|
|
: 'text-muted-foreground hover:bg-[var(--sidebar-hover)] hover:text-foreground'
|
|
)}
|
|
>
|
|
{/* Active indicator bar */}
|
|
{isActive && !isParentDimmed && (
|
|
<div className="absolute left-0 top-1/2 h-6 w-[3px] -translate-y-1/2 rounded-r-full bg-primary" />
|
|
)}
|
|
|
|
<Icon size={18} className={cn('shrink-0', isActive ? 'opacity-100' : 'opacity-70')} style={iconColor ? { color: iconColor } : undefined} />
|
|
<span className="truncate">{label}</span>
|
|
|
|
{/* Badge */}
|
|
{badge !== undefined && badge !== 0 && (
|
|
badge === 'dot' ? (
|
|
<span className="ml-auto h-1.5 w-1.5 shrink-0 rounded-full bg-brand-gradient-from" />
|
|
) : (
|
|
<span className="ml-auto shrink-0 rounded-full bg-card border border-border px-2 text-[0.6875rem] font-mono text-muted-foreground">
|
|
{badge}
|
|
</span>
|
|
)
|
|
)}
|
|
</Link>
|
|
|
|
{/* Sub-items — visible on hover or when a child is active */}
|
|
{children && children.length > 0 && (
|
|
<div className={cn(
|
|
'mt-0.5 space-y-0.5 overflow-hidden transition-all duration-200',
|
|
isActive || activeChild
|
|
? 'max-h-40 opacity-100'
|
|
: 'max-h-0 opacity-0 group-hover/nav:max-h-40 group-hover/nav:opacity-100'
|
|
)}>
|
|
{children.map(child => {
|
|
const childActive = fullPath === child.href || fullPath.startsWith(child.href + '&')
|
|
return (
|
|
<Link
|
|
key={child.href}
|
|
to={child.href}
|
|
className={cn(
|
|
'flex items-center gap-2 rounded-lg pl-9 pr-3 py-1.5 text-[0.8125rem] font-medium transition-colors',
|
|
childActive
|
|
? 'bg-[var(--sidebar-active)] text-foreground'
|
|
: 'text-muted-foreground hover:bg-[var(--sidebar-hover)] hover:text-foreground'
|
|
)}
|
|
>
|
|
<span className="truncate">{child.label}</span>
|
|
{child.count !== undefined && (
|
|
<span className="ml-auto shrink-0 rounded-full bg-card border border-border px-2 text-[0.6875rem] font-mono text-muted-foreground">
|
|
{child.count}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|