245 lines
No EOL
9.5 KiB
TypeScript
245 lines
No EOL
9.5 KiB
TypeScript
import { NavLink, Link, useNavigate } from 'react-router-dom'
|
|
import {
|
|
ChevronDown,
|
|
ChevronRight,
|
|
FileText,
|
|
FolderOpen,
|
|
Home,
|
|
Info,
|
|
LogOut,
|
|
Plus,
|
|
ShieldCheck,
|
|
} from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
import { Separator } from '@/components/ui/separator'
|
|
import { Button } from '@/components/ui/button'
|
|
import { useAuth } from '@/context/AuthContext'
|
|
import { useWorkspace } from '@/context/WorkspaceContext'
|
|
|
|
interface SidebarProps {
|
|
className?: string
|
|
}
|
|
|
|
const mainNav = [
|
|
{ to: '/', label: 'Home', icon: Home },
|
|
{ to: '/about', label: 'About', icon: Info },
|
|
{ to: '/security', label: 'Security', icon: ShieldCheck },
|
|
]
|
|
|
|
export function Sidebar({ className }: SidebarProps) {
|
|
const { isAuthenticated, signOut } = useAuth()
|
|
const navigate = useNavigate()
|
|
const {
|
|
parents,
|
|
childrenByParent,
|
|
expandedParents,
|
|
selection,
|
|
parentOfSelection,
|
|
toggleExpand,
|
|
selectParent,
|
|
selectChild,
|
|
requestCreate,
|
|
} = useWorkspace()
|
|
|
|
const createHint =
|
|
parentOfSelection === null
|
|
? 'Creates a new top-level page'
|
|
: `Creates a child under "${parentOfSelection.title || 'untitled page'}"`
|
|
|
|
function handleCreateParent() {
|
|
requestCreate('parent')
|
|
navigate('/workspace')
|
|
}
|
|
|
|
function handleCreateChild() {
|
|
requestCreate('child')
|
|
navigate('/workspace')
|
|
}
|
|
|
|
return (
|
|
<aside
|
|
className={cn(
|
|
'flex h-full w-[250px] flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground',
|
|
className
|
|
)}
|
|
>
|
|
<nav className="flex-1 overflow-y-auto px-2 py-4">
|
|
<p className="px-3 pb-2 text-[11px] font-semibold uppercase tracking-wider text-primary/50">
|
|
Navigation
|
|
</p>
|
|
<div className="space-y-1">
|
|
{mainNav.map((item) => (
|
|
<NavLink
|
|
key={item.to}
|
|
to={item.to}
|
|
end={item.to === '/'}
|
|
className={({ isActive }) =>
|
|
cn(
|
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
|
isActive
|
|
? 'bg-sidebar-accent text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_rgba(34,211,238,0.35)]'
|
|
: 'text-sidebar-foreground/70'
|
|
)
|
|
}
|
|
>
|
|
<item.icon className="h-4 w-4" />
|
|
{item.label}
|
|
</NavLink>
|
|
))}
|
|
</div>
|
|
|
|
{isAuthenticated ? (
|
|
<>
|
|
<p className="px-3 pb-2 pt-5 text-[11px] font-semibold uppercase tracking-wider text-primary/50">
|
|
Account
|
|
</p>
|
|
<div className="space-y-1">
|
|
<NavLink
|
|
to="/workspace"
|
|
className={({ isActive }) =>
|
|
cn(
|
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
|
isActive
|
|
? 'bg-sidebar-accent text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_rgba(34,211,238,0.35)]'
|
|
: 'text-sidebar-foreground/70'
|
|
)
|
|
}
|
|
>
|
|
<FolderOpen className="h-4 w-4" />
|
|
My Workspace
|
|
</NavLink>
|
|
</div>
|
|
|
|
<div className="mt-5 space-y-1">
|
|
<div className="flex items-center justify-between px-3 pb-1">
|
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-primary/50">My Pages</p>
|
|
<button
|
|
type="button"
|
|
onClick={handleCreateParent}
|
|
title="Create a new top-level page"
|
|
aria-label="Create a new top-level page"
|
|
className="flex h-6 w-6 items-center justify-center rounded-md text-primary/70 transition-colors hover:bg-sidebar-accent hover:text-primary"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<Button size="sm" variant="secondary" className="w-full justify-start gap-2" onClick={handleCreateChild}>
|
|
<Plus className="h-4 w-4" />
|
|
Create
|
|
</Button>
|
|
<p className="px-1 text-[10px] leading-snug text-muted-foreground">{createHint}</p>
|
|
</div>
|
|
|
|
<div className="mt-2 max-h-[38vh] space-y-0.5 overflow-y-auto pr-1">
|
|
{parents.length === 0 ? (
|
|
<div className="rounded-lg border border-dashed border-border px-3 py-6 text-center">
|
|
<p className="text-xs text-muted-foreground">No pages yet. Click + to create one.</p>
|
|
</div>
|
|
) : (
|
|
parents.map((parent) => {
|
|
const isExpanded = expandedParents.has(parent.id)
|
|
const isParentActive =
|
|
selection !== null && selection.kind === 'parent' && selection.parent.id === parent.id
|
|
const children = childrenByParent[parent.id] ?? []
|
|
|
|
return (
|
|
<div key={parent.id}>
|
|
<div
|
|
className={cn(
|
|
'group flex items-center gap-1 rounded-md py-1 pl-1 pr-2 transition-colors',
|
|
isParentActive
|
|
? 'bg-sidebar-accent text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_rgba(34,211,238,0.35)]'
|
|
: 'hover:bg-accent/60'
|
|
)}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleExpand(parent.id)}
|
|
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground"
|
|
aria-label={isExpanded ? 'Collapse page' : 'Expand page'}
|
|
>
|
|
{isExpanded ? (
|
|
<ChevronDown className="h-3.5 w-3.5" />
|
|
) : (
|
|
<ChevronRight className="h-3.5 w-3.5" />
|
|
)}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => selectParent(parent)}
|
|
className={cn(
|
|
'flex min-w-0 flex-1 items-center gap-2 py-0.5 text-left text-sm',
|
|
isParentActive ? 'font-medium' : 'font-normal'
|
|
)}
|
|
>
|
|
<FileText className="h-3.5 w-3.5 shrink-0 text-primary/60" />
|
|
<span className="truncate">{parent.title || '(untitled page)'}</span>
|
|
</button>
|
|
<span className="text-[10px] text-muted-foreground">{children.length}</span>
|
|
</div>
|
|
|
|
{isExpanded ? (
|
|
<div className="ml-[13px] border-l border-border pl-2.5">
|
|
{children.length === 0 ? (
|
|
<p className="py-1.5 pl-2 text-[11px] italic text-muted-foreground">
|
|
No child pages yet
|
|
</p>
|
|
) : (
|
|
children.map((child) => {
|
|
const isChildActive =
|
|
selection !== null &&
|
|
selection.kind === 'child' &&
|
|
selection.child.id === child.id
|
|
|
|
return (
|
|
<button
|
|
key={child.id}
|
|
type="button"
|
|
onClick={() => selectChild(child)}
|
|
className={cn(
|
|
'flex w-full items-center gap-2 rounded-md py-1.5 pl-2 pr-2 text-left text-sm transition-colors',
|
|
isChildActive
|
|
? 'bg-sidebar-accent text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_rgba(34,211,238,0.35)]'
|
|
: 'text-sidebar-foreground/70 hover:bg-accent/60'
|
|
)}
|
|
>
|
|
<FileText className="h-3.5 w-3.5 shrink-0 text-primary/40" />
|
|
<span className="truncate">{child.title || '(untitled child)'}</span>
|
|
</button>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</nav>
|
|
|
|
<Separator className="bg-sidebar-border" />
|
|
|
|
<div className="px-3 py-3">
|
|
{isAuthenticated ? (
|
|
<Button variant="outline" size="sm" className="w-full justify-start gap-2" onClick={signOut}>
|
|
<LogOut className="h-4 w-4" />
|
|
Sign out
|
|
</Button>
|
|
) : (
|
|
<div className="flex flex-col gap-2">
|
|
<Button size="sm" asChild>
|
|
<Link to="/signup">Sign up</Link>
|
|
</Button>
|
|
<Button variant="outline" size="sm" asChild>
|
|
<Link to="/login">Log in</Link>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
)
|
|
} |