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,606 @@
/**
* Canvas page: the graph editor (React Flow) with nodes, edges, context menu, import/export.
* Rendered inside the platform when a project is selected.
*/
import React, { useCallback, useMemo, useRef } from 'react'
import {
ReactFlow,
ReactFlowProvider,
Controls,
Background,
MiniMap,
addEdge,
applyNodeChanges,
applyEdgeChanges,
useNodesInitialized,
useReactFlow,
type Node,
type Edge,
type Connection,
type ColorMode,
type NodeChange,
type EdgeChange,
} from '@xyflow/react'
import { AnimatedEdge } from '@/components/base/AnimatedEdge'
import FlowContext from '@/lib/flowContext'
import { useTheme } from '@/lib/themeContext'
import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
ContextMenuGroup,
} from '@/components/ui/context-menu'
import { CanvasMenubar } from '@/components/CanvasMenubar'
import { FlowKeyboardShortcuts } from '@/components/FlowKeyboardShortcuts'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty'
import { Button } from '@/components/ui/button'
import { ClipboardPaste, FolderOpen, FileStack, CircleDotDashed } from 'lucide-react'
import { DEFAULT_NODE_STYLE, getDefaultDataForType, getNextNodeId } from '@/lib/flowUtils'
import {
getRegisteredNodeTypes,
getRegisteredNodeTypeIds,
getDefaultStyle,
isConnectionAllowed,
} from '@/lib/nodeRegistry'
import type { AppNode, AppEdge } from '@/lib/nodeTypes'
const SNAP_GRID: [number, number] = [15, 15]
const snapToGrid = (x: number, y: number): { x: number; y: number } => ({
x: Math.round(x / SNAP_GRID[0]) * SNAP_GRID[0],
y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1],
})
const NODE_GAP = 150
const initialNodes: AppNode[] = [
{
id: 'var_001',
position: { x: 50, y: 100 },
data: { value: 'Zoe', valueType: 'string' as const },
type: 'variable',
style: DEFAULT_NODE_STYLE.variable,
},
{
id: 'cfg_001',
position: { x: 50 + DEFAULT_NODE_STYLE.variable.width + NODE_GAP, y: 50 },
data: {
plantuml: '@startuml\nactor User\nparticipant "{{ var_001 }}" as R\nUser -> R : loves\n@enduml\n',
title: 'config-cfg_001',
},
type: 'config',
style: DEFAULT_NODE_STYLE.config,
},
{
id: 'rnd_001',
position: {
x: 50 + DEFAULT_NODE_STYLE.variable.width + NODE_GAP + DEFAULT_NODE_STYLE.config.width + NODE_GAP,
y: 50,
},
data: {},
type: 'render',
style: DEFAULT_NODE_STYLE.render,
},
]
const initialEdges: AppEdge[] = [
{ id: 'e-var_001-cfg_001', source: 'var_001', target: 'cfg_001', type: 'animated' },
{ id: 'e-cfg_001-rnd_001', source: 'cfg_001', target: 'rnd_001', type: 'animated' },
]
function getExampleGraph(): { nodes: AppNode[]; edges: AppEdge[] } {
return {
nodes: initialNodes.map((n) => ({
...n,
data: n.data && typeof n.data === 'object' ? { ...(n.data as object) } : n.data,
})),
edges: initialEdges.map((e) => ({ ...e })),
}
}
const PROJECT_FILE_EXT = '.zui.json'
const PROJECT_VERSION = 1
export type CanvasMessage = { type: 'success' | 'error'; text: string }
function FlowFitViewOnLoad() {
const nodesInitialized = useNodesInitialized()
const { fitView } = useReactFlow()
React.useEffect(() => {
if (nodesInitialized) fitView?.({ duration: 200 })
}, [nodesInitialized, fitView])
return null
}
export type CanvasPageProps = {
/** Optional project id for future per-project graph loading */
projectId?: string
}
export function CanvasPage({ projectId: _projectId }: CanvasPageProps) {
const { theme } = useTheme()
const {
nodes,
edges,
setNodes,
setEdges,
setNodesSilent,
applyGraph,
saveForDragEnd,
commitDragEnd,
undo,
redo,
canUndo,
canRedo,
setStateImmediate,
} = useGraphStateWithHistory(getExampleGraph().nodes, getExampleGraph().edges)
const importInputRef = useRef<HTMLInputElement | null>(null)
const [rfInstance, setRfInstance] = React.useState<unknown>(null)
const [renamingNodeId, setRenamingNodeId] = React.useState<string | null>(null)
const [connectionFrom, setConnectionFrom] = React.useState<{ nodeId: string; sourceHandle?: string } | null>(null)
const [canvasMessage, setCanvasMessage] = React.useState<CanvasMessage | null>(null)
const wrapperRef = useRef<HTMLDivElement | null>(null)
const lastClickRef = useRef<{ clientX: number; clientY: number } | null>(null)
const flowActionsRef = useRef<{ pasteAtViewportCenter: () => void; fitView: () => void } | null>(null)
const [contextTarget, setContextTarget] = React.useState<null | { type: 'canvas'; clientX: number; clientY: number }>(null)
const [lastCreatedNodeId, setLastCreatedNodeId] = React.useState<string | null>(null)
const [ariaAnnouncement, setAriaAnnouncement] = React.useState<string | null>(null)
const nodesRef = useRef(nodes)
nodesRef.current = nodes
const [apiTodosCount, setApiTodosCount] = React.useState<number | null>(null)
React.useEffect(() => {
fetch('/api/todos')
.then((r) => r.json())
.then((data: unknown) => {
if (Array.isArray(data)) setApiTodosCount(data.length)
})
.catch(() => setApiTodosCount(-1))
}, [])
const pendingChangesRef = useRef<NodeChange<Node>[]>([])
const rafRef = useRef<number | null>(null)
const onNodesChange = useCallback(
(changes: NodeChange<Node>[]) => {
if (changes.length === 0) return
const pending = pendingChangesRef.current
for (const c of changes) {
const id = (c as { id?: string }).id
if (id != null) {
const i = pending.findIndex((p) => (p as { id?: string }).id === id)
if (i >= 0) pending[i] = c
else pending.push(c)
} else pending.push(c)
}
if (rafRef.current === null) {
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null
const toApply = pendingChangesRef.current.splice(0, pendingChangesRef.current.length)
if (toApply.length > 0) setNodesSilent((nds) => applyNodeChanges(toApply, nds))
})
}
},
[setNodesSilent]
)
const nodeTypes = useMemo(
() => Object.fromEntries(getRegisteredNodeTypes().map((r) => [r.id, r.component])),
[]
)
const edgeTypes = useMemo(() => ({ animated: AnimatedEdge }), [])
const defaultEdgeOptions = useMemo(() => ({ type: 'animated' as const }), [])
const onEdgesChange = useCallback(
(changes: EdgeChange<Edge>[]) => {
if (changes.length === 0) return
setEdges((eds) => applyEdgeChanges(changes, eds))
},
[setEdges]
)
const onConnect = useCallback(
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
[setEdges]
)
const isValidConnection = useCallback(
(connection: Connection) => {
const sourceNode = nodes.find((n) => n.id === connection.source)
const targetNode = nodes.find((n) => n.id === connection.target)
const sourceType = sourceNode?.type
const targetType = targetNode?.type
if (!sourceType || !targetType) return false
if (sourceType === 'render' && targetType === 'config') {
const targetData = targetNode?.data as { configType?: string } | undefined
if (targetData?.configType !== 'markdown') return false
}
return isConnectionAllowed(sourceType, targetType, connection.source, connection.target)
},
[nodes]
)
const onConnectStart = useCallback(
(_: React.MouseEvent | React.TouchEvent, params: { nodeId?: string | null; handleId?: string | null; handleType?: string | null }) => {
if (params.handleType !== 'source' || !params.nodeId) {
setConnectionFrom(null)
return
}
setConnectionFrom({ nodeId: params.nodeId, sourceHandle: params.handleId ?? undefined })
},
[]
)
const onConnectEnd = useCallback(() => setConnectionFrom(null), [])
const onInit = useCallback((instance: unknown) => setRfInstance(instance), [])
React.useEffect(() => {
if (!canvasMessage) return
const t = setTimeout(() => setCanvasMessage(null), 3000)
return () => clearTimeout(t)
}, [canvasMessage])
const onNodeDragStart = useCallback(() => saveForDragEnd(), [saveForDragEnd])
const onNodeDragStop = useCallback(() => commitDragEnd(), [commitDragEnd])
const handleExportProject = useCallback(() => {
const state = { version: PROJECT_VERSION, nodes, edges }
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `project${PROJECT_FILE_EXT}`
a.click()
URL.revokeObjectURL(url)
setCanvasMessage({ type: 'success', text: 'Project exported' })
}, [nodes, edges])
const handleImportProject = useCallback(() => importInputRef.current?.click(), [])
const handleLoadExample = useCallback(() => {
const { nodes: exampleNodes, edges: exampleEdges } = getExampleGraph()
setStateImmediate({ nodes: exampleNodes, edges: exampleEdges })
setCanvasMessage({ type: 'success', text: 'Example loaded' })
}, [setStateImmediate])
const onImportFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ''
if (!file) return
const reader = new FileReader()
reader.onload = () => {
try {
const text = reader.result as string
const state = JSON.parse(text) as { version?: number; nodes?: unknown[]; edges?: unknown[] }
if (!state || !Array.isArray(state.nodes) || !Array.isArray(state.edges)) {
setCanvasMessage({ type: 'error', text: 'Invalid file: expected nodes and edges arrays' })
return
}
setStateImmediate({ nodes: state.nodes as AppNode[], edges: state.edges as AppEdge[] })
setCanvasMessage(
state.version != null && state.version > PROJECT_VERSION
? { type: 'error', text: 'Project was created with a newer app version' }
: { type: 'success', text: 'Project loaded' }
)
} catch {
setCanvasMessage({ type: 'error', text: 'Invalid file: not valid JSON' })
}
}
reader.readAsText(file)
},
[setStateImmediate]
)
const flowContextValue = useMemo(
() => ({
nodes,
setNodes,
edges,
setEdges,
renamingNodeId,
setRenamingNodeId,
connectionFrom,
setConnectionFrom,
isValidConnection,
flowActionsRef,
}),
[
nodes,
setNodes,
edges,
setEdges,
renamingNodeId,
setRenamingNodeId,
connectionFrom,
setConnectionFrom,
isValidConnection,
flowActionsRef,
]
)
const onContextMenuCapture = useCallback((ev: React.MouseEvent) => {
const target = ev.target as HTMLElement
if (target.closest('.react-flow__node')) {
ev.preventDefault()
ev.stopPropagation()
}
}, [])
const onWheelCapture = useCallback((ev: React.WheelEvent) => {
const target = ev.target as HTMLElement
if (target.closest('.react-flow__node')) {
ev.preventDefault()
ev.stopPropagation()
}
}, [])
const onCanvasContextMenu = useCallback((ev: React.MouseEvent) => {
ev.preventDefault()
lastClickRef.current = { clientX: ev.clientX, clientY: ev.clientY }
setContextTarget({ type: 'canvas', clientX: ev.clientX, clientY: ev.clientY })
}, [])
const getMenuPosition = useCallback(() => {
if (!rfInstance) return null
const click = contextTarget ?? lastClickRef.current
const clientX = click?.clientX ?? window.innerWidth / 2
const clientY = click?.clientY ?? window.innerHeight / 2
try {
const inst = rfInstance as { screenToFlowPosition?: (p: { x: number; y: number }) => { x: number; y: number }; project?: (p: { x: number; y: number }) => { x: number; y: number } }
const screenToFlow = inst.screenToFlowPosition ?? inst.project
const p = screenToFlow?.call(rfInstance, { x: clientX, y: clientY })
return p ? snapToGrid(p.x, p.y) : null
} catch {
return snapToGrid(clientX, clientY)
}
}, [rfInstance, contextTarget])
const createNode = useCallback(
(type: string) => {
const position = getMenuPosition()
if (position == null) return
const nodeType = type as Node['type']
const newId = getNextNodeId(nodeType, nodesRef.current.map((n) => n.id))
const dataMap = getDefaultDataForType(nodeType, newId)
const style = getDefaultStyle(nodeType)
const newNode: Node = {
id: newId,
type: nodeType,
position: { x: position.x, y: position.y },
data: dataMap,
style,
}
setNodes((nds) => nds.concat(newNode))
setLastCreatedNodeId(newId)
lastClickRef.current = null
setContextTarget(null)
},
[getMenuPosition, setNodes]
)
React.useEffect(() => {
if (lastCreatedNodeId == null) return
const id = lastCreatedNodeId
const raf = requestAnimationFrame(() => {
const nodeEl = document.querySelector(`.react-flow__node[data-id="${id}"]`) as HTMLElement | null
if (nodeEl) {
setAriaAnnouncement('Node created')
nodeEl.setAttribute('tabindex', '-1')
nodeEl.focus({ preventScroll: false })
setTimeout(() => setAriaAnnouncement(null), 1000)
}
setLastCreatedNodeId(null)
})
return () => cancelAnimationFrame(raf)
}, [lastCreatedNodeId, nodes])
const pasteNode = useCallback(async () => {
const position = getMenuPosition()
if (position == null) return
try {
const text = await navigator.clipboard?.readText()
if (!text) return
const raw = JSON.parse(text) as { id?: string; type?: string; data?: Record<string, unknown>; position?: { x: number; y: number }; style?: unknown }
const validIds = getRegisteredNodeTypeIds()
if (!raw || typeof raw.type !== 'string' || !validIds.includes(raw.type)) return
setNodes((nds) => {
const newId = getNextNodeId(raw.type as Node['type'], nds.map((n) => n.id))
const data = raw.data != null && typeof raw.data === 'object' ? { ...raw.data } : {}
if (raw.type === 'config' && data && 'title' in data) data.title = `config-${newId}`
const style = getDefaultStyle(raw.type)
const newNode: Node = {
id: newId,
type: raw.type as Node['type'],
position: { x: position.x, y: position.y },
data,
style,
}
return nds.concat(newNode)
})
lastClickRef.current = null
setContextTarget(null)
} catch {
/* ignore */
}
}, [getMenuPosition, setNodes])
const deleteNode = useCallback(
(id: string | undefined) => {
if (!id) return
applyGraph(({ nodes: nds, edges: eds }) => ({
nodes: nds.filter((n) => n.id !== id),
edges: eds.filter((e) => e.source !== id && e.target !== id),
}))
setContextTarget(null)
},
[applyGraph]
)
return (
<div
className="reactflow-wrapper flex flex-col h-full"
ref={wrapperRef}
onContextMenuCapture={onContextMenuCapture}
onContextMenu={onCanvasContextMenu}
style={{ position: 'relative' }}
>
<div role="status" aria-live="polite" aria-atomic className="sr-only">
{ariaAnnouncement}
</div>
<input
ref={importInputRef}
type="file"
accept=".json,.zui.json,application/json"
className="hidden"
onChange={onImportFileChange}
aria-hidden
/>
<CanvasMenubar
onImport={handleImportProject}
onExport={handleExportProject}
undo={undo}
redo={redo}
canUndo={canUndo}
canRedo={canRedo}
onFitView={() => flowActionsRef.current?.fitView?.()}
/>
{apiTodosCount !== null && (
<div className="shrink-0 px-3 py-1 text-xs text-muted-foreground border-b border-border/50">
Backend API: {apiTodosCount >= 0 ? `${apiTodosCount} todos` : 'unavailable'}
</div>
)}
{canvasMessage && (
<div
role="status"
className={`shrink-0 px-3 py-2 text-sm ${
canvasMessage.type === 'success'
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200'
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200'
}`}
>
{canvasMessage.text}
</div>
)}
<div className="flex-1 min-h-0 relative flex flex-col">
<div className="flex-1 min-h-0 flex flex-col">
<FlowContext.Provider value={flowContextValue}>
<ContextMenu>
<ContextMenuTrigger asChild>
<div
className="flex-1 min-h-0 w-full relative"
onWheelCapture={onWheelCapture}
role="application"
aria-label="Graph canvas"
tabIndex={0}
>
{nodes.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<div className="pointer-events-auto rounded-lg border border-border bg-background/95 dark:bg-background/90 shadow-sm">
<Empty className="p-6 md:p-8">
<EmptyHeader>
<EmptyMedia variant="icon">
<CircleDotDashed className="size-6" />
</EmptyMedia>
<EmptyTitle>Start adding a new node!</EmptyTitle>
<EmptyDescription>
Rightclick to add nodes. <br />
Import a project or paste a node.
</EmptyDescription>
</EmptyHeader>
<EmptyContent className="flex-row flex-wrap justify-center gap-2">
<Button onClick={handleImportProject} variant="outline" size="sm">
<FolderOpen className="size-4" />
Import
</Button>
<Button onClick={handleLoadExample} variant="outline" size="sm">
<FileStack className="size-4" />
Load example
</Button>
</EmptyContent>
</Empty>
</div>
</div>
)}
<ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView>
<FlowFitViewOnLoad />
<FlowKeyboardShortcuts />
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onConnectStart={onConnectStart}
onConnectEnd={onConnectEnd}
onNodeDragStart={onNodeDragStart}
onNodeDragStop={onNodeDragStop}
isValidConnection={isValidConnection}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
defaultEdgeOptions={defaultEdgeOptions}
colorMode={theme as ColorMode}
snapToGrid
snapGrid={SNAP_GRID}
fitView
onInit={onInit}
nodeDragThreshold={1}
nodesDraggable
nodesConnectable
elementsSelectable
>
<Background variant="dots" gap={20} />
<div role="group" aria-label="Canvas controls: zoom and fit view">
<Controls />
</div>
<div role="region" aria-label="Minimap: overview of the graph">
<MiniMap />
</div>
</ReactFlow>
</ReactFlowProvider>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-48" aria-label="Canvas menu: create node or paste">
<ContextMenuGroup>
<ContextMenuSub>
<ContextMenuSubTrigger>Create Node</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-44">
<ContextMenuGroup>
{getRegisteredNodeTypes().map((desc, index) => (
<React.Fragment key={desc.id}>
{index === 2 ? <ContextMenuSeparator /> : null}
<ContextMenuItem onSelect={() => createNode(desc.id)}>
{desc.menuIcon}
{desc.menuLabel}
</ContextMenuItem>
</React.Fragment>
))}
</ContextMenuGroup>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => pasteNode()}>
<ClipboardPaste className="mr-2 h-4 w-4" />
Paste
</ContextMenuItem>
</ContextMenuGroup>
</ContextMenuContent>
</ContextMenu>
</FlowContext.Provider>
</div>
</div>
</div>
)
}

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]