- Standardize all procedural back/exit paths to /trees (not /my-trees) - Add exit button with ConfirmDialog to procedural session top bar - Consolidate duplicate account links in sidebar and topbar - Auto-redirect non-owners to personal analytics - Add toast feedback before silent permission redirects in tree editor - Delete orphaned AdminCategoriesPage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
224 lines
8.8 KiB
TypeScript
224 lines
8.8 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { useNavigate, useLocation } from 'react-router-dom'
|
|
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
|
import { CategoryList } from '@/components/sidebar/CategoryList'
|
|
import { TagCloud } from '@/components/sidebar/TagCloud'
|
|
import { PinnedFlowsSection } from '@/components/sidebar/PinnedFlowsSection'
|
|
import { NavItem } from './NavItem'
|
|
import { categoriesApi, tagsApi, sessionsApi, treesApi } from '@/api'
|
|
import { pinnedFlowsApi } from '@/api/pinnedFlows'
|
|
import type { PinnedFlow } from '@/api/pinnedFlows'
|
|
import { toast } from '@/lib/toast'
|
|
|
|
interface CategoryItem {
|
|
id: string
|
|
name: string
|
|
color: string
|
|
count: number
|
|
}
|
|
|
|
export function Sidebar() {
|
|
const sidebarCollapsed = useUserPreferencesStore(s => s.sidebarCollapsed)
|
|
const toggleSidebar = useUserPreferencesStore(s => s.toggleSidebar)
|
|
|
|
const [categories, setCategories] = useState<CategoryItem[]>([])
|
|
const [tags, setTags] = useState<string[]>([])
|
|
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null)
|
|
const [activeTags, setActiveTags] = useState<string[]>([])
|
|
const [activeSessionCount, setActiveSessionCount] = useState(0)
|
|
const [pinnedFlows, setPinnedFlows] = useState<PinnedFlow[]>([])
|
|
const [treeCounts, setTreeCounts] = useState({ total: 0, troubleshooting: 0, procedural: 0, maintenance: 0 })
|
|
|
|
// Fetch sidebar data on mount
|
|
useEffect(() => {
|
|
const fetchData = async () => {
|
|
try {
|
|
const [cats, tagList, activeSessions, allTrees, pinnedData] = await Promise.all([
|
|
categoriesApi.list(),
|
|
tagsApi.list().catch(() => []),
|
|
sessionsApi.list({ completed: false, size: 50 }).catch(() => []),
|
|
treesApi.list({ sort_by: 'name' }).catch(() => []),
|
|
pinnedFlowsApi.list().catch(() => ({ items: [], count: 0 })),
|
|
])
|
|
setCategories(cats.map(c => ({
|
|
id: c.id,
|
|
name: c.name,
|
|
color: c.color || '#3b82f6',
|
|
count: c.tree_count || 0,
|
|
})))
|
|
setTags(tagList.map((t: { name: string }) => t.name).slice(0, 15))
|
|
setActiveSessionCount(activeSessions.length)
|
|
setPinnedFlows(pinnedData.items)
|
|
|
|
const total = allTrees.length
|
|
const troubleshooting = allTrees.filter(t => t.tree_type === 'troubleshooting').length
|
|
const procedural = allTrees.filter(t => t.tree_type === 'procedural').length
|
|
const maintenance = allTrees.filter(t => t.tree_type === 'maintenance').length
|
|
setTreeCounts({ total, troubleshooting, procedural, maintenance })
|
|
} catch {
|
|
// Silently handle errors
|
|
}
|
|
}
|
|
fetchData()
|
|
}, [])
|
|
|
|
const navigate = useNavigate()
|
|
const location = useLocation()
|
|
|
|
// Sync active filters from URL when on /trees page
|
|
useEffect(() => {
|
|
if (location.pathname === '/trees') {
|
|
const params = new URLSearchParams(location.search)
|
|
setActiveCategoryId(params.get('category') || null)
|
|
const tagsParam = params.get('tags')
|
|
setActiveTags(tagsParam ? tagsParam.split(',') : [])
|
|
}
|
|
}, [location.pathname, location.search])
|
|
|
|
const handleCategorySelect = (id: string | null) => {
|
|
setActiveCategoryId(id)
|
|
const params = new URLSearchParams(location.search)
|
|
if (id) {
|
|
params.set('category', id)
|
|
} else {
|
|
params.delete('category')
|
|
}
|
|
navigate(`/trees?${params.toString()}`)
|
|
}
|
|
|
|
const handleTagClick = (tag: string) => {
|
|
const next = activeTags.includes(tag)
|
|
? activeTags.filter(t => t !== tag)
|
|
: [...activeTags, tag]
|
|
setActiveTags(next)
|
|
const params = new URLSearchParams(location.search)
|
|
if (next.length > 0) {
|
|
params.set('tags', next.join(','))
|
|
} else {
|
|
params.delete('tags')
|
|
}
|
|
navigate(`/trees?${params.toString()}`)
|
|
}
|
|
|
|
const handleUnpin = async (treeId: string) => {
|
|
try {
|
|
await pinnedFlowsApi.unpin(treeId)
|
|
setPinnedFlows(prev => prev.filter(f => f.tree_id !== treeId))
|
|
toast.success('Unpinned from sidebar')
|
|
} catch {
|
|
toast.error('Failed to unpin flow')
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
// If sidebar can't consume wheel movement, forward it to main content scroller.
|
|
if (!canSidebarScroll || (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()
|
|
}
|
|
}
|
|
}
|
|
|
|
return (
|
|
<nav
|
|
className="sidebar flex flex-col border-r border-border bg-[hsl(var(--sidebar-bg))]"
|
|
onWheel={handleSidebarWheel}
|
|
>
|
|
{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" collapsed />
|
|
<NavItem href="/trees" icon={Box} label="All Flows" matchPaths={['/trees', '/flows']} collapsed />
|
|
<NavItem href="/my-trees" icon={PenLine} label="Flow Editor" collapsed />
|
|
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} collapsed />
|
|
<NavItem href="/shares" icon={FileText} label="Exports" collapsed />
|
|
<NavItem href="/step-library" icon={Bookmark} label="Step Library" collapsed />
|
|
<NavItem href="/analytics" icon={BarChart3} label="Analytics" collapsed />
|
|
<NavItem href="/feedback" icon={MessageSquareText} label="Feedback" collapsed />
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
{/* Pinned Flows */}
|
|
<PinnedFlowsSection flows={pinnedFlows} onUnpin={handleUnpin} />
|
|
|
|
<div className="border-b border-[hsl(var(--border-subtle))]" />
|
|
|
|
{/* Primary Navigation */}
|
|
<div className="px-3 py-2 space-y-0.5">
|
|
<NavItem href="/" icon={LayoutGrid} label="Dashboard" />
|
|
<NavItem
|
|
href="/trees"
|
|
icon={Box}
|
|
label="All Flows"
|
|
badge={treeCounts.total || undefined}
|
|
matchPaths={['/trees', '/flows']}
|
|
children={[
|
|
{ href: '/trees?type=troubleshooting', label: 'Troubleshooting', count: treeCounts.troubleshooting || undefined },
|
|
{ href: '/trees?type=procedural', label: 'Projects', count: treeCounts.procedural || undefined },
|
|
{ href: '/trees?type=maintenance', label: 'Maintenance', count: treeCounts.maintenance || undefined },
|
|
]}
|
|
/>
|
|
<NavItem href="/my-trees" icon={PenLine} label="Flow Editor" />
|
|
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} />
|
|
<NavItem href="/shares" icon={FileText} label="Exports" />
|
|
<NavItem href="/step-library" icon={Bookmark} label="Step Library" />
|
|
<NavItem href="/analytics" icon={BarChart3} label="Analytics" />
|
|
</div>
|
|
|
|
<div className="border-b border-[hsl(var(--border-subtle))]" />
|
|
|
|
{/* Categories */}
|
|
<CategoryList
|
|
categories={categories}
|
|
activeId={activeCategoryId}
|
|
onSelect={handleCategorySelect}
|
|
/>
|
|
|
|
<div className="border-b border-[hsl(var(--border-subtle))]" />
|
|
|
|
{/* Tags */}
|
|
<TagCloud tags={tags} activeTags={activeTags} onTagClick={handleTagClick} />
|
|
</>
|
|
)}
|
|
|
|
{/* Spacer */}
|
|
<div className="flex-1" />
|
|
|
|
{/* Footer */}
|
|
<div className={cn(
|
|
"border-t border-[hsl(var(--border-subtle))]",
|
|
sidebarCollapsed ? "px-1.5 py-2 flex flex-col items-center" : "px-3 py-2 space-y-0.5"
|
|
)}>
|
|
{!sidebarCollapsed && (
|
|
<>
|
|
<NavItem href="/feedback" icon={MessageSquareText} label="Feedback" />
|
|
<NavItem href="/account" icon={Settings} label="Account" />
|
|
</>
|
|
)}
|
|
<button
|
|
onClick={toggleSidebar}
|
|
className={cn(
|
|
"flex w-full items-center rounded-lg text-[0.8125rem] font-medium text-muted-foreground hover:bg-[hsl(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'}
|
|
>
|
|
{sidebarCollapsed ? <PanelLeftOpen size={20} /> : <PanelLeftClose size={18} />}
|
|
{!sidebarCollapsed && <span>Collapse</span>}
|
|
</button>
|
|
</div>
|
|
</nav>
|
|
)
|
|
}
|