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
|
||||
}
|
||||
|
||||
/**
|
||||
* Brand logo mark: gradient cyan square with rounded corners
|
||||
* containing a white lightning bolt.
|
||||
*/
|
||||
export function BrandLogo({ size = 'sm', className }: BrandLogoProps) {
|
||||
const sizeClasses = size === 'sm' ? 'h-8 w-8' : 'h-20 w-20'
|
||||
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))
|
||||
const dim = size === 'sm' ? 30 : 64
|
||||
|
||||
return (
|
||||
<svg viewBox={vb} fill="none" className={cn(sizeClasses, className)}>
|
||||
<defs>
|
||||
<linearGradient id={gradId} x1="0" y1="0" x2={gradEnd} y2={gradEnd} gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stopColor="#06b6d4" />
|
||||
<stop offset="100%" stopColor="#22d3ee" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<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" />
|
||||
<circle cx={5 * s} cy={33 * s} r={nodeR.outer} fill={`url(#${gradId})`} opacity="0.5" />
|
||||
<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" />
|
||||
<path d={`M${7.75 * s} ${15 * s}L${14 * s} ${19 * s}`} stroke={`url(#${gradId})`} strokeWidth={strokeBase} strokeLinecap="round" opacity="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" />
|
||||
<circle cx={18 * s} cy={20 * s} r={hubR.glow} fill={`url(#${gradId})`} opacity="0.15" />
|
||||
<circle cx={18 * s} cy={20 * s} r={hubR.solid} fill={`url(#${gradId})`} opacity="0.9" />
|
||||
<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" />
|
||||
</svg>
|
||||
<div
|
||||
className={cn('shrink-0 flex items-center justify-center', className)}
|
||||
style={{
|
||||
width: dim,
|
||||
height: dim,
|
||||
borderRadius: size === 'sm' ? 8 : 14,
|
||||
background: 'linear-gradient(135deg, #06b6d4, #22d3ee)',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
style={{ width: dim * 0.5, height: dim * 0.5 }}
|
||||
>
|
||||
<path
|
||||
d="M13 2L4.5 13.5H12L11 22L19.5 10.5H12L13 2Z"
|
||||
fill="white"
|
||||
stroke="white"
|
||||
strokeWidth="0.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,13 +9,12 @@ export function BrandWordmark({ size = 'sm', className }: BrandWordmarkProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'font-heading font-bold tracking-tight',
|
||||
'font-heading font-bold tracking-tight text-[#f0f2f5]',
|
||||
size === 'sm' ? 'text-xl' : 'text-3xl',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="text-foreground">Resolution</span>
|
||||
<span className="text-gradient-brand">Flow</span>
|
||||
ResolutionFlow
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
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 { usePermissions } from '@/hooks/usePermissions'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
@@ -16,7 +16,7 @@ export function AppLayout() {
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuthStore()
|
||||
const { effectiveRole } = usePermissions()
|
||||
const sidebarCollapsed = useUserPreferencesStore(s => s.sidebarCollapsed)
|
||||
const sidebarPinned = useUserPreferencesStore(s => s.sidebarPinned)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
|
||||
// Close mobile menu on route change
|
||||
@@ -54,8 +54,8 @@ export function AppLayout() {
|
||||
{ path: '/', label: 'Dashboard', icon: LayoutGrid },
|
||||
{ path: '/sessions', label: 'Active Sessions', icon: Clock },
|
||||
{ path: '/escalations', label: 'Escalations', icon: AlertTriangle },
|
||||
{ path: '/trees', label: 'Flows', icon: Network },
|
||||
{ path: '/step-library', label: 'Step Library', icon: Library },
|
||||
{ path: '/trees', label: 'Flows', icon: GitBranch },
|
||||
{ path: '/step-library', label: 'Step Library', icon: Layers },
|
||||
{ path: '/scripts', label: 'Scripts', icon: Code2 },
|
||||
{ path: '/script-builder', label: 'Script Builder', icon: Wand2 },
|
||||
{ path: '/analytics', label: 'Analytics', icon: BarChart3 },
|
||||
@@ -64,34 +64,8 @@ export function AppLayout() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Atmosphere orbs — ambient light behind glass */}
|
||||
<div
|
||||
className="pointer-events-none fixed z-0"
|
||||
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')}
|
||||
className={cn('app-shell relative z-1', sidebarPinned && 'app-shell--pinned')}
|
||||
data-testid="app-shell"
|
||||
>
|
||||
{/* Top Bar - spans full width */}
|
||||
@@ -104,8 +78,9 @@ export function AppLayout() {
|
||||
|
||||
{/* Mobile hamburger - overlaid on topbar */}
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<Menu size={20} />
|
||||
@@ -119,17 +94,19 @@ export function AppLayout() {
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
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">
|
||||
<div className="flex h-14 items-center justify-between border-b border-border px-4">
|
||||
<nav
|
||||
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">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-brand">
|
||||
<BrandLogo size="sm" className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-sm font-heading font-bold">ResolutionFlow</span>
|
||||
<BrandLogo size="sm" />
|
||||
<span className="text-sm font-heading font-bold text-[#f0f2f5]">ResolutionFlow</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<X size={18} />
|
||||
@@ -138,10 +115,10 @@ export function AppLayout() {
|
||||
|
||||
<div className="flex flex-col p-3">
|
||||
{/* User info */}
|
||||
<div className="mb-3 border-b border-border pb-3 px-3">
|
||||
<p className="text-sm font-medium text-foreground">{user?.name || user?.email}</p>
|
||||
<div className="mb-3 pb-3 px-3" style={{ borderBottom: '1px solid #1e2130' }}>
|
||||
<p className="text-sm font-medium text-[#e2e5eb]">{user?.name || user?.email}</p>
|
||||
{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} />
|
||||
{effectiveRole === 'super_admin' ? 'Super Admin' : effectiveRole === 'owner' ? 'Owner' : 'Viewer'}
|
||||
</span>
|
||||
@@ -162,8 +139,8 @@ export function AppLayout() {
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-[var(--sidebar-active)] text-foreground'
|
||||
: 'text-muted-foreground hover:bg-[var(--sidebar-hover)] hover:text-foreground'
|
||||
? 'bg-[rgba(34,211,238,0.10)] text-[#e2e5eb]'
|
||||
: 'text-[#848b9b] hover:bg-[#191c25] hover:text-[#e2e5eb]'
|
||||
)}
|
||||
>
|
||||
<Icon size={18} />
|
||||
@@ -174,10 +151,11 @@ export function AppLayout() {
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<div className="mt-3 border-t border-border pt-3">
|
||||
<div className="mt-3 pt-3" style={{ borderTop: '1px solid #1e2130' }}>
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
|
||||
@@ -1,173 +1,420 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
LayoutGrid, Network, Clock, FileOutput, BarChart3, TrendingUp,
|
||||
Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText, ListChecks,
|
||||
BookOpen, Code2, Library, AlertTriangle, Wand2,
|
||||
LayoutGrid, Clock, AlertTriangle, GitBranch, Layers, Code2, Wand2,
|
||||
ListChecks, Download, BarChart3, Rocket, BookOpen, MessageSquare,
|
||||
Settings, Pin, PinOff,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
import { sidebarApi } from '@/api'
|
||||
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
|
||||
const NAV_COLORS = {
|
||||
dashboard: '#22d3ee', // cyan-400
|
||||
flows: '#a78bfa', // violet-400
|
||||
sessions: '#34d399', // emerald-400
|
||||
exports: '#60a5fa', // blue-400
|
||||
stepLib: '#fb923c', // orange-400
|
||||
scripts: '#2dd4bf', // teal-400
|
||||
scriptBuilder: '#e879f9', // fuchsia-400
|
||||
analytics: '#38bdf8', // sky-400
|
||||
guides: '#a3e635', // lime-400
|
||||
feedback: '#818cf8', // indigo-400
|
||||
} as const
|
||||
/* ── 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 sidebarCollapsed = useUserPreferencesStore(s => s.sidebarCollapsed)
|
||||
const toggleSidebar = useUserPreferencesStore(s => s.toggleSidebar)
|
||||
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(() => {})
|
||||
}, [])
|
||||
|
||||
// Fetch sidebar stats — refreshes on navigation
|
||||
useEffect(() => {
|
||||
refreshStats()
|
||||
}, [location.pathname, refreshStats])
|
||||
useEffect(() => { refreshStats() }, [location.pathname, refreshStats])
|
||||
|
||||
// Refresh when sessions are created or completed
|
||||
useEffect(() => {
|
||||
window.addEventListener('session-changed', refreshStats)
|
||||
return () => window.removeEventListener('session-changed', refreshStats)
|
||||
}, [refreshStats])
|
||||
|
||||
const handleSidebarWheel = (e: React.WheelEvent<HTMLElement>) => {
|
||||
const sidebar = e.currentTarget
|
||||
const canSidebarScroll = sidebar.scrollHeight > sidebar.clientHeight
|
||||
const atTop = sidebar.scrollTop <= 0
|
||||
const atBottom = sidebar.scrollTop + sidebar.clientHeight >= sidebar.scrollHeight - 1
|
||||
/* ── Navigation data ──────────────────────────────── */
|
||||
|
||||
// If sidebar can't consume wheel movement, forward it to main content scroller.
|
||||
if (!canSidebarScroll || (e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) {
|
||||
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: '/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
|
||||
if (main) {
|
||||
main.scrollTop += e.deltaY
|
||||
e.preventDefault()
|
||||
}
|
||||
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"
|
||||
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 (
|
||||
<nav
|
||||
className="sidebar flex flex-col border-r"
|
||||
style={{
|
||||
background: 'rgba(16, 17, 20, 0.5)',
|
||||
backdropFilter: 'var(--glass-blur-light)',
|
||||
WebkitBackdropFilter: 'var(--glass-blur-light)',
|
||||
borderColor: 'var(--glass-border)',
|
||||
}}
|
||||
onWheel={handleSidebarWheel}
|
||||
ref={sidebarRef}
|
||||
className="sidebar flex flex-col items-center"
|
||||
style={{ background: '#0f1118', borderRight: '1px solid #1e2130' }}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
<>
|
||||
{/* Collapsed: icon-only nav */}
|
||||
<div className="flex flex-col items-center px-1.5 py-3 space-y-1">
|
||||
<NavItem href="/" icon={LayoutGrid} label="Dashboard" iconColor={NAV_COLORS.dashboard} collapsed />
|
||||
<NavItem href="/sessions" icon={Clock} label="Active Sessions" badge={stats?.active_count || undefined} iconColor={NAV_COLORS.sessions} collapsed />
|
||||
<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 />
|
||||
<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 />
|
||||
{/* Nav sections */}
|
||||
<div className="flex flex-col items-center w-full px-1.5 py-2 space-y-0.5">
|
||||
{sections.map((section, si) => (
|
||||
<div key={section.title} className="w-full">
|
||||
{si > 0 && (
|
||||
<div className="mx-3 my-2" style={{ borderTop: '1px solid #1e2130' }} />
|
||||
)}
|
||||
{section.items.map((item, ii) => renderRailItemWithRef(item, `${si}-${ii}`))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Navigation */}
|
||||
<div className="px-3 py-2 space-y-0.5">
|
||||
{/* Dashboard */}
|
||||
<NavItem href="/" icon={LayoutGrid} label="Dashboard" iconColor={NAV_COLORS.dashboard} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 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" />
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className={cn(
|
||||
"border-t",
|
||||
sidebarCollapsed ? "px-1.5 py-2 flex flex-col items-center" : "px-3 py-2 space-y-0.5"
|
||||
)}
|
||||
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" />
|
||||
</>
|
||||
)}
|
||||
<div className="flex flex-col items-center w-full px-1.5 py-2 space-y-0.5" style={{ borderTop: '1px solid #1e2130' }}>
|
||||
{footerItems.map((item, i) => (
|
||||
<div key={`footer-${i}`} data-flyout-key={`footer-${i}`}>
|
||||
{renderRailItem(item, `footer-${i}-inner`)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className={cn(
|
||||
"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",
|
||||
sidebarCollapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2"
|
||||
)}
|
||||
title={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
type="button"
|
||||
onClick={toggleSidebarPinned}
|
||||
className="flex flex-col items-center justify-center rounded-lg px-1 py-2 text-[#6b7280] hover:text-[#848b9b] transition-colors"
|
||||
title="Pin sidebar"
|
||||
>
|
||||
{sidebarCollapsed ? <PanelLeftOpen size={20} /> : <PanelLeftClose size={18} />}
|
||||
{!sidebarCollapsed && <span>Collapse</span>}
|
||||
<Pin size={18} className="opacity-60 hover:opacity-85" />
|
||||
<span className="mt-1 text-[0.5625rem] font-mono leading-tight">Pin</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -35,7 +35,7 @@ export function TopBar() {
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [userMenuOpen])
|
||||
|
||||
// ⌘K / Ctrl+K global shortcut
|
||||
// Cmd+K / Ctrl+K global shortcut
|
||||
const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
@@ -55,12 +55,10 @@ export function TopBar() {
|
||||
return (
|
||||
<>
|
||||
<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={{
|
||||
background: 'rgba(16, 17, 20, 0.6)',
|
||||
backdropFilter: 'var(--glass-blur-strong)',
|
||||
WebkitBackdropFilter: 'var(--glass-blur-strong)',
|
||||
borderColor: 'var(--glass-border)',
|
||||
background: '#0f1118',
|
||||
borderBottom: '1px solid #1e2130',
|
||||
}}
|
||||
>
|
||||
{/* Logo area */}
|
||||
@@ -68,10 +66,9 @@ export function TopBar() {
|
||||
to="/"
|
||||
className="flex items-center gap-2.5 pr-4 transition-all duration-200"
|
||||
>
|
||||
<BrandLogo size="sm" className="h-7 w-7 shrink-0" />
|
||||
<span className="text-sm font-heading font-bold tracking-tight whitespace-nowrap">
|
||||
<span className="text-foreground">Resolution</span>
|
||||
<span className="text-gradient-brand">Flow</span>
|
||||
<BrandLogo size="sm" />
|
||||
<span className="text-sm font-heading font-bold tracking-tight whitespace-nowrap text-[#f0f2f5]">
|
||||
ResolutionFlow
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
@@ -84,17 +81,28 @@ export function TopBar() {
|
||||
className="hidden sm:relative sm:block w-full text-left"
|
||||
style={{ maxWidth: '480px' }}
|
||||
>
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<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">
|
||||
Search flows, sessions, tags…
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#848b9b]" />
|
||||
<div
|
||||
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>
|
||||
<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">
|
||||
{navigator.platform?.toLowerCase().includes('mac') ? '⌘K' : 'Ctrl+K'}
|
||||
<span
|
||||
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>
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Search size={18} />
|
||||
@@ -107,14 +115,14 @@ export function TopBar() {
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Zap size={18} />
|
||||
</button>
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
<HelpCircle size={18} />
|
||||
@@ -125,18 +133,22 @@ export function TopBar() {
|
||||
<div className="relative ml-2" ref={menuRef}>
|
||||
<button
|
||||
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'}
|
||||
>
|
||||
{initials}
|
||||
</button>
|
||||
|
||||
{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 className="border-b border-border px-3 py-2.5 mb-1">
|
||||
<p className="text-sm font-medium text-foreground truncate">{user?.name || user?.email}</p>
|
||||
<div
|
||||
className="absolute right-0 z-50 mt-2 w-56 rounded-lg p-1 shadow-xl animate-scale-in"
|
||||
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' && (
|
||||
<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} />
|
||||
{effectiveRole === 'super_admin' ? 'Super Admin' : effectiveRole === 'owner' ? 'Owner' : 'Viewer'}
|
||||
</span>
|
||||
@@ -145,7 +157,7 @@ export function TopBar() {
|
||||
<Link
|
||||
to="/account"
|
||||
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} />
|
||||
Account
|
||||
@@ -154,18 +166,18 @@ export function TopBar() {
|
||||
<Link
|
||||
to="/admin"
|
||||
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} />
|
||||
Admin Panel
|
||||
</Link>
|
||||
)}
|
||||
<div className="border-t border-border mt-1 pt-1">
|
||||
<div className="mt-1 pt-1" style={{ borderTop: '1px solid #1e2130' }}>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className={cn(
|
||||
'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} />
|
||||
|
||||
@@ -261,6 +261,7 @@
|
||||
--sidebar-w: 260px;
|
||||
}
|
||||
|
||||
/* Legacy collapsed class — kept as alias for pinned inverse */
|
||||
.app-shell--collapsed {
|
||||
grid-template-columns: 56px 1fr;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ interface UserPreferencesState {
|
||||
setPreferredEditorMode: (mode: EditorMode) => void
|
||||
sidebarCollapsed: boolean
|
||||
toggleSidebar: () => void
|
||||
sidebarPinned: boolean
|
||||
toggleSidebarPinned: () => void
|
||||
dashboardMyFlowsView: TreeLibraryView
|
||||
setDashboardMyFlowsView: (view: TreeLibraryView) => void
|
||||
}
|
||||
@@ -34,6 +36,8 @@ export const useUserPreferencesStore = create<UserPreferencesState>()(
|
||||
setPreferredEditorMode: (mode) => set({ preferredEditorMode: mode }),
|
||||
sidebarCollapsed: false,
|
||||
toggleSidebar: () => set({ sidebarCollapsed: !get().sidebarCollapsed }),
|
||||
sidebarPinned: false,
|
||||
toggleSidebarPinned: () => set({ sidebarPinned: !get().sidebarPinned }),
|
||||
dashboardMyFlowsView: 'grid',
|
||||
setDashboardMyFlowsView: (view) => set({ dashboardMyFlowsView: view }),
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user