feat: add sidebar collapse, category/tag filtering, and workspace CRUD (Phase D)

- Sidebar collapse/expand toggle with icon-only rail mode (persisted)
- Sidebar category/tag clicks navigate to /trees with URL params
- TreeLibraryPage syncs filters from URL search params bidirectionally
- Workspace create modal with icon picker and auto-slug generation
- TopBar logo adapts to collapsed sidebar state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-02-15 05:05:25 -05:00
parent 09cd05e143
commit f334ba861b
9 changed files with 301 additions and 40 deletions

View File

@@ -15,6 +15,7 @@ export function AppLayout() {
const { user, logout } = useAuthStore()
const { effectiveRole } = usePermissions()
const fetchWorkspaces = useWorkspaceStore(s => s.fetchWorkspaces)
const sidebarCollapsed = useWorkspaceStore(s => s.sidebarCollapsed)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
// Fetch workspaces on mount
@@ -65,7 +66,7 @@ export function AppLayout() {
]
return (
<div className="app-shell">
<div className={cn('app-shell', sidebarCollapsed && 'app-shell--collapsed')}>
{/* Top Bar - spans full width */}
<TopBar />

View File

@@ -8,9 +8,10 @@ interface NavItemProps {
label: string
badge?: number | 'dot'
matchPaths?: string[]
collapsed?: boolean
}
export function NavItem({ href, icon: Icon, label, badge, matchPaths }: NavItemProps) {
export function NavItem({ href, icon: Icon, label, badge, matchPaths, collapsed }: NavItemProps) {
const location = useLocation()
const isActive = matchPaths
? matchPaths.some(p => location.pathname.startsWith(p))
@@ -18,6 +19,31 @@ export function NavItem({ href, icon: Icon, label, badge, matchPaths }: NavItemP
? location.pathname === '/'
: location.pathname.startsWith(href)
if (collapsed) {
return (
<Link
to={href}
className={cn(
'group relative flex items-center justify-center rounded-lg p-2 transition-all duration-120',
isActive
? 'bg-[hsl(var(--sidebar-active))] text-foreground'
: 'text-muted-foreground hover:bg-[hsl(var(--sidebar-hover))] hover:text-foreground'
)}
title={label}
>
{isActive && (
<div className="absolute left-0 top-1/2 h-6 w-[3px] -translate-y-1/2 rounded-r-full bg-gradient-brand" />
)}
<Icon size={18} className={cn('shrink-0', isActive ? 'opacity-100' : 'opacity-70')} />
{badge !== undefined && badge !== 0 && badge !== 'dot' && (
<span className="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[0.5rem] font-bold text-primary-foreground">
{badge}
</span>
)}
</Link>
)
}
return (
<Link
to={href}

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, Users, Settings } from 'lucide-react'
import { useNavigate, useLocation } from 'react-router-dom'
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, Users, Settings, PanelLeftClose, PanelLeftOpen } from 'lucide-react'
import { useWorkspaceStore } from '@/store/workspaceStore'
import { getWorkspaceLabels } from '@/constants/workspaceLabels'
import { WorkspaceSwitcher } from '@/components/workspace/WorkspaceSwitcher'
@@ -18,6 +19,8 @@ interface CategoryItem {
export function Sidebar() {
const activeWorkspace = useWorkspaceStore(s => s.getActiveWorkspace())
const activeWorkspaceId = useWorkspaceStore(s => s.activeWorkspaceId)
const sidebarCollapsed = useWorkspaceStore(s => s.sidebarCollapsed)
const toggleSidebar = useWorkspaceStore(s => s.toggleSidebar)
const labels = getWorkspaceLabels(activeWorkspace?.slug)
const [categories, setCategories] = useState<CategoryItem[]>([])
@@ -50,50 +53,110 @@ export function Sidebar() {
fetchData()
}, [activeWorkspaceId])
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) => {
setActiveTags(prev =>
prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]
)
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()}`)
}
return (
<nav className="sidebar flex flex-col border-r border-border bg-[hsl(var(--sidebar-bg))]">
{/* Workspace Switcher */}
<WorkspaceSwitcher />
{sidebarCollapsed ? (
<>
{/* Collapsed: icon-only nav */}
<div className="px-2 py-3 space-y-1">
<NavItem href="/" icon={LayoutGrid} label="" collapsed />
<NavItem href="/trees" icon={Box} label="" matchPaths={['/trees', '/flows']} collapsed />
<NavItem href="/my-trees" icon={PenLine} label="" collapsed />
<NavItem href="/sessions" icon={Clock} label="" badge={activeSessionCount || undefined} collapsed />
<NavItem href="/shares" icon={FileText} label="" collapsed />
<NavItem href="/step-library" icon={Bookmark} label="" collapsed />
</div>
</>
) : (
<>
{/* Workspace Switcher */}
<WorkspaceSwitcher />
<div className="border-b border-[hsl(var(--border-subtle))]" />
<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={labels.allItems} matchPaths={['/trees', '/flows']} />
<NavItem href="/my-trees" icon={PenLine} label={labels.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" badge="dot" />
</div>
{/* Primary Navigation */}
<div className="px-3 py-2 space-y-0.5">
<NavItem href="/" icon={LayoutGrid} label="Dashboard" />
<NavItem href="/trees" icon={Box} label={labels.allItems} matchPaths={['/trees', '/flows']} />
<NavItem href="/my-trees" icon={PenLine} label={labels.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" badge="dot" />
</div>
<div className="border-b border-[hsl(var(--border-subtle))]" />
<div className="border-b border-[hsl(var(--border-subtle))]" />
{/* Categories */}
<CategoryList
categories={categories}
activeId={activeCategoryId}
onSelect={setActiveCategoryId}
/>
{/* Categories */}
<CategoryList
categories={categories}
activeId={activeCategoryId}
onSelect={handleCategorySelect}
/>
<div className="border-b border-[hsl(var(--border-subtle))]" />
<div className="border-b border-[hsl(var(--border-subtle))]" />
{/* Tags */}
<TagCloud tags={tags} activeTags={activeTags} onTagClick={handleTagClick} />
{/* Tags */}
<TagCloud tags={tags} activeTags={activeTags} onTagClick={handleTagClick} />
</>
)}
{/* Spacer */}
<div className="flex-1" />
{/* Footer */}
<div className="border-t border-[hsl(var(--border-subtle))] px-3 py-2 space-y-0.5">
<NavItem href="/account" icon={Users} label="Team" />
<NavItem href="/account" icon={Settings} label="Settings" />
{!sidebarCollapsed && (
<>
<NavItem href="/account" icon={Users} label="Team" />
<NavItem href="/account" icon={Settings} label="Settings" />
</>
)}
<button
onClick={toggleSidebar}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-[0.8125rem] font-medium text-muted-foreground hover:bg-[hsl(var(--sidebar-hover))] hover:text-foreground transition-colors"
title={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{sidebarCollapsed ? <PanelLeftOpen size={18} /> : <PanelLeftClose size={18} />}
{!sidebarCollapsed && <span>Collapse</span>}
</button>
</div>
</nav>
)

View File

@@ -14,6 +14,7 @@ export function TopBar() {
const { user, logout } = useAuthStore()
const { effectiveRole, isSuperAdmin } = usePermissions()
const activeWorkspace = useWorkspaceStore(s => s.getActiveWorkspace())
const sidebarCollapsed = useWorkspaceStore(s => s.sidebarCollapsed)
const labels = getWorkspaceLabels(activeWorkspace?.slug)
const [userMenuOpen, setUserMenuOpen] = useState(false)
@@ -57,14 +58,20 @@ export function TopBar() {
<>
<header className="topbar flex items-center gap-4 border-b border-border bg-background px-4">
{/* Logo area */}
<Link to="/" className="flex items-center gap-2.5 pr-4" style={{ width: 'calc(260px - 40px)' }}>
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-brand">
<Link
to="/"
className="flex items-center gap-2.5 pr-4 transition-all duration-200"
style={{ width: sidebarCollapsed ? '32px' : 'calc(260px - 40px)' }}
>
<div className="flex h-8 w-8 shrink-0 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 tracking-tight">
<span className="text-foreground">Resolution</span>
<span className="text-gradient-brand">Flow</span>
</span>
{!sidebarCollapsed && (
<span className="text-sm font-heading font-bold tracking-tight">
<span className="text-foreground">Resolution</span>
<span className="text-gradient-brand">Flow</span>
</span>
)}
</Link>
{/* Search trigger */}

View File

@@ -0,0 +1,136 @@
import { useState } from 'react'
import { X, Loader2 } from 'lucide-react'
import { workspacesApi } from '@/api/workspaces'
import { useWorkspaceStore } from '@/store/workspaceStore'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
interface WorkspaceCreateModalProps {
open: boolean
onClose: () => void
}
const ICONS = ['📁', '🔧', '📋', '🚀', '🎯', '💼', '🔒', '📊', '🧪', '⚙️']
export function WorkspaceCreateModal({ open, onClose }: WorkspaceCreateModalProps) {
const fetchWorkspaces = useWorkspaceStore(s => s.fetchWorkspaces)
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [icon, setIcon] = useState('📁')
const [saving, setSaving] = useState(false)
const slug = name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.slice(0, 50)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim() || !slug) return
setSaving(true)
try {
await workspacesApi.create({ name: name.trim(), slug, description: description.trim() || undefined, icon })
await fetchWorkspaces()
toast.success(`Workspace "${name}" created`)
setName('')
setDescription('')
setIcon('📁')
onClose()
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to create workspace'
toast.error(message)
} finally {
setSaving(false)
}
}
if (!open) return null
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm animate-fade-in" onClick={onClose} />
<div className="relative w-full max-w-md rounded-xl border border-border bg-card p-6 shadow-2xl animate-scale-in">
<div className="flex items-center justify-between mb-5">
<h2 className="text-lg font-heading font-bold text-foreground">Create Workspace</h2>
<button onClick={onClose} className="rounded-lg p-1 text-muted-foreground hover:bg-accent hover:text-foreground">
<X size={18} />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Icon picker */}
<div>
<label className="mb-1.5 block text-sm font-medium text-foreground">Icon</label>
<div className="flex flex-wrap gap-1.5">
{ICONS.map(i => (
<button
key={i}
type="button"
onClick={() => setIcon(i)}
className={cn(
'flex h-9 w-9 items-center justify-center rounded-lg text-lg transition-colors',
icon === i
? 'bg-primary/10 border border-primary/30'
: 'border border-border hover:bg-accent'
)}
>
{i}
</button>
))}
</div>
</div>
{/* Name */}
<div>
<label className="mb-1.5 block text-sm font-medium text-foreground">Name</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="e.g., Network Operations"
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
autoFocus
/>
{slug && (
<p className="mt-1 text-[0.6875rem] text-muted-foreground">
Slug: <code className="rounded bg-secondary px-1">{slug}</code>
</p>
)}
</div>
{/* Description */}
<div>
<label className="mb-1.5 block text-sm font-medium text-foreground">Description <span className="text-muted-foreground font-normal">(optional)</span></label>
<input
type="text"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Brief description"
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
/>
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={!name.trim() || saving}
className="flex items-center gap-2 rounded-lg bg-gradient-brand px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-primary/20 hover:opacity-90 transition-opacity disabled:opacity-50"
>
{saving && <Loader2 size={14} className="animate-spin" />}
Create
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -1,6 +1,7 @@
import { useState, useRef, useEffect } from 'react'
import { ChevronDown, Plus } from 'lucide-react'
import { useWorkspaceStore } from '@/store/workspaceStore'
import { WorkspaceCreateModal } from './WorkspaceCreateModal'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
@@ -9,6 +10,7 @@ export function WorkspaceSwitcher() {
const activeWorkspace = workspaces.find(w => w.id === activeWorkspaceId)
const [open, setOpen] = useState(false)
const [createModalOpen, setCreateModalOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
@@ -30,6 +32,7 @@ export function WorkspaceSwitcher() {
if (!activeWorkspace) return null
return (
<>
<div className="relative px-3 py-2" ref={ref}>
<button
onClick={() => setOpen(!open)}
@@ -66,7 +69,10 @@ export function WorkspaceSwitcher() {
))}
</div>
<div className="border-t border-border p-1">
<button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground">
<button
onClick={() => { setOpen(false); setCreateModalOpen(true) }}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
>
<Plus size={14} />
Add workspace
</button>
@@ -74,5 +80,8 @@ export function WorkspaceSwitcher() {
</div>
)}
</div>
<WorkspaceCreateModal open={createModalOpen} onClose={() => setCreateModalOpen(false)} />
</>
)
}

View File

@@ -76,6 +76,11 @@
grid-template-rows: 56px 1fr;
height: 100vh;
overflow: hidden;
transition: grid-template-columns 200ms ease;
}
.app-shell--collapsed {
grid-template-columns: 56px 1fr;
}
.topbar {

View File

@@ -27,8 +27,10 @@ export function TreeLibraryPage() {
const [trees, setTrees] = useState<TreeListItem[]>([])
const [categories, setCategories] = useState<CategoryListItem[]>([])
const [folders, setFolders] = useState<FolderListItem[]>([])
const [selectedCategoryId, setSelectedCategoryId] = useState<string>('')
const [selectedTags, setSelectedTags] = useState<string[]>([])
const urlCategory = searchParams.get('category') || ''
const urlTags = searchParams.get('tags')
const [selectedCategoryId, setSelectedCategoryId] = useState<string>(urlCategory)
const [selectedTags, setSelectedTags] = useState<string[]>(urlTags ? urlTags.split(',') : [])
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [isLoading, setIsLoading] = useState(true)
@@ -40,7 +42,7 @@ export function TreeLibraryPage() {
urlType === 'troubleshooting' || urlType === 'procedural' ? urlType : 'all'
)
// Sync type filter when URL changes (e.g. clicking nav sub-items)
// Sync filters when URL changes (e.g. clicking sidebar categories/tags or nav sub-items)
useEffect(() => {
const t = searchParams.get('type')
if (t === 'troubleshooting' || t === 'procedural') {
@@ -48,6 +50,9 @@ export function TreeLibraryPage() {
} else {
setTypeFilter('all')
}
setSelectedCategoryId(searchParams.get('category') || '')
const tagsParam = searchParams.get('tags')
setSelectedTags(tagsParam ? tagsParam.split(',') : [])
}, [searchParams])
// View preferences from store

View File

@@ -6,15 +6,18 @@ interface WorkspaceState {
workspaces: Workspace[]
activeWorkspaceId: string | null
loading: boolean
sidebarCollapsed: boolean
setActiveWorkspace: (id: string) => void
fetchWorkspaces: () => Promise<void>
getActiveWorkspace: () => Workspace | undefined
toggleSidebar: () => void
}
export const useWorkspaceStore = create<WorkspaceState>((set, get) => ({
workspaces: [],
activeWorkspaceId: localStorage.getItem('active-workspace-id'),
loading: false,
sidebarCollapsed: localStorage.getItem('sidebar-collapsed') === 'true',
setActiveWorkspace: (id: string) => {
localStorage.setItem('active-workspace-id', id)
@@ -47,4 +50,10 @@ export const useWorkspaceStore = create<WorkspaceState>((set, get) => ({
const { workspaces, activeWorkspaceId } = get()
return workspaces.find(w => w.id === activeWorkspaceId)
},
toggleSidebar: () => {
const next = !get().sidebarCollapsed
localStorage.setItem('sidebar-collapsed', String(next))
set({ sidebarCollapsed: next })
},
}))