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 { 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 />
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
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 { 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)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user