feat: add Sheet and Sidebar components for improved UI layout

- Introduced a new Sheet component for modal-like functionality with customizable content and animations.
- Implemented a Sidebar component with mobile responsiveness, state management, and keyboard shortcuts for toggling.
- Added Skeleton component for loading states and Tooltip component for enhanced user guidance.
- Created a useIsMobile hook to manage mobile view detection.
- Updated main entry point to render the new PlatformPage component.
- Enhanced styles for sidebar and scrollbar customization in CSS.
- Extended Tailwind configuration to include sidebar color variables for better theming.
This commit is contained in:
2026-03-09 20:51:29 +01:00
parent b3c2c6711f
commit 4e51b6866f
22 changed files with 2488 additions and 174 deletions

View File

@@ -0,0 +1,124 @@
/**
* Platform sidebar (sidebar-07 style): projects list, create, delete, collapse to icons.
*/
import React from 'react'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarTrigger,
} from '@/components/ui/sidebar'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { MoreHorizontal, Plus, Trash2 } from 'lucide-react'
import { useSidebar } from '@/components/ui/sidebar'
import type { Project } from './types'
import { getProjectIcon } from './icon-map'
import { NewProjectDialog } from './NewProjectDialog'
type AppSidebarProps = {
projects: Project[]
selectedProjectId: string | null
onSelectProject: (id: string) => void
onDeleteProject: (id: string) => void
onCreateProject: (project: Project) => void
}
export function AppSidebar({
projects,
selectedProjectId,
onSelectProject,
onDeleteProject,
onCreateProject,
}: AppSidebarProps) {
const { isMobile } = useSidebar()
return (
<>
<Sidebar collapsible="icon">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" tooltip="Zui" className="font-semibold">
<span className="flex size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground text-xs">
Zo
</span>
<span>Zui</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">Projects</SidebarGroupLabel>
<SidebarMenu>
{projects.map((project) => {
const Icon = getProjectIcon(project.iconId)
const isActive = selectedProjectId === project.id
return (
<SidebarMenuItem key={project.id}>
<SidebarMenuButton
tooltip={project.name}
isActive={isActive}
onClick={() => onSelectProject(project.id)}
>
<Icon className="size-4" />
<span>{project.name}</span>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction showOnHover>
<MoreHorizontal />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align={isMobile ? 'end' : 'start'}
>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => onDeleteProject(project.id)}
>
<Trash2 className="size-4" />
Delete project
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
)
})}
<SidebarMenuItem>
<NewProjectDialog
onCreate={onCreateProject}
trigger={
<SidebarMenuButton className="text-sidebar-foreground/70 w-full cursor-pointer">
<Plus className="size-4" />
<span>New project</span>
</SidebarMenuButton>
}
/>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter />
<SidebarRail />
</Sidebar>
</>
)
}

View File

