Files
resolutionflow/frontend/src/components/layout/Sidebar.tsx
chihlasm 8fd4207ee6 refactor: charcoal palette + sidebar drawer fixes
- Switch from true-dark to charcoal palette (sidebar darkest, content lighter)
  Page #1a1c23, Sidebar #10121a, Card #22252e, Border #2e3240
- Update all Tailwind semantic mappings to match new palette
- Update landing page CSS hardcoded hex to new palette values
- Fix remaining hardcoded hex in SurveyResponsesPage, SessionsPanel, FlowPilotMessageBar
- Sidebar drawer starts below topbar (top: var(--topbar-h)) instead of viewport top
- Drawer section title uses amber (#fbbf24) for visual pop
- Unify all rail icons: white when inactive, cyan with bg-accent-dim when active

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 21:39:05 +00:00

498 lines
20 KiB
TypeScript

import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
import { Link, useLocation } from 'react-router-dom'
import type { LucideIcon } from 'lucide-react'
import {
LayoutGrid, Clock, AlertTriangle, GitBranch, Layers, Code2, Wand2,
ListChecks, Download, BarChart3, Rocket,
Settings, Pin, PinOff,
Zap, Database, HelpCircle,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import { sidebarApi } from '@/api'
import type { SidebarStatsResponse } from '@/api/sidebar'
import { prefetchForRoute } from '@/lib/routePrefetch'
/* ── Types ──────────────────────────────────────────── */
interface NavSubItem {
href: string
label: string
count?: number
}
interface NavEntry {
href: string
icon: LucideIcon
label: string
shortLabel: string
badge?: number
matchPaths?: string[]
children?: NavSubItem[]
}
interface NavSection {
title: string
items: NavEntry[]
}
/* ── Sidebar component ──────────────────────────────── */
export function Sidebar() {
const location = useLocation()
const sidebarPinned = useUserPreferencesStore(s => s.sidebarPinned)
const toggleSidebarPinned = useUserPreferencesStore(s => s.toggleSidebarPinned)
const [stats, setStats] = useState<SidebarStatsResponse | null>(null)
const [flyoutIndex, setFlyoutIndex] = useState<string | null>(null)
const flyoutTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
const sidebarRef = useRef<HTMLElement>(null)
/* ── Stats fetching ───────────────────────────────── */
const refreshStats = useCallback(() => {
sidebarApi.getStats().then(setStats).catch(() => {})
}, [])
useEffect(() => { refreshStats() }, [location.pathname, refreshStats])
useEffect(() => {
window.addEventListener('session-changed', refreshStats)
return () => window.removeEventListener('session-changed', refreshStats)
}, [refreshStats])
/* ── Navigation data ──────────────────────────────── */
/* ── Grouped nav: 5 top-level icons (Sentry-style) ── */
const railGroups: NavEntry[] = [
{
href: '/', icon: LayoutGrid, label: 'Home', shortLabel: 'Home',
matchPaths: ['/'],
},
{
href: '/sessions', icon: Zap, label: 'Work', shortLabel: 'Work',
badge: (stats?.active_count || 0) + (stats?.escalation_count || 0) || undefined,
matchPaths: ['/sessions', '/escalations', '/pilot'],
children: [
{ href: '/sessions', label: 'Active Sessions', count: stats?.active_count || undefined },
{ href: '/escalations', label: 'Escalations', count: stats?.escalation_count || undefined },
],
},
{
href: '/trees', icon: Database, label: 'Knowledge', shortLabel: 'Know',
badge: stats?.tree_counts.total || undefined,
matchPaths: ['/trees', '/flows', '/my-trees', '/step-library', '/scripts', '/script-builder', '/review-queue'],
children: [
{ href: '/trees', label: 'All Flows', count: stats?.tree_counts.total || undefined },
{ href: '/trees?type=troubleshooting', label: 'Troubleshooting', count: stats?.tree_counts.troubleshooting || undefined },
{ href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined },
{ href: '/trees?type=maintenance', label: 'Maintenance', count: stats?.tree_counts.maintenance || undefined },
{ href: '/step-library', label: 'Step Library' },
{ href: '/scripts', label: 'Scripts' },
{ href: '/script-builder', label: 'Script Builder' },
{ href: '/review-queue', label: 'Review Queue' },
],
},
{
href: '/analytics', icon: BarChart3, label: 'Insights', shortLabel: 'Data',
matchPaths: ['/analytics', '/shares'],
children: [
{ href: '/shares', label: 'Exports' },
{ href: '/analytics', label: 'Analytics' },
{ href: '/analytics/flowpilot', label: 'FlowPilot Analytics' },
],
},
{
href: '/guides', icon: HelpCircle, label: 'Help', shortLabel: 'Help',
matchPaths: ['/guides', '/feedback'],
children: [
{ href: '/guides', label: 'User Guides' },
{ href: '/feedback', label: 'Feedback' },
],
},
]
/* Pinned mode still uses the detailed section layout */
const sections: NavSection[] = [
{
title: 'RESOLVE',
items: [
{ href: '/', icon: LayoutGrid, label: 'Dashboard', shortLabel: 'Dash' },
{ href: '/sessions', icon: Clock, label: 'Active Sessions', shortLabel: 'Sessions', badge: stats?.active_count || undefined, matchPaths: ['/sessions'] },
{ href: '/escalations', icon: AlertTriangle, label: 'Escalations', shortLabel: 'Escal', badge: stats?.escalation_count || undefined },
],
},
{
title: 'KNOWLEDGE',
items: [
{
href: '/trees', icon: GitBranch, label: 'Flows', shortLabel: 'Flows',
badge: stats?.tree_counts.total || undefined,
matchPaths: ['/trees', '/flows', '/my-trees'],
children: [
{ href: '/trees', label: 'All Flows' },
{ href: '/trees?type=troubleshooting', label: 'Troubleshooting', count: stats?.tree_counts.troubleshooting || undefined },
{ href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined },
{ href: '/trees?type=maintenance', label: 'Maintenance', count: stats?.tree_counts.maintenance || undefined },
],
},
{ href: '/step-library', icon: Layers, label: 'Step Library', shortLabel: 'Steps' },
{ href: '/scripts', icon: Code2, label: 'Scripts', shortLabel: 'Scripts' },
{ href: '/script-builder', icon: Wand2, label: 'Script Builder', shortLabel: 'Builder' },
{ href: '/review-queue', icon: ListChecks, label: 'Review Queue', shortLabel: 'Review' },
],
},
{
title: 'INSIGHTS',
items: [
{ href: '/shares', icon: Download, label: 'Exports', shortLabel: 'Export' },
{ href: '/analytics', icon: BarChart3, label: 'Analytics', shortLabel: 'Stats' },
{ href: '/analytics/flowpilot', icon: Rocket, label: 'FlowPilot Analytics', shortLabel: 'FPilot' },
],
},
]
const footerItems: NavEntry[] = [
{ href: '/account', icon: Settings, label: 'Account', shortLabel: 'Acct' },
]
/* ── Active detection ─────────────────────────────── */
const isActive = (item: NavEntry) => {
if (item.matchPaths) return item.matchPaths.some(p =>
p === '/' ? location.pathname === '/' : location.pathname.startsWith(p)
)
if (item.href === '/') return location.pathname === '/'
return location.pathname.startsWith(item.href)
}
const isChildActive = (child: NavSubItem) => {
const fullPath = location.pathname + location.search
return fullPath === child.href || fullPath.startsWith(child.href + '&')
}
/* ── Flyout management ────────────────────────────── */
const openFlyout = (key: string) => {
if (flyoutTimeout.current) clearTimeout(flyoutTimeout.current)
setFlyoutIndex(key)
}
const closeFlyout = () => {
flyoutTimeout.current = setTimeout(() => setFlyoutIndex(null), 400)
}
const keepFlyout = () => {
if (flyoutTimeout.current) clearTimeout(flyoutTimeout.current)
}
/* ── Drawer resize ───────────────────────────────── */
const [drawerWidth, setDrawerWidth] = useState(240)
const isResizing = useRef(false)
const handleResizeStart = (e: ReactPointerEvent) => {
e.preventDefault()
isResizing.current = true
const startX = e.clientX
const startWidth = drawerWidth
const onMove = (ev: globalThis.PointerEvent) => {
if (!isResizing.current) return
const newWidth = Math.max(180, Math.min(400, startWidth + (ev.clientX - startX)))
setDrawerWidth(newWidth)
}
const onUp = () => {
isResizing.current = false
document.removeEventListener('pointermove', onMove)
document.removeEventListener('pointerup', onUp)
}
document.addEventListener('pointermove', onMove)
document.addEventListener('pointerup', onUp)
}
/* Close flyout on Escape */
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setFlyoutIndex(null)
}
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [])
/* ── Wheel forwarding (when sidebar can't scroll) ── */
const handleWheel = (e: React.WheelEvent<HTMLElement>) => {
const el = e.currentTarget
const canScroll = el.scrollHeight > el.clientHeight
const atTop = el.scrollTop <= 0
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1
if (!canScroll || (e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) {
const main = document.querySelector('.main-content') as HTMLElement | null
if (main) { main.scrollTop += e.deltaY; e.preventDefault() }
}
}
/* ── Render helpers ───────────────────────────────── */
const renderRailItem = (item: NavEntry, key: string) => {
const active = isActive(item)
const Icon = item.icon
const hasChildren = item.children && item.children.length > 0
return (
<div
key={key}
className="relative w-full"
onMouseEnter={() => hasChildren && !sidebarPinned ? openFlyout(key) : undefined}
>
<Link
to={item.href}
onMouseEnter={() => prefetchForRoute(item.href)}
onFocus={() => hasChildren && !sidebarPinned ? openFlyout(key) : undefined}
className={cn(
'group relative flex flex-col items-center justify-center rounded-lg px-2 py-3 transition-all duration-150',
active
? 'bg-accent-dim text-accent-text'
: 'text-text-rail-label hover:text-foreground'
)}
title={item.label}
>
<span className="relative">
<Icon size={24} strokeWidth={1.6} className={active ? 'opacity-100' : 'opacity-60 group-hover:opacity-85'} />
{item.badge !== undefined && item.badge > 0 && (
<span className="absolute -right-2 -top-1.5 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-primary px-1 text-[0.5rem] font-bold text-background">
{item.badge > 99 ? '99+' : item.badge}
</span>
)}
</span>
<span className="mt-1.5 text-[0.625rem] font-sans font-medium leading-tight truncate max-w-[64px]">
{item.shortLabel}
</span>
</Link>
{/* Flyout rendered as drawer below */}
</div>
)
}
const renderPinnedItem = (item: NavEntry, key: string) => {
const active = isActive(item)
const Icon = item.icon
const fullPath = location.pathname + location.search
const activeChild = item.children?.find(c => fullPath === c.href || fullPath.startsWith(c.href + '&'))
const isParentDimmed = !!activeChild && active
return (
<div key={key} className="group/nav">
<Link
to={item.href}
onMouseEnter={() => prefetchForRoute(item.href)}
className={cn(
'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-[0.8125rem] font-medium transition-all duration-150',
active
? isParentDimmed
? 'bg-[rgba(34,211,238,0.05)] text-foreground/70'
: 'bg-accent-dim text-foreground'
: 'text-muted-foreground hover:bg-input hover:text-foreground'
)}
>
{active && !isParentDimmed && (
<div
className="absolute left-0 top-1/2 h-6 w-[3px] -translate-y-1/2"
style={{ background: 'var(--color-accent)', borderRadius: '0 3px 3px 0' }}
/>
)}
<Icon size={18} className={cn('shrink-0', active ? 'opacity-100' : 'opacity-70')} />
<span className="truncate">{item.label}</span>
{item.badge !== undefined && item.badge > 0 && (
<span className="ml-auto shrink-0 rounded-full px-2 text-[0.6875rem] font-mono text-text-muted"
style={{ background: 'var(--color-bg-card)', border: '1px solid var(--color-border-default)' }}>
{item.badge}
</span>
)}
</Link>
{/* Sub-items for pinned mode */}
{item.children && item.children.length > 0 && (
<div className={cn(
'mt-0.5 space-y-0.5 overflow-hidden transition-all duration-200',
active || activeChild
? 'max-h-40 opacity-100'
: 'max-h-0 opacity-0 group-hover/nav:max-h-40 group-hover/nav:opacity-100'
)}>
{item.children.map(child => {
const childActive = isChildActive(child)
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-accent-dim text-foreground'
: 'text-muted-foreground hover:bg-input hover:text-foreground'
)}
>
<span className="truncate">{child.label}</span>
{child.count !== undefined && (
<span className="ml-auto shrink-0 rounded-full px-2 text-[0.6875rem] font-mono text-text-muted"
style={{ background: 'var(--color-bg-card)', border: '1px solid var(--color-border-default)' }}>
{child.count}
</span>
)}
</Link>
)
})}
</div>
)}
</div>
)
}
/* ── Find active flyout group for drawer ── */
const activeFlyoutGroup = flyoutIndex && !sidebarPinned
? railGroups.find((_, i) => `rail-${i}` === flyoutIndex) ||
footerItems.find((_, i) => `footer-${i}` === flyoutIndex)
: null
/* ── Main render ──────────────────────────────────── */
if (sidebarPinned) {
return (
<nav
ref={sidebarRef}
className="sidebar flex flex-col h-full"
style={{ background: 'var(--color-bg-sidebar)', borderRight: '1px solid var(--color-border-default)' }}
onWheel={handleWheel}
>
{/* Pinned sidebar content */}
<div className="px-3 py-2 space-y-0.5">
{sections.map((section, si) => (
<div key={section.title}>
{si > 0 && (
<div className="font-mono text-[0.5625rem] uppercase tracking-[0.12em] text-text-muted px-3 pt-3 pb-1">
{section.title}
</div>
)}
{section.items.map((item, ii) => renderPinnedItem(item, `${si}-${ii}`))}
</div>
))}
</div>
<div className="flex-1" />
{/* Footer */}
<div className="px-3 pt-2 pb-4 space-y-0.5" style={{ borderTop: '1px solid var(--color-border-default)' }}>
{footerItems.map((item, i) => renderPinnedItem(item, `footer-${i}`))}
<button
type="button"
onClick={toggleSidebarPinned}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-[0.8125rem] font-medium text-muted-foreground hover:bg-input hover:text-foreground transition-colors"
title="Unpin sidebar"
>
<PinOff size={18} className="shrink-0" />
<span>Unpin</span>
</button>
</div>
</nav>
)
}
/* Icon Rail (default) — 5 grouped icons, Sentry-style */
return (
<div
className="flex h-full"
onMouseLeave={closeFlyout}
>
{/* Rail */}
<nav
ref={sidebarRef}
className="sidebar flex flex-col items-center h-full shrink-0"
style={{ background: 'var(--color-bg-sidebar)', borderRight: '1px solid var(--color-border-default)', width: '72px' }}
onWheel={handleWheel}
>
{/* Grouped nav items */}
<div className="flex flex-col items-center w-full px-1 pt-4 space-y-1.5">
{railGroups.map((item, i) => renderRailItem(item, `rail-${i}`))}
</div>
<div className="flex-1" />
{/* Footer: Account + Pin */}
<div className="flex flex-col items-center w-full px-1 pb-5 pt-3 space-y-1.5" style={{ borderTop: '1px solid var(--color-border-default)' }}>
{footerItems.map((item, i) => renderRailItem(item, `footer-${i}`))}
<button
type="button"
onClick={toggleSidebarPinned}
className="flex flex-col items-center justify-center rounded-lg px-2 py-3 text-text-rail-label hover:text-muted-foreground transition-colors"
title="Pin sidebar"
>
<Pin size={22} strokeWidth={1.6} className="opacity-60 hover:opacity-85" />
<span className="mt-1.5 text-[0.625rem] font-sans font-medium leading-tight">Pin</span>
</button>
</div>
</nav>
{/* Drawer panel — fixed position, full height, resizable, overlays main content */}
{activeFlyoutGroup && activeFlyoutGroup.children && (
<div
className="fixed bottom-0 z-50 flex"
style={{ top: 'var(--topbar-h)', left: '72px' }}
onMouseEnter={keepFlyout}
onMouseLeave={closeFlyout}
>
<div
className="flex flex-col h-full overflow-y-auto py-4 px-2"
style={{
width: drawerWidth,
background: 'var(--color-bg-sidebar)',
borderRight: '1px solid var(--color-border-default)',
boxShadow: '4px 0 12px rgba(0,0,0,0.2)',
}}
>
{/* Drawer header */}
<div className="px-3 mb-3">
<h3 className="text-[0.6875rem] font-mono uppercase tracking-[0.12em] text-[#fbbf24]">
{activeFlyoutGroup.label}
</h3>
</div>
{/* Drawer items */}
<div className="space-y-0.5 px-1">
{activeFlyoutGroup.children.map(child => (
<Link
key={child.href}
to={child.href}
className={cn(
'flex items-center justify-between rounded-md px-3 py-2 text-[0.8125rem] transition-colors',
isChildActive(child)
? 'bg-accent-dim text-accent-text'
: 'text-muted-foreground hover:bg-input hover:text-foreground'
)}
>
<span>{child.label}</span>
{child.count !== undefined && (
<span className="text-[0.6875rem] font-mono text-text-muted">{child.count}</span>
)}
</Link>
))}
</div>
</div>
{/* Resize handle */}
<div
className="w-1 cursor-col-resize hover:bg-primary/20 active:bg-primary/30 transition-colors shrink-0"
onPointerDown={handleResizeStart}
title="Drag to resize"
/>
</div>
)}
</div>
)
}