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>
)
}