From 4e51b6866f928c4fa5bdb1680cf0ada94c336ed9 Mon Sep 17 00:00:00 2001 From: dtoro Date: Mon, 9 Mar 2026 20:51:29 +0100 Subject: [PATCH] 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. --- frontend/package-lock.json | 253 ++++++ frontend/package.json | 5 + .../{App.tsx => app/canvas/CanvasPage.tsx} | 206 +++-- frontend/src/app/platform/AppSidebar.tsx | 124 +++ .../src/app/platform/NewProjectDialog.tsx | 111 +++ frontend/src/app/platform/PlatformPage.tsx | 133 +++ frontend/src/app/platform/icon-map.tsx | 27 + frontend/src/app/platform/types.ts | 24 + frontend/src/components/CanvasMenubar.tsx | 150 ++++ frontend/src/components/ui/avatar.tsx | 48 ++ frontend/src/components/ui/breadcrumb.tsx | 115 +++ frontend/src/components/ui/collapsible.tsx | 11 + frontend/src/components/ui/dialog.tsx | 120 +++ frontend/src/components/ui/dropdown-menu.tsx | 199 +++++ frontend/src/components/ui/sheet.tsx | 140 ++++ frontend/src/components/ui/sidebar.tsx | 771 ++++++++++++++++++ frontend/src/components/ui/skeleton.tsx | 15 + frontend/src/components/ui/tooltip.tsx | 30 + frontend/src/hooks/use-mobile.tsx | 19 + frontend/src/main.tsx | 4 +- frontend/src/styles.css | 39 +- frontend/tailwind.config.cjs | 118 +-- 22 files changed, 2488 insertions(+), 174 deletions(-) rename frontend/src/{App.tsx => app/canvas/CanvasPage.tsx} (79%) create mode 100644 frontend/src/app/platform/AppSidebar.tsx create mode 100644 frontend/src/app/platform/NewProjectDialog.tsx create mode 100644 frontend/src/app/platform/PlatformPage.tsx create mode 100644 frontend/src/app/platform/icon-map.tsx create mode 100644 frontend/src/app/platform/types.ts create mode 100644 frontend/src/components/CanvasMenubar.tsx create mode 100644 frontend/src/components/ui/avatar.tsx create mode 100644 frontend/src/components/ui/breadcrumb.tsx create mode 100644 frontend/src/components/ui/collapsible.tsx create mode 100644 frontend/src/components/ui/dialog.tsx create mode 100644 frontend/src/components/ui/dropdown-menu.tsx create mode 100644 frontend/src/components/ui/sheet.tsx create mode 100644 frontend/src/components/ui/sidebar.tsx create mode 100644 frontend/src/components/ui/skeleton.tsx create mode 100644 frontend/src/components/ui/tooltip.tsx create mode 100644 frontend/src/hooks/use-mobile.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8bd692b..20eca3b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,13 +10,18 @@ "dependencies": { "@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-markdown": "^6.5.0", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-menubar": "^1.1.16", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tooltip": "^1.2.8", "@uiw/react-codemirror": "^4.25.7", "@wireweave/core": "^2.6.0", "@xyflow/react": "^12.10.1", @@ -1756,6 +1761,101 @@ } } }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.11.tgz", + "integrity": "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", @@ -1858,6 +1958,60 @@ } } }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", @@ -1900,6 +2054,35 @@ } } }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-focus-guards": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", @@ -2409,6 +2592,58 @@ } } }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -2479,6 +2714,24 @@ } } }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-layout-effect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 45299b3..0345ee1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,13 +10,18 @@ "dependencies": { "@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-markdown": "^6.5.0", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-menubar": "^1.1.16", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tooltip": "^1.2.8", "@uiw/react-codemirror": "^4.25.7", "@wireweave/core": "^2.6.0", "@xyflow/react": "^12.10.1", diff --git a/frontend/src/App.tsx b/frontend/src/app/canvas/CanvasPage.tsx similarity index 79% rename from frontend/src/App.tsx rename to frontend/src/app/canvas/CanvasPage.tsx index 0135171..3937c56 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/app/canvas/CanvasPage.tsx @@ -1,3 +1,8 @@ +/** + * 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, @@ -17,10 +22,10 @@ import { 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 { AnimatedEdge } from '@/components/base/AnimatedEdge' +import FlowContext from '@/lib/flowContext' +import { useTheme } from '@/lib/themeContext' +import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory' import { ContextMenu, ContextMenuContent, @@ -30,9 +35,9 @@ import { ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, - ContextMenuGroup -} from "@/components/ui/context-menu" -import { AppMenubar } from '@/components/AppMenubar' + ContextMenuGroup, +} from '@/components/ui/context-menu' +import { CanvasMenubar } from '@/components/CanvasMenubar' import { FlowKeyboardShortcuts } from '@/components/FlowKeyboardShortcuts' import { Empty, @@ -44,14 +49,14 @@ import { } 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 { DEFAULT_NODE_STYLE, getDefaultDataForType, getNextNodeId } from '@/lib/flowUtils' import { getRegisteredNodeTypes, getRegisteredNodeTypeIds, getDefaultStyle, isConnectionAllowed, -} from './lib/nodeRegistry' -import type { AppNode, AppEdge } from './lib/nodeTypes' +} 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 } => ({ @@ -108,21 +113,23 @@ function getExampleGraph(): { nodes: AppNode[]; edges: AppEdge[] } { const PROJECT_FILE_EXT = '.zui.json' const PROJECT_VERSION = 1 -export type ProjectMessage = { type: 'success' | 'error'; text: string } +export type CanvasMessage = { type: 'success' | 'error'; text: string } -/** Calls fitView when nodes are initialized (e.g. after load/import). Must be rendered inside ReactFlowProvider. */ function FlowFitViewOnLoad() { const nodesInitialized = useNodesInitialized() const { fitView } = useReactFlow() React.useEffect(() => { - if (nodesInitialized) { - fitView?.({ duration: 200 }) - } + if (nodesInitialized) fitView?.({ duration: 200 }) }, [nodesInitialized, fitView]) return null } -export default function App() { +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, @@ -141,21 +148,19 @@ export default function App() { } = useGraphStateWithHistory(getExampleGraph().nodes, getExampleGraph().edges) const importInputRef = useRef(null) - const [rfInstance, setRfInstance] = React.useState(null) + const [rfInstance, setRfInstance] = React.useState(null) const [renamingNodeId, setRenamingNodeId] = React.useState(null) const [connectionFrom, setConnectionFrom] = React.useState<{ nodeId: string; sourceHandle?: string } | null>(null) - const [projectMessage, setProjectMessage] = React.useState(null) - - const wrapperRef = React.useRef(null) - const lastClickRef = React.useRef<{ clientX: number; clientY: number } | null>(null) - const flowActionsRef = React.useRef<{ pasteAtViewportCenter: () => void; fitView: () => void } | null>(null) + const [canvasMessage, setCanvasMessage] = React.useState(null) + const wrapperRef = useRef(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) const [lastCreatedNodeId, setLastCreatedNodeId] = React.useState(null) const [ariaAnnouncement, setAriaAnnouncement] = React.useState(null) - const nodesRef = React.useRef(nodes) + const nodesRef = useRef(nodes) nodesRef.current = nodes - // Example backend API fetch (dev proxy or Docker nginx → backend) const [apiTodosCount, setApiTodosCount] = React.useState(null) React.useEffect(() => { fetch('/api/todos') @@ -166,7 +171,6 @@ export default function App() { .catch(() => setApiTodosCount(-1)) }, []) - // Throttle node changes during drag: merge changes and flush at most once per animation frame to reduce re-renders const pendingChangesRef = useRef[]>([]) const rafRef = useRef(null) const onNodesChange = useCallback( @@ -179,31 +183,25 @@ export default function App() { 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) - } + } 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)) - } + if (toApply.length > 0) setNodesSilent((nds) => applyNodeChanges(toApply, nds)) }) } }, [setNodesSilent] ) - const nodeTypes = React.useMemo( + const nodeTypes = useMemo( () => Object.fromEntries(getRegisteredNodeTypes().map((r) => [r.id, r.component])), [] ) - - const edgeTypes = React.useMemo(() => ({ animated: AnimatedEdge }), []) - - const defaultEdgeOptions = React.useMemo(() => ({ type: 'animated' as const }), []) + const edgeTypes = useMemo(() => ({ animated: AnimatedEdge }), []) + const defaultEdgeOptions = useMemo(() => ({ type: 'animated' as const }), []) const onEdgesChange = useCallback( (changes: EdgeChange[]) => { @@ -213,29 +211,28 @@ export default function App() { [setEdges] ) - const onConnect = React.useCallback( + const onConnect = useCallback( (params: Connection) => setEdges((eds) => addEdge(params, eds)), [setEdges] ) - const isValidConnection = React.useCallback( + 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 - return isConnectionAllowed( - sourceType, - targetType, - connection.source, - connection.target - ) + 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 = React.useCallback( + 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) @@ -245,28 +242,18 @@ export default function App() { }, [] ) + const onConnectEnd = useCallback(() => setConnectionFrom(null), []) - const onConnectEnd = React.useCallback(() => { - setConnectionFrom(null) - }, []) - - const onInit = React.useCallback((instance: any) => { - setRfInstance(instance) - }, []) + const onInit = useCallback((instance: unknown) => setRfInstance(instance), []) React.useEffect(() => { - if (!projectMessage) return - const t = setTimeout(() => setProjectMessage(null), 3000) + if (!canvasMessage) return + const t = setTimeout(() => setCanvasMessage(null), 3000) return () => clearTimeout(t) - }, [projectMessage]) + }, [canvasMessage]) - const onNodeDragStart = useCallback(() => { - saveForDragEnd() - }, [saveForDragEnd]) - - const onNodeDragStop = useCallback(() => { - commitDragEnd() - }, [commitDragEnd]) + const onNodeDragStart = useCallback(() => saveForDragEnd(), [saveForDragEnd]) + const onNodeDragStop = useCallback(() => commitDragEnd(), [commitDragEnd]) const handleExportProject = useCallback(() => { const state = { version: PROJECT_VERSION, nodes, edges } @@ -277,17 +264,15 @@ export default function App() { a.download = `project${PROJECT_FILE_EXT}` a.click() URL.revokeObjectURL(url) - setProjectMessage({ type: 'success', text: 'Project exported' }) + setCanvasMessage({ type: 'success', text: 'Project exported' }) }, [nodes, edges]) - const handleImportProject = useCallback(() => { - importInputRef.current?.click() - }, []) + const handleImportProject = useCallback(() => importInputRef.current?.click(), []) const handleLoadExample = useCallback(() => { const { nodes: exampleNodes, edges: exampleEdges } = getExampleGraph() setStateImmediate({ nodes: exampleNodes, edges: exampleEdges }) - setProjectMessage({ type: 'success', text: 'Example loaded' }) + setCanvasMessage({ type: 'success', text: 'Example loaded' }) }, [setStateImmediate]) const onImportFileChange = useCallback( @@ -301,17 +286,17 @@ export default function App() { 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)) { - setProjectMessage({ type: 'error', text: 'Invalid file: expected nodes and edges arrays' }) + setCanvasMessage({ type: 'error', text: 'Invalid file: expected nodes and edges arrays' }) return } setStateImmediate({ nodes: state.nodes as AppNode[], edges: state.edges as AppEdge[] }) - setProjectMessage( + 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 { - setProjectMessage({ type: 'error', text: 'Invalid file: not valid JSON' }) + setCanvasMessage({ type: 'error', text: 'Invalid file: not valid JSON' }) } } reader.readAsText(file) @@ -346,17 +331,7 @@ export default function App() { ] ) - const onContextMenuCapture = React.useCallback((ev: React.MouseEvent) => { - const target = ev.target as HTMLElement - const nodeEl = target.closest('.react-flow__node') - if (nodeEl) { - ev.preventDefault() - ev.stopPropagation() - } - }, []) - - // Disable canvas zoom when the cursor is over a node (scroll/pinch only zooms when over the pane) - const onWheelCapture = React.useCallback((ev: React.WheelEvent) => { + const onContextMenuCapture = useCallback((ev: React.MouseEvent) => { const target = ev.target as HTMLElement if (target.closest('.react-flow__node')) { ev.preventDefault() @@ -364,29 +339,36 @@ export default function App() { } }, []) - const onCanvasContextMenu = React.useCallback((ev: React.MouseEvent) => { - ev.preventDefault() - const clientX = ev.clientX - const clientY = ev.clientY - lastClickRef.current = { clientX, clientY } - setContextTarget({ type: 'canvas', clientX, clientY }) + const onWheelCapture = useCallback((ev: React.WheelEvent) => { + const target = ev.target as HTMLElement + if (target.closest('.react-flow__node')) { + ev.preventDefault() + ev.stopPropagation() + } }, []) - const getMenuPosition = React.useCallback(() => { + 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 screenToFlow = rfInstance.screenToFlowPosition ?? rfInstance.project - const p = screenToFlow.call(rfInstance, { x: clientX, y: clientY }) - return snapToGrid(p.x, p.y) + 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 = React.useCallback( + const createNode = useCallback( (type: string) => { const position = getMenuPosition() if (position == null) return @@ -425,19 +407,19 @@ export default function App() { return () => cancelAnimationFrame(raf) }, [lastCreatedNodeId, nodes]) - const pasteNode = React.useCallback(async () => { + 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?: any; position?: { x: number; y: number }; style?: any } + const raw = JSON.parse(text) as { id?: string; type?: string; data?: Record; 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 != null) data.title = `config-${newId}` + if (raw.type === 'config' && data && 'title' in data) data.title = `config-${newId}` const style = getDefaultStyle(raw.type) const newNode: Node = { id: newId, @@ -451,11 +433,11 @@ export default function App() { lastClickRef.current = null setContextTarget(null) } catch { - // Invalid clipboard or not a copied node — do nothing + /* ignore */ } }, [getMenuPosition, setNodes]) - const deleteNode = React.useCallback( + const deleteNode = useCallback( (id: string | undefined) => { if (!id) return applyGraph(({ nodes: nds, edges: eds }) => ({ @@ -475,12 +457,7 @@ export default function App() { onContextMenu={onCanvasContextMenu} style={{ position: 'relative' }} > -
+
{ariaAnnouncement}
- = 0 ? `${apiTodosCount} todos` : 'unavailable'}
)} - {projectMessage && ( + {canvasMessage && (
- {projectMessage.text} + {canvasMessage.text}
)}
@@ -538,7 +516,8 @@ export default function App() { Start adding a new node! - Right‑click to add nodes.
Import a project or paste a node. + Right‑click to add nodes.
+ Import a project or paste a node.
@@ -593,7 +572,6 @@ export default function App() {
- @@ -623,8 +601,6 @@ export default function App() { - + ) } - - diff --git a/frontend/src/app/platform/AppSidebar.tsx b/frontend/src/app/platform/AppSidebar.tsx new file mode 100644 index 0000000..665406b --- /dev/null +++ b/frontend/src/app/platform/AppSidebar.tsx @@ -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 ( + <> + + + + + + + Zo + + Zui + + + + + + + Projects + + {projects.map((project) => { + const Icon = getProjectIcon(project.iconId) + const isActive = selectedProjectId === project.id + return ( + + onSelectProject(project.id)} + > + + {project.name} + + + + + + More + + + + onDeleteProject(project.id)} + > + + Delete project + + + + + ) + })} + + + + New project + + } + /> + + + + + + + + + ) +} diff --git a/frontend/src/app/platform/NewProjectDialog.tsx b/frontend/src/app/platform/NewProjectDialog.tsx new file mode 100644 index 0000000..00a06bb --- /dev/null +++ b/frontend/src/app/platform/NewProjectDialog.tsx @@ -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('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 ( + + + {trigger ?? ( + + )} + + +
+ + New project + Create a project to start editing a graph canvas. + +
+
+ + setName(e.target.value)} + placeholder="My project" + autoFocus + /> +
+
+ + +
+
+ + + + +
+
+
+ ) +} diff --git a/frontend/src/app/platform/PlatformPage.tsx b/frontend/src/app/platform/PlatformPage.tsx new file mode 100644 index 0000000..c1e0aa8 --- /dev/null +++ b/frontend/src/app/platform/PlatformPage.tsx @@ -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(loadProjects) + const [selectedProjectId, setSelectedProjectId] = useState(() => { + 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 ( + + + +
+
+ + + + + + Zui + + + + + {selectedProject ? selectedProject.name : 'No project'} + + + + +
+
+
+ {selectedProjectId && selectedProject ? ( +
+ +
+ ) : ( +
+

No project selected. Create one to open the canvas.

+ + + New project + + } + /> +
+ )} +
+
+
+ ) +} diff --git a/frontend/src/app/platform/icon-map.tsx b/frontend/src/app/platform/icon-map.tsx new file mode 100644 index 0000000..0348042 --- /dev/null +++ b/frontend/src/app/platform/icon-map.tsx @@ -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 = { + 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 +} diff --git a/frontend/src/app/platform/types.ts b/frontend/src/app/platform/types.ts new file mode 100644 index 0000000..255eb85 --- /dev/null +++ b/frontend/src/app/platform/types.ts @@ -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] diff --git a/frontend/src/components/CanvasMenubar.tsx b/frontend/src/components/CanvasMenubar.tsx new file mode 100644 index 0000000..e3e9a6f --- /dev/null +++ b/frontend/src/components/CanvasMenubar.tsx @@ -0,0 +1,150 @@ +/** + * Menubar for the canvas page: Project (Import/Export), Edit (Undo/Redo), View (Fit View, Theme). + */ + +import React, { useEffect } from 'react' +import { + Menubar, + MenubarContent, + MenubarItem, + MenubarMenu, + MenubarSeparator, + MenubarTrigger, + MenubarSub, + MenubarSubTrigger, + MenubarSubContent, + MenubarCheckboxItem, +} from '@/components/ui/menubar' +import { Kbd, KbdGroup } from '@/components/ui/kbd' +import { useTheme } from '@/lib/themeContext' +import { Download, FolderOpen, Moon, Redo2, Sun, Undo2 } from 'lucide-react' + +export type CanvasMenubarProps = { + onImport: () => void + onExport: () => void + undo: () => void + redo: () => void + canUndo: boolean + canRedo: boolean + onFitView?: () => void +} + +const UNDO_KEYS = { key: 'z', shiftKey: false } +const REDO_KEYS = { key: 'z', shiftKey: true } + +function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) { + const mod = ev.ctrlKey || ev.metaKey + return ev.key.toLowerCase() === want.key && !!mod && !!ev.shiftKey === want.shiftKey +} + +export function CanvasMenubar({ + onImport, + onExport, + undo, + redo, + canUndo, + canRedo, + onFitView, +}: CanvasMenubarProps) { + const { theme, setTheme } = useTheme() + + useEffect(() => { + const onKeyDown = (ev: KeyboardEvent) => { + if (matchKey(ev, UNDO_KEYS)) { + if (canUndo) { + ev.preventDefault() + ev.stopPropagation() + undo() + } + return + } + if (matchKey(ev, REDO_KEYS)) { + if (canRedo) { + ev.preventDefault() + ev.stopPropagation() + redo() + } + } + } + window.addEventListener('keydown', onKeyDown, true) + return () => window.removeEventListener('keydown', onKeyDown, true) + }, [undo, redo, canUndo, canRedo]) + + return ( + + + Project + + + + Import… + + + + Export… + + + + + Edit + + + + Undo + + + ⌘ + Z + + + + + + Redo + + + ⌘ + ⇧ + Z + + + + + + + View + + {onFitView && ( + + Fit View + + + ⌘0 + + + + )} + {onFitView && } + + Theme + + setTheme('light')} + className="gap-2" + > + + Light + + setTheme('dark')} + className="gap-2" + > + + Dark + + + + + + + ) +} diff --git a/frontend/src/components/ui/avatar.tsx b/frontend/src/components/ui/avatar.tsx new file mode 100644 index 0000000..991f56e --- /dev/null +++ b/frontend/src/components/ui/avatar.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/frontend/src/components/ui/breadcrumb.tsx b/frontend/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..60e6c96 --- /dev/null +++ b/frontend/src/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>