@@ -0,0 +1,111 @@
/**
* Dialog to create a new project: name + icon.
*/
import React, { useState } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Plus } from 'lucide-react'
import { PROJECT_ICON_IDS, type Project, type ProjectIconId } from './types'
import { getProjectIcon } from './icon-map'
type NewProjectDialogProps = {
onCreate: (project: Project) => void
trigger?: React.ReactNode
}
export function NewProjectDialog({ onCreate, trigger }: NewProjectDialogProps) {
const [open, setOpen] = useState(false)
const [name, setName] = useState('')
const [iconId, setIconId] = useState<ProjectIconId>('layout')
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const trimmed = name.trim()
if (!trimmed) return
const project: Project = {
id: `proj_${Date.now()}`,
name: trimmed,
iconId,
createdAt: Date.now(),
}
onCreate(project)
setName('')
setIconId('layout')
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline" size="sm" className="w-full justify-start gap-2">
<Plus className="size-4" />
New project
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>New project</DialogTitle>
<DialogDescription>Create a project to start editing a graph canvas.</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<label htmlFor="project-name" className="text-sm font-medium">
Name
</label>
<Input
id="project-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My project"
autoFocus
/>
</div>
<div className="grid gap-2">
<label className="text-sm font-medium">Icon</label>
<Select value={iconId} onValueChange={(v) => setIconId(v as ProjectIconId)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROJECT_ICON_IDS.map((id) => {
const Icon = getProjectIcon(id)
return (
<SelectItem key={id} value={id}>
<span className="flex items-center gap-2">
<Icon className="size-4" />
{id}
</span>
</SelectItem>
)
})}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={!name.trim()}>
Create
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,133 @@
/**
* Platform: main entry layout with sidebar (sidebar-07). Projects in sidebar;
* when a project is selected, the canvas is shown in the main area.
*/
import React, { useCallback, useState } from 'react'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
import { Separator } from '@/components/ui/separator'
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'
import { Button } from '@/components/ui/button'
import { Plus } from 'lucide-react'
import { AppSidebar } from './AppSidebar'
import { CanvasPage } from '../canvas/CanvasPage'
import type { Project } from './types'
import { NewProjectDialog } from './NewProjectDialog'
const STORAGE_KEY = 'zui_platform_projects'
function loadProjects(): Project[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
return parsed.filter(
(p): p is Project =>
p &&
typeof p === 'object' &&
typeof (p as Project).id === 'string' &&
typeof (p as Project).name === 'string' &&
typeof (p as Project).iconId === 'string' &&
typeof (p as Project).createdAt === 'number'
)
} catch {
return []
}
}
function saveProjects(projects: Project[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(projects))
}
export function PlatformPage() {
const [projects, setProjects] = useState<Project[]>(loadProjects)
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(() => {
const list = loadProjects()
return list.length > 0 ? list[0].id : null
})
const persist = useCallback((next: Project[]) => {
setProjects(next)
saveProjects(next)
}, [])
const handleCreateProject = useCallback(
(project: Project) => {
persist([...projects, project])
setSelectedProjectId(project.id)
},
[projects, persist]
)
const handleDeleteProject = useCallback(
(id: string) => {
const next = projects.filter((p) => p.id !== id)
persist(next)
if (selectedProjectId === id) setSelectedProjectId(next[0]?.id ?? null)
},
[projects, persist, selectedProjectId]
)
const selectedProject = projects.find((p) => p.id === selectedProjectId)
return (
<SidebarProvider>
<AppSidebar
projects={projects}
selectedProjectId={selectedProjectId}
onSelectProject={setSelectedProjectId}
onDeleteProject={handleDeleteProject}
onCreateProject={handleCreateProject}
/>
<SidebarInset className="flex min-h-0 flex-1 flex-col">
<header className="flex h-12 shrink-0 items-center gap-2 border-b border-border/40 px-4 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div className="flex items-center gap-2 px-2">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink href="#">Zui</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
<BreadcrumbItem>
<BreadcrumbPage className="line-clamp-1">
{selectedProject ? selectedProject.name : 'No project'}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col">
{selectedProjectId && selectedProject ? (
<div className="flex-1 min-h-0 flex flex-col">
<CanvasPage projectId={selectedProjectId} />
</div>
) : (
<div className="flex flex-1 flex-col items-center justify-center gap-4 rounded-xl border border-dashed bg-muted/30 p-8">
<p className="text-sm text-muted-foreground">No project selected. Create one to open the canvas.</p>
<NewProjectDialog
onCreate={handleCreateProject}
trigger={
<Button>
<Plus className="size-4" />
New project
</Button>
}
/>
</div>
)}
</div>
</SidebarInset>
</SidebarProvider>
)
}

View File

@@ -0,0 +1,27 @@
/**
* Map project icon ids to Lucide icons for the sidebar.
*/
import {
LayoutDashboard,
Folder,
FileStack,
Sparkles,
Box,
Layers,
type LucideIcon,
} from 'lucide-react'
import type { ProjectIconId } from './types'
export const PROJECT_ICON_MAP: Record<ProjectIconId, LucideIcon> = {
layout: LayoutDashboard,
folder: Folder,
'file-stack': FileStack,
sparkles: Sparkles,
box: Box,
layers: Layers,
}
export function getProjectIcon(iconId: string): LucideIcon {
return PROJECT_ICON_MAP[iconId as ProjectIconId] ?? LayoutDashboard
}

View File

@@ -0,0 +1,24 @@
/**
* Platform types: projects and sidebar state.
*/
import type { LucideIcon } from 'lucide-react'
export type Project = {
id: string
name: string
/** Icon identifier: key of PROJECT_ICONS map */
iconId: string
createdAt: number
}
export const PROJECT_ICON_IDS = [
'layout',
'folder',
'file-stack',
'sparkles',
'box',
'layers',
] as const
export type ProjectIconId = (typeof PROJECT_ICON_IDS)[number]