From f334ba861b7ad9c47131a326d53e2d779cef5fde Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sun, 15 Feb 2026 05:05:25 -0500 Subject: [PATCH] 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 --- frontend/src/components/layout/AppLayout.tsx | 3 +- frontend/src/components/layout/NavItem.tsx | 28 +++- frontend/src/components/layout/Sidebar.tsx | 119 +++++++++++---- frontend/src/components/layout/TopBar.tsx | 19 ++- .../workspace/WorkspaceCreateModal.tsx | 136 ++++++++++++++++++ .../workspace/WorkspaceSwitcher.tsx | 11 +- frontend/src/index.css | 5 + frontend/src/pages/TreeLibraryPage.tsx | 11 +- frontend/src/store/workspaceStore.ts | 9 ++ 9 files changed, 301 insertions(+), 40 deletions(-) create mode 100644 frontend/src/components/workspace/WorkspaceCreateModal.tsx diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 32ffe8fc..78253560 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -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 ( -
+
{/* Top Bar - spans full width */} diff --git a/frontend/src/components/layout/NavItem.tsx b/frontend/src/components/layout/NavItem.tsx index 8f07394a..fd70704f 100644 --- a/frontend/src/components/layout/NavItem.tsx +++ b/frontend/src/components/layout/NavItem.tsx @@ -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 ( + + {isActive && ( +
+ )} + + {badge !== undefined && badge !== 0 && badge !== 'dot' && ( + + {badge} + + )} + + ) + } + return ( 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([]) @@ -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 ( ) diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/components/layout/TopBar.tsx index 7bf4f2dd..1529d725 100644 --- a/frontend/src/components/layout/TopBar.tsx +++ b/frontend/src/components/layout/TopBar.tsx @@ -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() { <>
{/* Logo area */} - -
+ +
- - Resolution - Flow - + {!sidebarCollapsed && ( + + Resolution + Flow + + )} {/* Search trigger */} diff --git a/frontend/src/components/workspace/WorkspaceCreateModal.tsx b/frontend/src/components/workspace/WorkspaceCreateModal.tsx new file mode 100644 index 00000000..4c9b0da1 --- /dev/null +++ b/frontend/src/components/workspace/WorkspaceCreateModal.tsx @@ -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 ( +
+
+
+
+

Create Workspace

+ +
+ +
+ {/* Icon picker */} +
+ +
+ {ICONS.map(i => ( + + ))} +
+
+ + {/* Name */} +
+ + 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 && ( +

+ Slug: {slug} +

+ )} +
+ + {/* Description */} +
+ + 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" + /> +
+ + {/* Actions */} +
+ + +
+
+
+
+ ) +} diff --git a/frontend/src/components/workspace/WorkspaceSwitcher.tsx b/frontend/src/components/workspace/WorkspaceSwitcher.tsx index fca2655c..2afc36e0 100644 --- a/frontend/src/components/workspace/WorkspaceSwitcher.tsx +++ b/frontend/src/components/workspace/WorkspaceSwitcher.tsx @@ -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(null) useEffect(() => { @@ -30,6 +32,7 @@ export function WorkspaceSwitcher() { if (!activeWorkspace) return null return ( + <>
- @@ -74,5 +80,8 @@ export function WorkspaceSwitcher() {
)}
+ + setCreateModalOpen(false)} /> + ) } diff --git a/frontend/src/index.css b/frontend/src/index.css index 401a6090..bc7103ad 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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 { diff --git a/frontend/src/pages/TreeLibraryPage.tsx b/frontend/src/pages/TreeLibraryPage.tsx index 2814b9af..552b70b1 100644 --- a/frontend/src/pages/TreeLibraryPage.tsx +++ b/frontend/src/pages/TreeLibraryPage.tsx @@ -27,8 +27,10 @@ export function TreeLibraryPage() { const [trees, setTrees] = useState([]) const [categories, setCategories] = useState([]) const [folders, setFolders] = useState([]) - const [selectedCategoryId, setSelectedCategoryId] = useState('') - const [selectedTags, setSelectedTags] = useState([]) + const urlCategory = searchParams.get('category') || '' + const urlTags = searchParams.get('tags') + const [selectedCategoryId, setSelectedCategoryId] = useState(urlCategory) + const [selectedTags, setSelectedTags] = useState(urlTags ? urlTags.split(',') : []) const [selectedFolderId, setSelectedFolderId] = useState(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 diff --git a/frontend/src/store/workspaceStore.ts b/frontend/src/store/workspaceStore.ts index b8fd81bc..c044e4a4 100644 --- a/frontend/src/store/workspaceStore.ts +++ b/frontend/src/store/workspaceStore.ts @@ -6,15 +6,18 @@ interface WorkspaceState { workspaces: Workspace[] activeWorkspaceId: string | null loading: boolean + sidebarCollapsed: boolean setActiveWorkspace: (id: string) => void fetchWorkspaces: () => Promise getActiveWorkspace: () => Workspace | undefined + toggleSidebar: () => void } export const useWorkspaceStore = create((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((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 }) + }, }))