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:
606
frontend/src/app/canvas/CanvasPage.tsx
Normal file
606
frontend/src/app/canvas/CanvasPage.tsx
Normal 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>
|
||||
Right‑click 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>
|
||||
)
|
||||
}
|
||||
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