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:
@@ -15,6 +15,7 @@ export function AppLayout() {
|
|||||||
const { user, logout } = useAuthStore()
|
const { user, logout } = useAuthStore()
|
||||||
const { effectiveRole } = usePermissions()
|
const { effectiveRole } = usePermissions()
|
||||||
const fetchWorkspaces = useWorkspaceStore(s => s.fetchWorkspaces)
|
const fetchWorkspaces = useWorkspaceStore(s => s.fetchWorkspaces)
|
||||||
|
const sidebarCollapsed = useWorkspaceStore(s => s.sidebarCollapsed)
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||||
|
|
||||||
// Fetch workspaces on mount
|
// Fetch workspaces on mount
|
||||||
@@ -65,7 +66,7 @@ export function AppLayout() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className={cn('app-shell', sidebarCollapsed && 'app-shell--collapsed')}>
|
||||||
{/* Top Bar - spans full width */}
|
{/* Top Bar - spans full width */}
|
||||||
<TopBar />
|
<TopBar />
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ interface NavItemProps {
|
|||||||
label: string
|
label: string
|
||||||
badge?: number | 'dot'
|
badge?: number | 'dot'
|
||||||
matchPaths?: string[]
|
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 location = useLocation()
|
||||||
const isActive = matchPaths
|
const isActive = matchPaths
|
||||||
? matchPaths.some(p => location.pathname.startsWith(p))
|
? 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 === '/'
|
||||||
: location.pathname.startsWith(href)
|
: 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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={href}
|
to={href}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
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 { useWorkspaceStore } from '@/store/workspaceStore'
|
||||||
import { getWorkspaceLabels } from '@/constants/workspaceLabels'
|
import { getWorkspaceLabels } from '@/constants/workspaceLabels'
|
||||||
import { WorkspaceSwitcher } from '@/components/workspace/WorkspaceSwitcher'
|
import { WorkspaceSwitcher } from '@/components/workspace/WorkspaceSwitcher'
|
||||||
@@ -18,6 +19,8 @@ interface CategoryItem {
|
|||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const activeWorkspace = useWorkspaceStore(s => s.getActiveWorkspace())
|
const activeWorkspace = useWorkspaceStore(s => s.getActiveWorkspace())
|
||||||
const activeWorkspaceId = useWorkspaceStore(s => s.activeWorkspaceId)
|
const activeWorkspaceId = useWorkspaceStore(s => s.activeWorkspaceId)
|
||||||
|
const sidebarCollapsed = useWorkspaceStore(s => s.sidebarCollapsed)
|
||||||
|
const toggleSidebar = useWorkspaceStore(s => s.toggleSidebar)
|
||||||
const labels = getWorkspaceLabels(activeWorkspace?.slug)
|
const labels = getWorkspaceLabels(activeWorkspace?.slug)
|
||||||
|
|
||||||
const [categories, setCategories] = useState<CategoryItem[]>([])
|
const [categories, setCategories] = useState<CategoryItem[]>([])
|
||||||
@@ -50,50 +53,110 @@ export function Sidebar() {
|
|||||||
fetchData()
|
fetchData()
|
||||||
}, [activeWorkspaceId])
|
}, [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) => {
|
const handleTagClick = (tag: string) => {
|
||||||
setActiveTags(prev =>
|
const next = activeTags.includes(tag)
|
||||||
prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, 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 (
|
return (
|
||||||
<nav className="sidebar flex flex-col border-r border-border bg-[hsl(var(--sidebar-bg))]">
|
<nav className="sidebar flex flex-col border-r border-border bg-[hsl(var(--sidebar-bg))]">
|
||||||
{/* Workspace Switcher */}
|
{sidebarCollapsed ? (
|
||||||
<WorkspaceSwitcher />
|
<>
|
||||||
|
{/* 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 */}
|
{/* Primary Navigation */}
|
||||||
<div className="px-3 py-2 space-y-0.5">
|
<div className="px-3 py-2 space-y-0.5">
|
||||||
<NavItem href="/" icon={LayoutGrid} label="Dashboard" />
|
<NavItem href="/" icon={LayoutGrid} label="Dashboard" />
|
||||||
<NavItem href="/trees" icon={Box} label={labels.allItems} matchPaths={['/trees', '/flows']} />
|
<NavItem href="/trees" icon={Box} label={labels.allItems} matchPaths={['/trees', '/flows']} />
|
||||||
<NavItem href="/my-trees" icon={PenLine} label={labels.editor} />
|
<NavItem href="/my-trees" icon={PenLine} label={labels.editor} />
|
||||||
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} />
|
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} />
|
||||||
<NavItem href="/shares" icon={FileText} label="Exports" />
|
<NavItem href="/shares" icon={FileText} label="Exports" />
|
||||||
<NavItem href="/step-library" icon={Bookmark} label="Step Library" badge="dot" />
|
<NavItem href="/step-library" icon={Bookmark} label="Step Library" badge="dot" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-b border-[hsl(var(--border-subtle))]" />
|
<div className="border-b border-[hsl(var(--border-subtle))]" />
|
||||||
|
|
||||||
{/* Categories */}
|
{/* Categories */}
|
||||||
<CategoryList
|
<CategoryList
|
||||||
categories={categories}
|
categories={categories}
|
||||||
activeId={activeCategoryId}
|
activeId={activeCategoryId}
|
||||||
onSelect={setActiveCategoryId}
|
onSelect={handleCategorySelect}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="border-b border-[hsl(var(--border-subtle))]" />
|
<div className="border-b border-[hsl(var(--border-subtle))]" />
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
<TagCloud tags={tags} activeTags={activeTags} onTagClick={handleTagClick} />
|
<TagCloud tags={tags} activeTags={activeTags} onTagClick={handleTagClick} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Spacer */}
|
{/* Spacer */}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="border-t border-[hsl(var(--border-subtle))] px-3 py-2 space-y-0.5">
|
<div className="border-t border-[hsl(var(--border-subtle))] px-3 py-2 space-y-0.5">
|
||||||
<NavItem href="/account" icon={Users} label="Team" />
|
{!sidebarCollapsed && (
|
||||||
<NavItem href="/account" icon={Settings} label="Settings" />
|
<>
|
||||||
|
<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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export function TopBar() {
|
|||||||
const { user, logout } = useAuthStore()
|
const { user, logout } = useAuthStore()
|
||||||
const { effectiveRole, isSuperAdmin } = usePermissions()
|
const { effectiveRole, isSuperAdmin } = usePermissions()
|
||||||
const activeWorkspace = useWorkspaceStore(s => s.getActiveWorkspace())
|
const activeWorkspace = useWorkspaceStore(s => s.getActiveWorkspace())
|
||||||
|
const sidebarCollapsed = useWorkspaceStore(s => s.sidebarCollapsed)
|
||||||
const labels = getWorkspaceLabels(activeWorkspace?.slug)
|
const labels = getWorkspaceLabels(activeWorkspace?.slug)
|
||||||
|
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
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">
|
<header className="topbar flex items-center gap-4 border-b border-border bg-background px-4">
|
||||||
{/* Logo area */}
|
{/* Logo area */}
|
||||||
<Link to="/" className="flex items-center gap-2.5 pr-4" style={{ width: 'calc(260px - 40px)' }}>
|
<Link
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-brand">
|
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" />
|
<BrandLogo size="sm" className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-heading font-bold tracking-tight">
|
{!sidebarCollapsed && (
|
||||||
<span className="text-foreground">Resolution</span>
|
<span className="text-sm font-heading font-bold tracking-tight">
|
||||||
<span className="text-gradient-brand">Flow</span>
|
<span className="text-foreground">Resolution</span>
|
||||||
</span>
|
<span className="text-gradient-brand">Flow</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Search trigger */}
|
{/* Search trigger */}
|
||||||
|
|||||||
136
frontend/src/components/workspace/WorkspaceCreateModal.tsx
Normal file
136
frontend/src/components/workspace/WorkspaceCreateModal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { ChevronDown, Plus } from 'lucide-react'
|
import { ChevronDown, Plus } from 'lucide-react'
|
||||||
import { useWorkspaceStore } from '@/store/workspaceStore'
|
import { useWorkspaceStore } from '@/store/workspaceStore'
|
||||||
|
import { WorkspaceCreateModal } from './WorkspaceCreateModal'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ export function WorkspaceSwitcher() {
|
|||||||
const activeWorkspace = workspaces.find(w => w.id === activeWorkspaceId)
|
const activeWorkspace = workspaces.find(w => w.id === activeWorkspaceId)
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const [createModalOpen, setCreateModalOpen] = useState(false)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -30,6 +32,7 @@ export function WorkspaceSwitcher() {
|
|||||||
if (!activeWorkspace) return null
|
if (!activeWorkspace) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="relative px-3 py-2" ref={ref}>
|
<div className="relative px-3 py-2" ref={ref}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
@@ -66,7 +69,10 @@ export function WorkspaceSwitcher() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-border p-1">
|
<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} />
|
<Plus size={14} />
|
||||||
Add workspace…
|
Add workspace…
|
||||||
</button>
|
</button>
|
||||||
@@ -74,5 +80,8 @@ export function WorkspaceSwitcher() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<WorkspaceCreateModal open={createModalOpen} onClose={() => setCreateModalOpen(false)} />
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,11 @@
|
|||||||
grid-template-rows: 56px 1fr;
|
grid-template-rows: 56px 1fr;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
transition: grid-template-columns 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell--collapsed {
|
||||||
|
grid-template-columns: 56px 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
|
|||||||
@@ -27,8 +27,10 @@ export function TreeLibraryPage() {
|
|||||||
const [trees, setTrees] = useState<TreeListItem[]>([])
|
const [trees, setTrees] = useState<TreeListItem[]>([])
|
||||||
const [categories, setCategories] = useState<CategoryListItem[]>([])
|
const [categories, setCategories] = useState<CategoryListItem[]>([])
|
||||||
const [folders, setFolders] = useState<FolderListItem[]>([])
|
const [folders, setFolders] = useState<FolderListItem[]>([])
|
||||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string>('')
|
const urlCategory = searchParams.get('category') || ''
|
||||||
const [selectedTags, setSelectedTags] = useState<string[]>([])
|
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 [selectedFolderId, setSelectedFolderId] = useState<string | null>(null)
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
@@ -40,7 +42,7 @@ export function TreeLibraryPage() {
|
|||||||
urlType === 'troubleshooting' || urlType === 'procedural' ? urlType : 'all'
|
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(() => {
|
useEffect(() => {
|
||||||
const t = searchParams.get('type')
|
const t = searchParams.get('type')
|
||||||
if (t === 'troubleshooting' || t === 'procedural') {
|
if (t === 'troubleshooting' || t === 'procedural') {
|
||||||
@@ -48,6 +50,9 @@ export function TreeLibraryPage() {
|
|||||||
} else {
|
} else {
|
||||||
setTypeFilter('all')
|
setTypeFilter('all')
|
||||||
}
|
}
|
||||||
|
setSelectedCategoryId(searchParams.get('category') || '')
|
||||||
|
const tagsParam = searchParams.get('tags')
|
||||||
|
setSelectedTags(tagsParam ? tagsParam.split(',') : [])
|
||||||
}, [searchParams])
|
}, [searchParams])
|
||||||
|
|
||||||
// View preferences from store
|
// View preferences from store
|
||||||
|
|||||||
@@ -6,15 +6,18 @@ interface WorkspaceState {
|
|||||||
workspaces: Workspace[]
|
workspaces: Workspace[]
|
||||||
activeWorkspaceId: string | null
|
activeWorkspaceId: string | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
|
sidebarCollapsed: boolean
|
||||||
setActiveWorkspace: (id: string) => void
|
setActiveWorkspace: (id: string) => void
|
||||||
fetchWorkspaces: () => Promise<void>
|
fetchWorkspaces: () => Promise<void>
|
||||||
getActiveWorkspace: () => Workspace | undefined
|
getActiveWorkspace: () => Workspace | undefined
|
||||||
|
toggleSidebar: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useWorkspaceStore = create<WorkspaceState>((set, get) => ({
|
export const useWorkspaceStore = create<WorkspaceState>((set, get) => ({
|
||||||
workspaces: [],
|
workspaces: [],
|
||||||
activeWorkspaceId: localStorage.getItem('active-workspace-id'),
|
activeWorkspaceId: localStorage.getItem('active-workspace-id'),
|
||||||
loading: false,
|
loading: false,
|
||||||
|
sidebarCollapsed: localStorage.getItem('sidebar-collapsed') === 'true',
|
||||||
|
|
||||||
setActiveWorkspace: (id: string) => {
|
setActiveWorkspace: (id: string) => {
|
||||||
localStorage.setItem('active-workspace-id', id)
|
localStorage.setItem('active-workspace-id', id)
|
||||||
@@ -47,4 +50,10 @@ export const useWorkspaceStore = create<WorkspaceState>((set, get) => ({
|
|||||||
const { workspaces, activeWorkspaceId } = get()
|
const { workspaces, activeWorkspaceId } = get()
|
||||||
return workspaces.find(w => w.id === activeWorkspaceId)
|
return workspaces.find(w => w.id === activeWorkspaceId)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
toggleSidebar: () => {
|
||||||
|
const next = !get().sidebarCollapsed
|
||||||
|
localStorage.setItem('sidebar-collapsed', String(next))
|
||||||
|
set({ sidebarCollapsed: next })
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
Reference in New Issue
Block a user