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:
124
frontend/src/app/platform/AppSidebar.tsx
Normal file
124
frontend/src/app/platform/AppSidebar.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
111
frontend/src/app/platform/NewProjectDialog.tsx
Normal file
111
frontend/src/app/platform/NewProjectDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
133
frontend/src/app/platform/PlatformPage.tsx
Normal file
133
frontend/src/app/platform/PlatformPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
frontend/src/app/platform/icon-map.tsx
Normal file
27
frontend/src/app/platform/icon-map.tsx
Normal 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
|
||||
}
|
||||
24
frontend/src/app/platform/types.ts
Normal file
24
frontend/src/app/platform/types.ts
Normal 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]
|
||||
Reference in New Issue
Block a user