refactor: implement icon rail sidebar for Design System v4
72px icon rail with hover flyouts, pin-to-expand toggle (260px), keyboard accessible, mobile hamburger overlay. Flat TopBar styling (no blur/glass). New BrandLogo mark (gradient square + lightning bolt). BrandWordmark uses solid text color instead of gradient. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,37 +5,36 @@ interface BrandLogoProps {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brand logo mark: gradient cyan square with rounded corners
|
||||||
|
* containing a white lightning bolt.
|
||||||
|
*/
|
||||||
export function BrandLogo({ size = 'sm', className }: BrandLogoProps) {
|
export function BrandLogo({ size = 'sm', className }: BrandLogoProps) {
|
||||||
const sizeClasses = size === 'sm' ? 'h-8 w-8' : 'h-20 w-20'
|
const dim = size === 'sm' ? 30 : 64
|
||||||
const strokeBase = size === 'sm' ? 1 : 2
|
|
||||||
const strokeThick = size === 'sm' ? 1.25 : 2.5
|
|
||||||
const dashArray = size === 'sm' ? '1 1.5' : '2 3'
|
|
||||||
const nodeR = size === 'sm' ? { outer: 2.5, inner: 2.75 } : { outer: 5, inner: 5.5 }
|
|
||||||
const hubR = size === 'sm' ? { glow: 5, solid: 3.5 } : { glow: 10, solid: 7 }
|
|
||||||
const vb = size === 'sm' ? '0 0 40 40' : '0 0 80 80'
|
|
||||||
const s = size === 'sm' ? 1 : 2
|
|
||||||
const gradId = size === 'sm' ? 'logoGradSm' : 'logoGradLg'
|
|
||||||
const gradEnd = String(40 * (size === 'sm' ? 1 : 2))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg viewBox={vb} fill="none" className={cn(sizeClasses, className)}>
|
<div
|
||||||
<defs>
|
className={cn('shrink-0 flex items-center justify-center', className)}
|
||||||
<linearGradient id={gradId} x1="0" y1="0" x2={gradEnd} y2={gradEnd} gradientUnits="userSpaceOnUse">
|
style={{
|
||||||
<stop offset="0%" stopColor="#06b6d4" />
|
width: dim,
|
||||||
<stop offset="100%" stopColor="#22d3ee" />
|
height: dim,
|
||||||
</linearGradient>
|
borderRadius: size === 'sm' ? 8 : 14,
|
||||||
</defs>
|
background: 'linear-gradient(135deg, #06b6d4, #22d3ee)',
|
||||||
<circle cx={5 * s} cy={7 * s} r={nodeR.outer} fill={`url(#${gradId})`} opacity="0.5" />
|
}}
|
||||||
<circle cx={5 * s} cy={15 * s} r={nodeR.inner} fill={`url(#${gradId})`} opacity="0.7" />
|
>
|
||||||
<circle cx={5 * s} cy={25 * s} r={nodeR.inner} fill={`url(#${gradId})`} opacity="0.7" />
|
<svg
|
||||||
<circle cx={5 * s} cy={33 * s} r={nodeR.outer} fill={`url(#${gradId})`} opacity="0.5" />
|
viewBox="0 0 24 24"
|
||||||
<path d={`M${7.5 * s} ${7 * s}L${14 * s} ${17 * s}`} stroke={`url(#${gradId})`} strokeWidth={strokeBase} strokeLinecap="round" strokeDasharray={dashArray} opacity="0.4" />
|
fill="none"
|
||||||
<path d={`M${7.75 * s} ${15 * s}L${14 * s} ${19 * s}`} stroke={`url(#${gradId})`} strokeWidth={strokeBase} strokeLinecap="round" opacity="0.5" />
|
style={{ width: dim * 0.5, height: dim * 0.5 }}
|
||||||
<path d={`M${7.75 * s} ${25 * s}L${14 * s} ${21 * s}`} stroke={`url(#${gradId})`} strokeWidth={strokeBase} strokeLinecap="round" opacity="0.5" />
|
>
|
||||||
<path d={`M${7.5 * s} ${33 * s}L${14 * s} ${23 * s}`} stroke={`url(#${gradId})`} strokeWidth={strokeBase} strokeLinecap="round" strokeDasharray={dashArray} opacity="0.4" />
|
<path
|
||||||
<circle cx={18 * s} cy={20 * s} r={hubR.glow} fill={`url(#${gradId})`} opacity="0.15" />
|
d="M13 2L4.5 13.5H12L11 22L19.5 10.5H12L13 2Z"
|
||||||
<circle cx={18 * s} cy={20 * s} r={hubR.solid} fill={`url(#${gradId})`} opacity="0.9" />
|
fill="white"
|
||||||
<path d={`M${21.5 * s} ${20 * s}H${35 * s}M${35 * s} ${20 * s}L${30 * s} ${15 * s}M${35 * s} ${20 * s}L${30 * s} ${25 * s}`} stroke={`url(#${gradId})`} strokeWidth={strokeThick} strokeLinecap="round" strokeLinejoin="round" />
|
stroke="white"
|
||||||
</svg>
|
strokeWidth="0.5"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,13 +9,12 @@ export function BrandWordmark({ size = 'sm', className }: BrandWordmarkProps) {
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-heading font-bold tracking-tight',
|
'font-heading font-bold tracking-tight text-[#f0f2f5]',
|
||||||
size === 'sm' ? 'text-xl' : 'text-3xl',
|
size === 'sm' ? 'text-xl' : 'text-3xl',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="text-foreground">Resolution</span>
|
ResolutionFlow
|
||||||
<span className="text-gradient-brand">Flow</span>
|
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { useLocation, useNavigate, Link } from 'react-router-dom'
|
import { useLocation, useNavigate, Link } from 'react-router-dom'
|
||||||
import { Menu, X, LayoutGrid, Clock, Network, AlertTriangle, Code2, Wand2, BarChart3, Settings, LogOut, Shield, Library } from 'lucide-react'
|
import { Menu, X, LayoutGrid, Clock, AlertTriangle, GitBranch, Code2, Wand2, BarChart3, Settings, LogOut, Shield, Layers } from 'lucide-react'
|
||||||
import { useAuthStore } from '@/store/authStore'
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { usePermissions } from '@/hooks/usePermissions'
|
import { usePermissions } from '@/hooks/usePermissions'
|
||||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||||
@@ -16,7 +16,7 @@ export function AppLayout() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { user, logout } = useAuthStore()
|
const { user, logout } = useAuthStore()
|
||||||
const { effectiveRole } = usePermissions()
|
const { effectiveRole } = usePermissions()
|
||||||
const sidebarCollapsed = useUserPreferencesStore(s => s.sidebarCollapsed)
|
const sidebarPinned = useUserPreferencesStore(s => s.sidebarPinned)
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||||
|
|
||||||
// Close mobile menu on route change
|
// Close mobile menu on route change
|
||||||
@@ -54,8 +54,8 @@ export function AppLayout() {
|
|||||||
{ path: '/', label: 'Dashboard', icon: LayoutGrid },
|
{ path: '/', label: 'Dashboard', icon: LayoutGrid },
|
||||||
{ path: '/sessions', label: 'Active Sessions', icon: Clock },
|
{ path: '/sessions', label: 'Active Sessions', icon: Clock },
|
||||||
{ path: '/escalations', label: 'Escalations', icon: AlertTriangle },
|
{ path: '/escalations', label: 'Escalations', icon: AlertTriangle },
|
||||||
{ path: '/trees', label: 'Flows', icon: Network },
|
{ path: '/trees', label: 'Flows', icon: GitBranch },
|
||||||
{ path: '/step-library', label: 'Step Library', icon: Library },
|
{ path: '/step-library', label: 'Step Library', icon: Layers },
|
||||||
{ path: '/scripts', label: 'Scripts', icon: Code2 },
|
{ path: '/scripts', label: 'Scripts', icon: Code2 },
|
||||||
{ path: '/script-builder', label: 'Script Builder', icon: Wand2 },
|
{ path: '/script-builder', label: 'Script Builder', icon: Wand2 },
|
||||||
{ path: '/analytics', label: 'Analytics', icon: BarChart3 },
|
{ path: '/analytics', label: 'Analytics', icon: BarChart3 },
|
||||||
@@ -64,34 +64,8 @@ export function AppLayout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Atmosphere orbs — ambient light behind glass */}
|
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none fixed z-0"
|
className={cn('app-shell relative z-1', sidebarPinned && 'app-shell--pinned')}
|
||||||
style={{
|
|
||||||
top: '-120px',
|
|
||||||
right: '-80px',
|
|
||||||
width: '600px',
|
|
||||||
height: '600px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: 'radial-gradient(circle, rgba(6, 182, 212, 0.15) 0%, rgba(6, 182, 212, 0.04) 40%, transparent 70%)',
|
|
||||||
filter: 'blur(60px)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="pointer-events-none fixed z-0"
|
|
||||||
style={{
|
|
||||||
bottom: '-100px',
|
|
||||||
left: '-60px',
|
|
||||||
width: '500px',
|
|
||||||
height: '500px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: 'radial-gradient(circle, rgba(99, 102, 241, 0.08) 0%, rgba(99, 102, 241, 0.02) 40%, transparent 70%)',
|
|
||||||
filter: 'blur(50px)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn('app-shell relative z-1', sidebarCollapsed && 'app-shell--collapsed')}
|
|
||||||
data-testid="app-shell"
|
data-testid="app-shell"
|
||||||
>
|
>
|
||||||
{/* Top Bar - spans full width */}
|
{/* Top Bar - spans full width */}
|
||||||
@@ -104,8 +78,9 @@ export function AppLayout() {
|
|||||||
|
|
||||||
{/* Mobile hamburger - overlaid on topbar */}
|
{/* Mobile hamburger - overlaid on topbar */}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setMobileMenuOpen(true)}
|
onClick={() => setMobileMenuOpen(true)}
|
||||||
className="fixed left-4 top-3.5 z-50 rounded-lg p-2 text-muted-foreground hover:bg-card hover:text-foreground transition-colors md:hidden"
|
className="fixed left-4 top-3.5 z-50 rounded-lg p-2 text-[#848b9b] hover:bg-[#14161d] hover:text-[#e2e5eb] transition-colors md:hidden"
|
||||||
aria-label="Open menu"
|
aria-label="Open menu"
|
||||||
>
|
>
|
||||||
<Menu size={20} />
|
<Menu size={20} />
|
||||||
@@ -119,17 +94,19 @@ export function AppLayout() {
|
|||||||
onClick={() => setMobileMenuOpen(false)}
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<nav className="absolute inset-y-0 left-0 w-72 border-r border-border bg-[var(--sidebar-bg)] shadow-2xl animate-slide-in-left">
|
<nav
|
||||||
<div className="flex h-14 items-center justify-between border-b border-border px-4">
|
className="absolute inset-y-0 left-0 w-72 shadow-2xl animate-slide-in-left"
|
||||||
|
style={{ background: '#0f1118', borderRight: '1px solid #1e2130' }}
|
||||||
|
>
|
||||||
|
<div className="flex h-14 items-center justify-between px-4" style={{ borderBottom: '1px solid #1e2130' }}>
|
||||||
<Link to="/" className="flex items-center gap-2.5">
|
<Link to="/" className="flex items-center gap-2.5">
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-brand">
|
<BrandLogo size="sm" />
|
||||||
<BrandLogo size="sm" className="h-4 w-4" />
|
<span className="text-sm font-heading font-bold text-[#f0f2f5]">ResolutionFlow</span>
|
||||||
</div>
|
|
||||||
<span className="text-sm font-heading font-bold">ResolutionFlow</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
className="rounded-lg p-2 text-muted-foreground hover:bg-card hover:text-foreground"
|
className="rounded-lg p-2 text-[#848b9b] hover:bg-[#14161d] hover:text-[#e2e5eb]"
|
||||||
aria-label="Close menu"
|
aria-label="Close menu"
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
@@ -138,10 +115,10 @@ export function AppLayout() {
|
|||||||
|
|
||||||
<div className="flex flex-col p-3">
|
<div className="flex flex-col p-3">
|
||||||
{/* User info */}
|
{/* User info */}
|
||||||
<div className="mb-3 border-b border-border pb-3 px-3">
|
<div className="mb-3 pb-3 px-3" style={{ borderBottom: '1px solid #1e2130' }}>
|
||||||
<p className="text-sm font-medium text-foreground">{user?.name || user?.email}</p>
|
<p className="text-sm font-medium text-[#e2e5eb]">{user?.name || user?.email}</p>
|
||||||
{effectiveRole && effectiveRole !== 'engineer' && (
|
{effectiveRole && effectiveRole !== 'engineer' && (
|
||||||
<span className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground">
|
<span className="mt-1 inline-flex items-center gap-1 text-xs text-[#848b9b]">
|
||||||
<Shield size={10} />
|
<Shield size={10} />
|
||||||
{effectiveRole === 'super_admin' ? 'Super Admin' : effectiveRole === 'owner' ? 'Owner' : 'Viewer'}
|
{effectiveRole === 'super_admin' ? 'Super Admin' : effectiveRole === 'owner' ? 'Owner' : 'Viewer'}
|
||||||
</span>
|
</span>
|
||||||
@@ -162,8 +139,8 @@ export function AppLayout() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
|
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-[var(--sidebar-active)] text-foreground'
|
? 'bg-[rgba(34,211,238,0.10)] text-[#e2e5eb]'
|
||||||
: 'text-muted-foreground hover:bg-[var(--sidebar-hover)] hover:text-foreground'
|
: 'text-[#848b9b] hover:bg-[#191c25] hover:text-[#e2e5eb]'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon size={18} />
|
<Icon size={18} />
|
||||||
@@ -174,10 +151,11 @@ export function AppLayout() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logout */}
|
{/* Logout */}
|
||||||
<div className="mt-3 border-t border-border pt-3">
|
<div className="mt-3 pt-3" style={{ borderTop: '1px solid #1e2130' }}>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-muted-foreground hover:bg-[var(--sidebar-hover)] hover:text-foreground transition-colors"
|
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-[#848b9b] hover:bg-[#191c25] hover:text-[#e2e5eb] transition-colors"
|
||||||
>
|
>
|
||||||
<LogOut size={18} />
|
<LogOut size={18} />
|
||||||
Logout
|
Logout
|
||||||
|
|||||||
@@ -1,173 +1,420 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
LayoutGrid, Network, Clock, FileOutput, BarChart3, TrendingUp,
|
LayoutGrid, Clock, AlertTriangle, GitBranch, Layers, Code2, Wand2,
|
||||||
Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText, ListChecks,
|
ListChecks, Download, BarChart3, Rocket, BookOpen, MessageSquare,
|
||||||
BookOpen, Code2, Library, AlertTriangle, Wand2,
|
Settings, Pin, PinOff,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||||
import { sidebarApi } from '@/api'
|
import { sidebarApi } from '@/api'
|
||||||
import type { SidebarStatsResponse } from '@/api/sidebar'
|
import type { SidebarStatsResponse } from '@/api/sidebar'
|
||||||
import { NavItem } from './NavItem'
|
import { prefetchForRoute } from '@/lib/routePrefetch'
|
||||||
|
|
||||||
// Semantic icon colors — each nav item gets a unique color for visual landmarks
|
/* ── Types ──────────────────────────────────────────── */
|
||||||
const NAV_COLORS = {
|
|
||||||
dashboard: '#22d3ee', // cyan-400
|
interface NavSubItem {
|
||||||
flows: '#a78bfa', // violet-400
|
href: string
|
||||||
sessions: '#34d399', // emerald-400
|
label: string
|
||||||
exports: '#60a5fa', // blue-400
|
count?: number
|
||||||
stepLib: '#fb923c', // orange-400
|
}
|
||||||
scripts: '#2dd4bf', // teal-400
|
|
||||||
scriptBuilder: '#e879f9', // fuchsia-400
|
interface NavEntry {
|
||||||
analytics: '#38bdf8', // sky-400
|
href: string
|
||||||
guides: '#a3e635', // lime-400
|
icon: LucideIcon
|
||||||
feedback: '#818cf8', // indigo-400
|
label: string
|
||||||
} as const
|
shortLabel: string
|
||||||
|
badge?: number
|
||||||
|
matchPaths?: string[]
|
||||||
|
children?: NavSubItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavSection {
|
||||||
|
title: string
|
||||||
|
items: NavEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sidebar component ──────────────────────────────── */
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const sidebarCollapsed = useUserPreferencesStore(s => s.sidebarCollapsed)
|
|
||||||
const toggleSidebar = useUserPreferencesStore(s => s.toggleSidebar)
|
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
const sidebarPinned = useUserPreferencesStore(s => s.sidebarPinned)
|
||||||
|
const toggleSidebarPinned = useUserPreferencesStore(s => s.toggleSidebarPinned)
|
||||||
|
|
||||||
const [stats, setStats] = useState<SidebarStatsResponse | null>(null)
|
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(() => {
|
const refreshStats = useCallback(() => {
|
||||||
sidebarApi.getStats().then(setStats).catch(() => {})
|
sidebarApi.getStats().then(setStats).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Fetch sidebar stats — refreshes on navigation
|
useEffect(() => { refreshStats() }, [location.pathname, refreshStats])
|
||||||
useEffect(() => {
|
|
||||||
refreshStats()
|
|
||||||
}, [location.pathname, refreshStats])
|
|
||||||
|
|
||||||
// Refresh when sessions are created or completed
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.addEventListener('session-changed', refreshStats)
|
window.addEventListener('session-changed', refreshStats)
|
||||||
return () => window.removeEventListener('session-changed', refreshStats)
|
return () => window.removeEventListener('session-changed', refreshStats)
|
||||||
}, [refreshStats])
|
}, [refreshStats])
|
||||||
|
|
||||||
const handleSidebarWheel = (e: React.WheelEvent<HTMLElement>) => {
|
/* ── Navigation data ──────────────────────────────── */
|
||||||
const sidebar = e.currentTarget
|
|
||||||
const canSidebarScroll = sidebar.scrollHeight > sidebar.clientHeight
|
|
||||||
const atTop = sidebar.scrollTop <= 0
|
|
||||||
const atBottom = sidebar.scrollTop + sidebar.clientHeight >= sidebar.scrollHeight - 1
|
|
||||||
|
|
||||||
// If sidebar can't consume wheel movement, forward it to main content scroller.
|
const sections: NavSection[] = [
|
||||||
if (!canSidebarScroll || (e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) {
|
{
|
||||||
|
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: '/guides', icon: BookOpen, label: 'User Guides', shortLabel: 'Guides' },
|
||||||
|
{ href: '/feedback', icon: MessageSquare, label: 'Feedback', shortLabel: 'Feedbk' },
|
||||||
|
{ href: '/account', icon: Settings, label: 'Account', shortLabel: 'Acct' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/* ── Active detection ─────────────────────────────── */
|
||||||
|
|
||||||
|
const isActive = (item: NavEntry) => {
|
||||||
|
if (item.matchPaths) return item.matchPaths.some(p => 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), 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
const keepFlyout = () => {
|
||||||
|
if (flyoutTimeout.current) clearTimeout(flyoutTimeout.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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
|
const main = document.querySelector('.main-content') as HTMLElement | null
|
||||||
if (main) {
|
if (main) { main.scrollTop += e.deltaY; e.preventDefault() }
|
||||||
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"
|
||||||
|
onMouseEnter={() => hasChildren && !sidebarPinned ? openFlyout(key) : undefined}
|
||||||
|
onMouseLeave={() => hasChildren && !sidebarPinned ? closeFlyout() : undefined}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to={item.href}
|
||||||
|
onMouseEnter={() => prefetchForRoute(item.href)}
|
||||||
|
onFocus={() => hasChildren && !sidebarPinned ? openFlyout(key) : undefined}
|
||||||
|
onBlur={() => hasChildren && !sidebarPinned ? closeFlyout() : undefined}
|
||||||
|
className={cn(
|
||||||
|
'group relative flex flex-col items-center justify-center rounded-lg px-1 py-2 transition-all duration-150',
|
||||||
|
active
|
||||||
|
? 'bg-[rgba(34,211,238,0.10)] text-[#67e8f9]'
|
||||||
|
: 'text-[#6b7280] hover:text-[#848b9b]'
|
||||||
|
)}
|
||||||
|
title={item.label}
|
||||||
|
>
|
||||||
|
<span className="relative">
|
||||||
|
<Icon size={20} className={active ? 'opacity-100' : 'opacity-60 group-hover:opacity-85'} />
|
||||||
|
{item.badge !== undefined && item.badge > 0 && (
|
||||||
|
<span className="absolute -right-1.5 -top-1.5 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-[#22d3ee] px-1 text-[0.5rem] font-bold text-[#0c0d10]">
|
||||||
|
{item.badge > 99 ? '99+' : item.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 text-[0.5625rem] font-mono leading-tight truncate max-w-[60px]">
|
||||||
|
{item.shortLabel}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Flyout panel (icon rail only) */}
|
||||||
|
{hasChildren && !sidebarPinned && flyoutIndex === key && (
|
||||||
|
<div
|
||||||
|
className="fixed z-50 ml-1"
|
||||||
|
style={{
|
||||||
|
left: '72px',
|
||||||
|
top: sidebarRef.current
|
||||||
|
? (() => {
|
||||||
|
const itemEl = sidebarRef.current.querySelector(`[data-flyout-key="${key}"]`)
|
||||||
|
if (itemEl) {
|
||||||
|
const rect = itemEl.getBoundingClientRect()
|
||||||
|
return `${rect.top}px`
|
||||||
|
}
|
||||||
|
return '0px'
|
||||||
|
})()
|
||||||
|
: '0px',
|
||||||
|
maxHeight: '70vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
onMouseEnter={keepFlyout}
|
||||||
|
onMouseLeave={closeFlyout}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-[220px] rounded-lg p-2 animate-fade-in"
|
||||||
|
style={{
|
||||||
|
background: '#14161d',
|
||||||
|
border: '1px solid #1e2130',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.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-[rgba(34,211,238,0.10)] text-[#67e8f9]'
|
||||||
|
: 'text-[#848b9b] hover:bg-[#191c25] hover:text-[#e2e5eb]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{child.label}</span>
|
||||||
|
{child.count !== undefined && (
|
||||||
|
<span className="text-[0.6875rem] font-mono text-[#4f5666]">{child.count}</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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-[#e2e5eb]/70'
|
||||||
|
: 'bg-[rgba(34,211,238,0.10)] text-[#e2e5eb]'
|
||||||
|
: 'text-[#848b9b] hover:bg-[#191c25] hover:text-[#e2e5eb]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{active && !isParentDimmed && (
|
||||||
|
<div
|
||||||
|
className="absolute left-0 top-1/2 h-6 w-[3px] -translate-y-1/2"
|
||||||
|
style={{ background: '#22d3ee', 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-[#4f5666]"
|
||||||
|
style={{ background: '#14161d', border: '1px solid #1e2130' }}>
|
||||||
|
{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-[rgba(34,211,238,0.10)] text-[#e2e5eb]'
|
||||||
|
: 'text-[#848b9b] hover:bg-[#191c25] hover:text-[#e2e5eb]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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-[#4f5666]"
|
||||||
|
style={{ background: '#14161d', border: '1px solid #1e2130' }}>
|
||||||
|
{child.count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Flyout positioning: use data attribute for lookup ── */
|
||||||
|
|
||||||
|
const renderRailItemWithRef = (item: NavEntry, key: string) => {
|
||||||
|
return (
|
||||||
|
<div key={key} data-flyout-key={key}>
|
||||||
|
{renderRailItem(item, key + '-inner')}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main render ──────────────────────────────────── */
|
||||||
|
|
||||||
|
if (sidebarPinned) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
ref={sidebarRef}
|
||||||
|
className="sidebar flex flex-col"
|
||||||
|
style={{ background: '#0f1118', borderRight: '1px solid #1e2130' }}
|
||||||
|
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-[#4f5666] 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 py-2 space-y-0.5" style={{ borderTop: '1px solid #1e2130' }}>
|
||||||
|
{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-[#848b9b] hover:bg-[#191c25] hover:text-[#e2e5eb] transition-colors"
|
||||||
|
title="Unpin sidebar"
|
||||||
|
>
|
||||||
|
<PinOff size={18} className="shrink-0" />
|
||||||
|
<span>Unpin</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon Rail (default) */
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
className="sidebar flex flex-col border-r"
|
ref={sidebarRef}
|
||||||
style={{
|
className="sidebar flex flex-col items-center"
|
||||||
background: 'rgba(16, 17, 20, 0.5)',
|
style={{ background: '#0f1118', borderRight: '1px solid #1e2130' }}
|
||||||
backdropFilter: 'var(--glass-blur-light)',
|
onWheel={handleWheel}
|
||||||
WebkitBackdropFilter: 'var(--glass-blur-light)',
|
|
||||||
borderColor: 'var(--glass-border)',
|
|
||||||
}}
|
|
||||||
onWheel={handleSidebarWheel}
|
|
||||||
>
|
>
|
||||||
{sidebarCollapsed ? (
|
{/* Nav sections */}
|
||||||
<>
|
<div className="flex flex-col items-center w-full px-1.5 py-2 space-y-0.5">
|
||||||
{/* Collapsed: icon-only nav */}
|
{sections.map((section, si) => (
|
||||||
<div className="flex flex-col items-center px-1.5 py-3 space-y-1">
|
<div key={section.title} className="w-full">
|
||||||
<NavItem href="/" icon={LayoutGrid} label="Dashboard" iconColor={NAV_COLORS.dashboard} collapsed />
|
{si > 0 && (
|
||||||
<NavItem href="/sessions" icon={Clock} label="Active Sessions" badge={stats?.active_count || undefined} iconColor={NAV_COLORS.sessions} collapsed />
|
<div className="mx-3 my-2" style={{ borderTop: '1px solid #1e2130' }} />
|
||||||
<NavItem href="/escalations" icon={AlertTriangle} label="Escalations" badge={stats?.escalation_count || undefined} iconColor="#fbbf24" collapsed />
|
)}
|
||||||
<NavItem href="/trees" icon={Network} label="Flows" matchPaths={['/trees', '/flows', '/my-trees']} iconColor={NAV_COLORS.flows} collapsed />
|
{section.items.map((item, ii) => renderRailItemWithRef(item, `${si}-${ii}`))}
|
||||||
<NavItem href="/step-library" icon={Library} label="Step Library" iconColor={NAV_COLORS.stepLib} collapsed />
|
|
||||||
<NavItem href="/scripts" icon={Code2} label="Scripts" iconColor={NAV_COLORS.scripts} collapsed />
|
|
||||||
<NavItem href="/script-builder" icon={Wand2} label="Script Builder" iconColor={NAV_COLORS.scriptBuilder} collapsed />
|
|
||||||
<NavItem href="/review-queue" icon={ListChecks} label="Review Queue" iconColor="#fbbf24" collapsed />
|
|
||||||
<NavItem href="/shares" icon={FileOutput} label="Exports" iconColor={NAV_COLORS.exports} collapsed />
|
|
||||||
<NavItem href="/analytics" icon={BarChart3} label="Analytics" iconColor={NAV_COLORS.analytics} collapsed />
|
|
||||||
<NavItem href="/analytics/flowpilot" icon={TrendingUp} label="FlowPilot Analytics" iconColor="#2dd4bf" collapsed />
|
|
||||||
<NavItem href="/guides" icon={BookOpen} label="User Guides" iconColor={NAV_COLORS.guides} collapsed />
|
|
||||||
<NavItem href="/feedback" icon={MessageSquareText} label="Feedback" iconColor={NAV_COLORS.feedback} collapsed />
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
))}
|
||||||
) : (
|
</div>
|
||||||
<>
|
|
||||||
{/* Navigation */}
|
|
||||||
<div className="px-3 py-2 space-y-0.5">
|
|
||||||
{/* Dashboard */}
|
|
||||||
<NavItem href="/" icon={LayoutGrid} label="Dashboard" iconColor={NAV_COLORS.dashboard} />
|
|
||||||
|
|
||||||
{/* Resolve */}
|
|
||||||
<div className="font-label text-[0.5625rem] uppercase tracking-[0.12em] text-[#5a6170] px-3 pt-3 pb-1">
|
|
||||||
Resolve
|
|
||||||
</div>
|
|
||||||
<NavItem href="/sessions" icon={Clock} label="Active Sessions" badge={stats?.active_count || undefined} iconColor={NAV_COLORS.sessions} matchPaths={['/sessions']} />
|
|
||||||
<NavItem href="/escalations" icon={AlertTriangle} label="Escalations" badge={stats?.escalation_count || undefined} iconColor="#fbbf24" />
|
|
||||||
|
|
||||||
{/* Knowledge */}
|
|
||||||
<div className="font-label text-[0.5625rem] uppercase tracking-[0.12em] text-[#5a6170] px-3 pt-3 pb-1">
|
|
||||||
Knowledge
|
|
||||||
</div>
|
|
||||||
<NavItem
|
|
||||||
href="/trees"
|
|
||||||
icon={Network}
|
|
||||||
label="Flows"
|
|
||||||
badge={stats?.tree_counts.total || undefined}
|
|
||||||
iconColor={NAV_COLORS.flows}
|
|
||||||
matchPaths={['/trees', '/flows', '/my-trees']}
|
|
||||||
children={[
|
|
||||||
{ 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 },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<NavItem href="/step-library" icon={Library} label="Step Library" iconColor={NAV_COLORS.stepLib} />
|
|
||||||
<NavItem href="/scripts" icon={Code2} label="Scripts" iconColor={NAV_COLORS.scripts} />
|
|
||||||
<NavItem href="/script-builder" icon={Wand2} label="Script Builder" iconColor={NAV_COLORS.scriptBuilder} />
|
|
||||||
<NavItem href="/review-queue" icon={ListChecks} label="Review Queue" iconColor="#fbbf24" />
|
|
||||||
|
|
||||||
{/* Insights */}
|
|
||||||
<div className="font-label text-[0.5625rem] uppercase tracking-[0.12em] text-[#5a6170] px-3 pt-3 pb-1">
|
|
||||||
Insights
|
|
||||||
</div>
|
|
||||||
<NavItem href="/shares" icon={FileOutput} label="Exports" iconColor={NAV_COLORS.exports} />
|
|
||||||
<NavItem href="/analytics" icon={BarChart3} label="Analytics" iconColor={NAV_COLORS.analytics} />
|
|
||||||
<NavItem href="/analytics/flowpilot" icon={TrendingUp} label="FlowPilot Analytics" iconColor="#2dd4bf" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Spacer — pushes footer to bottom */}
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div
|
<div className="flex flex-col items-center w-full px-1.5 py-2 space-y-0.5" style={{ borderTop: '1px solid #1e2130' }}>
|
||||||
className={cn(
|
{footerItems.map((item, i) => (
|
||||||
"border-t",
|
<div key={`footer-${i}`} data-flyout-key={`footer-${i}`}>
|
||||||
sidebarCollapsed ? "px-1.5 py-2 flex flex-col items-center" : "px-3 py-2 space-y-0.5"
|
{renderRailItem(item, `footer-${i}-inner`)}
|
||||||
)}
|
</div>
|
||||||
style={{ borderColor: 'var(--glass-border)' }}
|
))}
|
||||||
>
|
|
||||||
{!sidebarCollapsed && (
|
|
||||||
<>
|
|
||||||
<NavItem href="/guides" icon={BookOpen} label="User Guides" iconColor={NAV_COLORS.guides} />
|
|
||||||
<NavItem href="/feedback" icon={MessageSquareText} label="Feedback" iconColor={NAV_COLORS.feedback} />
|
|
||||||
<NavItem href="/account" icon={Settings} label="Account" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={toggleSidebar}
|
type="button"
|
||||||
className={cn(
|
onClick={toggleSidebarPinned}
|
||||||
"flex w-full items-center rounded-lg text-[0.8125rem] font-medium text-muted-foreground hover:bg-[var(--sidebar-hover)] hover:text-foreground transition-colors",
|
className="flex flex-col items-center justify-center rounded-lg px-1 py-2 text-[#6b7280] hover:text-[#848b9b] transition-colors"
|
||||||
sidebarCollapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2"
|
title="Pin sidebar"
|
||||||
)}
|
|
||||||
title={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
|
||||||
>
|
>
|
||||||
{sidebarCollapsed ? <PanelLeftOpen size={20} /> : <PanelLeftClose size={18} />}
|
<Pin size={18} className="opacity-60 hover:opacity-85" />
|
||||||
{!sidebarCollapsed && <span>Collapse</span>}
|
<span className="mt-1 text-[0.5625rem] font-mono leading-tight">Pin</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function TopBar() {
|
|||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
}, [userMenuOpen])
|
}, [userMenuOpen])
|
||||||
|
|
||||||
// ⌘K / Ctrl+K global shortcut
|
// Cmd+K / Ctrl+K global shortcut
|
||||||
const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => {
|
const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -55,12 +55,10 @@ export function TopBar() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header
|
<header
|
||||||
className="topbar relative z-10 flex items-center gap-4 border-b px-4"
|
className="topbar relative z-10 flex items-center gap-4 px-4"
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(16, 17, 20, 0.6)',
|
background: '#0f1118',
|
||||||
backdropFilter: 'var(--glass-blur-strong)',
|
borderBottom: '1px solid #1e2130',
|
||||||
WebkitBackdropFilter: 'var(--glass-blur-strong)',
|
|
||||||
borderColor: 'var(--glass-border)',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Logo area */}
|
{/* Logo area */}
|
||||||
@@ -68,10 +66,9 @@ export function TopBar() {
|
|||||||
to="/"
|
to="/"
|
||||||
className="flex items-center gap-2.5 pr-4 transition-all duration-200"
|
className="flex items-center gap-2.5 pr-4 transition-all duration-200"
|
||||||
>
|
>
|
||||||
<BrandLogo size="sm" className="h-7 w-7 shrink-0" />
|
<BrandLogo size="sm" />
|
||||||
<span className="text-sm font-heading font-bold tracking-tight whitespace-nowrap">
|
<span className="text-sm font-heading font-bold tracking-tight whitespace-nowrap text-[#f0f2f5]">
|
||||||
<span className="text-foreground">Resolution</span>
|
ResolutionFlow
|
||||||
<span className="text-gradient-brand">Flow</span>
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@@ -84,17 +81,28 @@ export function TopBar() {
|
|||||||
className="hidden sm:relative sm:block w-full text-left"
|
className="hidden sm:relative sm:block w-full text-left"
|
||||||
style={{ maxWidth: '480px' }}
|
style={{ maxWidth: '480px' }}
|
||||||
>
|
>
|
||||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#848b9b]" />
|
||||||
<div className="w-full rounded-lg border border-border bg-card py-2 pl-9 pr-14 text-[0.8125rem] text-muted-foreground cursor-pointer hover:border-primary/30 transition-colors">
|
<div
|
||||||
Search flows, sessions, tags…
|
className="w-full rounded-md py-2 pl-9 pr-14 text-[0.8125rem] text-[#848b9b] cursor-pointer transition-colors"
|
||||||
|
style={{
|
||||||
|
background: '#14161d',
|
||||||
|
border: '1px solid #1e2130',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.borderColor = '#2a2f3d' }}
|
||||||
|
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.borderColor = '#1e2130' }}
|
||||||
|
>
|
||||||
|
Search flows, sessions, tags...
|
||||||
</div>
|
</div>
|
||||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 rounded border border-border bg-background px-1.5 py-0.5 font-label text-[0.625rem] text-muted-foreground">
|
<span
|
||||||
{navigator.platform?.toLowerCase().includes('mac') ? '⌘K' : 'Ctrl+K'}
|
className="absolute right-3 top-1/2 -translate-y-1/2 rounded px-1.5 py-0.5 font-mono text-[0.625rem] text-[#4f5666]"
|
||||||
|
style={{ background: '#0c0d10', border: '1px solid #1e2130' }}
|
||||||
|
>
|
||||||
|
{navigator.platform?.toLowerCase().includes('mac') ? '\u2318K' : 'Ctrl+K'}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCommandPaletteOpen(true)}
|
onClick={() => setCommandPaletteOpen(true)}
|
||||||
className="sm:hidden rounded-lg p-2 text-muted-foreground hover:bg-card hover:text-foreground transition-colors"
|
className="sm:hidden rounded-lg p-2 text-[#848b9b] hover:text-[#e2e5eb] transition-colors"
|
||||||
title="Search"
|
title="Search"
|
||||||
>
|
>
|
||||||
<Search size={18} />
|
<Search size={18} />
|
||||||
@@ -107,14 +115,14 @@ export function TopBar() {
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => setQuickLaunchOpen(true)}
|
onClick={() => setQuickLaunchOpen(true)}
|
||||||
className="rounded-lg p-2 text-muted-foreground hover:bg-card hover:text-foreground transition-colors"
|
className="rounded-lg p-2 text-[#848b9b] hover:bg-[#14161d] hover:text-[#e2e5eb] transition-colors"
|
||||||
title="Quick Launch"
|
title="Quick Launch"
|
||||||
>
|
>
|
||||||
<Zap size={18} />
|
<Zap size={18} />
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
to="/guides"
|
to="/guides"
|
||||||
className="rounded-lg p-2 text-muted-foreground hover:bg-card hover:text-foreground transition-colors"
|
className="rounded-lg p-2 text-[#848b9b] hover:bg-[#14161d] hover:text-[#e2e5eb] transition-colors"
|
||||||
title="User Guides"
|
title="User Guides"
|
||||||
>
|
>
|
||||||
<HelpCircle size={18} />
|
<HelpCircle size={18} />
|
||||||
@@ -125,18 +133,22 @@ export function TopBar() {
|
|||||||
<div className="relative ml-2" ref={menuRef}>
|
<div className="relative ml-2" ref={menuRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setUserMenuOpen(!userMenuOpen)}
|
onClick={() => setUserMenuOpen(!userMenuOpen)}
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-[10px] bg-gradient-brand text-xs font-heading font-bold text-primary-foreground hover:opacity-90 transition-opacity"
|
className="flex h-8 w-8 items-center justify-center rounded-[10px] text-xs font-heading font-bold text-white hover:opacity-90 transition-opacity"
|
||||||
|
style={{ background: 'linear-gradient(135deg, #06b6d4, #22d3ee)' }}
|
||||||
title={user?.name || user?.email || 'User'}
|
title={user?.name || user?.email || 'User'}
|
||||||
>
|
>
|
||||||
{initials}
|
{initials}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{userMenuOpen && (
|
{userMenuOpen && (
|
||||||
<div className="absolute right-0 z-50 mt-2 w-56 rounded-lg border border-border bg-card p-1 shadow-xl animate-scale-in">
|
<div
|
||||||
<div className="border-b border-border px-3 py-2.5 mb-1">
|
className="absolute right-0 z-50 mt-2 w-56 rounded-lg p-1 shadow-xl animate-scale-in"
|
||||||
<p className="text-sm font-medium text-foreground truncate">{user?.name || user?.email}</p>
|
style={{ background: '#14161d', border: '1px solid #1e2130' }}
|
||||||
|
>
|
||||||
|
<div className="px-3 py-2.5 mb-1" style={{ borderBottom: '1px solid #1e2130' }}>
|
||||||
|
<p className="text-sm font-medium text-[#e2e5eb] truncate">{user?.name || user?.email}</p>
|
||||||
{effectiveRole && effectiveRole !== 'engineer' && (
|
{effectiveRole && effectiveRole !== 'engineer' && (
|
||||||
<span className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground">
|
<span className="mt-1 inline-flex items-center gap-1 text-xs text-[#848b9b]">
|
||||||
<Shield size={10} />
|
<Shield size={10} />
|
||||||
{effectiveRole === 'super_admin' ? 'Super Admin' : effectiveRole === 'owner' ? 'Owner' : 'Viewer'}
|
{effectiveRole === 'super_admin' ? 'Super Admin' : effectiveRole === 'owner' ? 'Owner' : 'Viewer'}
|
||||||
</span>
|
</span>
|
||||||
@@ -145,7 +157,7 @@ export function TopBar() {
|
|||||||
<Link
|
<Link
|
||||||
to="/account"
|
to="/account"
|
||||||
onClick={() => setUserMenuOpen(false)}
|
onClick={() => setUserMenuOpen(false)}
|
||||||
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-[#848b9b] hover:bg-[#191c25] hover:text-[#e2e5eb]"
|
||||||
>
|
>
|
||||||
<Settings size={14} />
|
<Settings size={14} />
|
||||||
Account
|
Account
|
||||||
@@ -154,18 +166,18 @@ export function TopBar() {
|
|||||||
<Link
|
<Link
|
||||||
to="/admin"
|
to="/admin"
|
||||||
onClick={() => setUserMenuOpen(false)}
|
onClick={() => setUserMenuOpen(false)}
|
||||||
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-[#848b9b] hover:bg-[#191c25] hover:text-[#e2e5eb]"
|
||||||
>
|
>
|
||||||
<Shield size={14} />
|
<Shield size={14} />
|
||||||
Admin Panel
|
Admin Panel
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<div className="border-t border-border mt-1 pt-1">
|
<div className="mt-1 pt-1" style={{ borderTop: '1px solid #1e2130' }}>
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm',
|
'flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm',
|
||||||
'text-muted-foreground hover:bg-accent hover:text-foreground'
|
'text-[#848b9b] hover:bg-[#191c25] hover:text-[#e2e5eb]'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<LogOut size={14} />
|
<LogOut size={14} />
|
||||||
|
|||||||
@@ -261,6 +261,7 @@
|
|||||||
--sidebar-w: 260px;
|
--sidebar-w: 260px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Legacy collapsed class — kept as alias for pinned inverse */
|
||||||
.app-shell--collapsed {
|
.app-shell--collapsed {
|
||||||
grid-template-columns: 56px 1fr;
|
grid-template-columns: 56px 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ interface UserPreferencesState {
|
|||||||
setPreferredEditorMode: (mode: EditorMode) => void
|
setPreferredEditorMode: (mode: EditorMode) => void
|
||||||
sidebarCollapsed: boolean
|
sidebarCollapsed: boolean
|
||||||
toggleSidebar: () => void
|
toggleSidebar: () => void
|
||||||
|
sidebarPinned: boolean
|
||||||
|
toggleSidebarPinned: () => void
|
||||||
dashboardMyFlowsView: TreeLibraryView
|
dashboardMyFlowsView: TreeLibraryView
|
||||||
setDashboardMyFlowsView: (view: TreeLibraryView) => void
|
setDashboardMyFlowsView: (view: TreeLibraryView) => void
|
||||||
}
|
}
|
||||||
@@ -34,6 +36,8 @@ export const useUserPreferencesStore = create<UserPreferencesState>()(
|
|||||||
setPreferredEditorMode: (mode) => set({ preferredEditorMode: mode }),
|
setPreferredEditorMode: (mode) => set({ preferredEditorMode: mode }),
|
||||||
sidebarCollapsed: false,
|
sidebarCollapsed: false,
|
||||||
toggleSidebar: () => set({ sidebarCollapsed: !get().sidebarCollapsed }),
|
toggleSidebar: () => set({ sidebarCollapsed: !get().sidebarCollapsed }),
|
||||||
|
sidebarPinned: false,
|
||||||
|
toggleSidebarPinned: () => set({ sidebarPinned: !get().sidebarPinned }),
|
||||||
dashboardMyFlowsView: 'grid',
|
dashboardMyFlowsView: 'grid',
|
||||||
setDashboardMyFlowsView: (view) => set({ dashboardMyFlowsView: view }),
|
setDashboardMyFlowsView: (view) => set({ dashboardMyFlowsView: view }),
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user