diff --git a/Data/WebUI/src/App.jsx b/Data/WebUI/src/App.jsx index 8c0b2e1..322926f 100644 --- a/Data/WebUI/src/App.jsx +++ b/Data/WebUI/src/App.jsx @@ -1,13 +1,15 @@ +// App.jsx + // Core React Imports import React, { useState, useEffect, useCallback, useRef -} from "react"; - -// Material UI - Components -import { + } from "react"; + + // Material UI - Components + import { AppBar, Toolbar, Typography, @@ -18,1185 +20,684 @@ import { CssBaseline, ThemeProvider, createTheme, - Accordion, - AccordionSummary, - AccordionDetails, Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Divider, - Tooltip, - Tabs, - Tab, TextField -} from "@mui/material"; - -// Material UI - Icons -import { - DragIndicator as DragIndicatorIcon, + } from "@mui/material"; + + // Material UI - Icons + import { KeyboardArrowDown as KeyboardArrowDownIcon, - ExpandMore as ExpandMoreIcon, - Save as SaveIcon, - FileOpen as FileOpenIcon, - DeleteForever as DeleteForeverIcon, InfoOutlined as InfoOutlinedIcon, - Polyline as PolylineIcon, MergeType as MergeTypeIcon, - People as PeopleIcon, - Add as AddIcon -} from "@mui/icons-material"; - -// React Flow -import ReactFlow, { - Background, - addEdge, - applyNodeChanges, - applyEdgeChanges, - ReactFlowProvider, - useReactFlow -} from "reactflow"; - -// Styles -import "reactflow/dist/style.css"; -import "./Borealis.css"; - -// Global Node Update Timer Variable -if (!window.BorealisUpdateRate) { + People as PeopleIcon + } from "@mui/icons-material"; + + // React Flow + import { ReactFlowProvider } from "reactflow"; + + // Styles + import "reactflow/dist/style.css"; + import "./Borealis.css"; + + // Import our new components + import FlowTabs from "./Flow_Tabs"; + import FlowEditor from "./Flow_Editor"; + import NodeSidebar from "./Node_Sidebar"; + + // Global Node Update Timer Variable + if (!window.BorealisUpdateRate) { window.BorealisUpdateRate = 200; -} - -// Dynamically load all node components -const nodeContext = require.context("./nodes", true, /\.jsx$/); -const nodeTypes = {}; -const categorizedNodes = {}; - -nodeContext.keys().forEach((path) => { + } + + // Dynamically load all node components + const nodeContext = require.context("./nodes", true, /\.jsx$/); + const nodeTypes = {}; + const categorizedNodes = {}; + + nodeContext.keys().forEach((path) => { const mod = nodeContext(path); if (!mod.default) return; const { type, label, component } = mod.default; if (!type || !component) return; - + const pathParts = path.replace("./", "").split("/"); if (pathParts.length < 2) return; const category = pathParts[0]; - + if (!categorizedNodes[category]) { - categorizedNodes[category] = []; + categorizedNodes[category] = []; } categorizedNodes[category].push(mod.default); nodeTypes[type] = component; -}); - -// Single flow editor -function FlowEditor({ nodes, edges, setNodes, setEdges, nodeTypes }) { - const wrapperRef = useRef(null); - const { project } = useReactFlow(); - const [contextMenu, setContextMenu] = useState(null); - - const onDrop = useCallback( - (event) => { - event.preventDefault(); - const type = event.dataTransfer.getData("application/reactflow"); - if (!type) return; - - const bounds = wrapperRef.current.getBoundingClientRect(); - const position = project({ - x: event.clientX - bounds.left, - y: event.clientY - bounds.top - }); - - const id = "node-" + Date.now(); - - const nodeMeta = Object.values(categorizedNodes) - .flat() - .find((n) => n.type === type); - - const newNode = { - id: id, - type: type, - position: position, - data: { - label: nodeMeta?.label || type, - content: nodeMeta?.content - } - }; - - setNodes((nds) => [...nds, newNode]); - }, - [project, setNodes] - ); - - const onDragOver = useCallback((event) => { - event.preventDefault(); - event.dataTransfer.dropEffect = "move"; - }, []); - - const onConnect = useCallback( - (params) => - setEdges((eds) => - addEdge( - { - ...params, - type: "smoothstep", - animated: true, - style: { - strokeDasharray: "6 3", - stroke: "#58a6ff" - } - }, - eds - ) - ), - [setEdges] - ); - - const onNodesChange = useCallback( - (changes) => setNodes((nds) => applyNodeChanges(changes, nds)), - [setNodes] - ); - - const onEdgesChange = useCallback( - (changes) => setEdges((eds) => applyEdgeChanges(changes, eds)), - [setEdges] - ); - - const handleRightClick = (e, node) => { - e.preventDefault(); - setContextMenu({ - mouseX: e.clientX + 2, - mouseY: e.clientY - 6, - nodeId: node.id - }); - }; - - const handleDisconnect = () => { - if (contextMenu?.nodeId) { - setEdges((eds) => - eds.filter( - (e) => - e.source !== contextMenu.nodeId && - e.target !== contextMenu.nodeId - ) - ); - } - setContextMenu(null); - }; - - const handleRemoveNode = () => { - if (contextMenu?.nodeId) { - setNodes((nds) => - nds.filter((n) => n.id !== contextMenu.nodeId) - ); - setEdges((eds) => - eds.filter( - (e) => - e.source !== contextMenu.nodeId && - e.target !== contextMenu.nodeId - ) - ); - } - setContextMenu(null); - }; - - useEffect(() => { - const nodeCountEl = document.getElementById("nodeCount"); - if (nodeCountEl) { - nodeCountEl.innerText = nodes.length; - } - }, [nodes]); - - return ( -
- - - - - {/* Right-click node menu */} - setContextMenu(null)} - anchorReference="anchorPosition" - anchorPosition={ - contextMenu - ? { top: contextMenu.mouseY, left: contextMenu.mouseX } - : undefined - } - PaperProps={{ - sx: { - bgcolor: "#1e1e1e", - color: "#fff", - fontSize: "13px" - } - }} - > - - - Disconnect All Edges - - - - Remove Node - - -
- ); -} - -const darkTheme = createTheme({ + }); + + const darkTheme = createTheme({ palette: { - mode: "dark", - background: { - default: "#121212", - paper: "#1e1e1e" - }, - text: { - primary: "#ffffff" - } + mode: "dark", + background: { + default: "#121212", + paper: "#1e1e1e" + }, + text: { + primary: "#ffffff" + } } -}); - -export default function App() { + }); + + export default function App() { const [tabs, setTabs] = useState([ + { + id: "flow_1", + tab_name: "Flow 1", + nodes: [], + edges: [] + } + ]); + const [activeTabId, setActiveTabId] = useState("flow_1"); + + // About menu + const [aboutAnchorEl, setAboutAnchorEl] = useState(null); + const [creditsDialogOpen, setCreditsDialogOpen] = useState(false); + + // Close all flows + const [confirmCloseOpen, setConfirmCloseOpen] = useState(false); + + // Rename tab + const [renameDialogOpen, setRenameDialogOpen] = useState(false); + const [renameTabId, setRenameTabId] = useState(null); + const [renameValue, setRenameValue] = useState(""); + + // Right-click tab menu + const [tabMenuAnchor, setTabMenuAnchor] = useState(null); + const [tabMenuTabId, setTabMenuTabId] = useState(null); + + // File input ref (for imports on older browsers) + const fileInputRef = useRef(null); + + // Setup callbacks to update nodes/edges in the currently active tab + const handleSetNodes = useCallback( + (callbackOrArray, tId) => { + const targetId = tId || activeTabId; + setTabs((old) => + old.map((tab) => { + if (tab.id !== targetId) return tab; + const newNodes = + typeof callbackOrArray === "function" + ? callbackOrArray(tab.nodes) + : callbackOrArray; + return { ...tab, nodes: newNodes }; + }) + ); + }, + [activeTabId] + ); + + const handleSetEdges = useCallback( + (callbackOrArray, tId) => { + const targetId = tId || activeTabId; + setTabs((old) => + old.map((tab) => { + if (tab.id !== targetId) return tab; + const newEdges = + typeof callbackOrArray === "function" + ? callbackOrArray(tab.edges) + : callbackOrArray; + return { ...tab, edges: newEdges }; + }) + ); + }, + [activeTabId] + ); + + // About menu + const handleAboutMenuOpen = (event) => setAboutAnchorEl(event.currentTarget); + const handleAboutMenuClose = () => setAboutAnchorEl(null); + + // Credits + const openCreditsDialog = () => { + handleAboutMenuClose(); + setCreditsDialogOpen(true); + }; + + // Close all dialog + const handleOpenCloseAllDialog = () => setConfirmCloseOpen(true); + const handleCloseDialog = () => setConfirmCloseOpen(false); + const handleConfirmCloseAll = () => { + setTabs([ { + id: "flow_1", + tab_name: "Flow 1", + nodes: [], + edges: [] + } + ]); + setActiveTabId("flow_1"); + setConfirmCloseOpen(false); + }; + + // Create new tab + const createNewTab = () => { + const nextIndex = tabs.length + 1; + const newId = "flow_" + nextIndex; + setTabs((old) => [ + ...old, + { + id: newId, + tab_name: "Flow " + nextIndex, + nodes: [], + edges: [] + } + ]); + setActiveTabId(newId); + }; + + // Handle user clicking on a tab + const handleTabChange = (newActiveTabId) => { + setActiveTabId(newActiveTabId); + }; + + // Right-click tab menu + const handleTabRightClick = (evt, tabId) => { + evt.preventDefault(); + setTabMenuAnchor({ x: evt.clientX, y: evt.clientY }); + setTabMenuTabId(tabId); + }; + const handleCloseTabMenu = () => { + setTabMenuAnchor(null); + setTabMenuTabId(null); + }; + + // Rename / close tab + const handleRenameTab = () => { + setRenameDialogOpen(true); + setRenameTabId(tabMenuTabId); + const t = tabs.find((x) => x.id === tabMenuTabId); + setRenameValue(t ? t.tab_name : ""); + handleCloseTabMenu(); + }; + const handleCloseTab = () => { + setTabs((old) => { + const idx = old.findIndex((t) => t.id === tabMenuTabId); + if (idx === -1) return old; + + const newList = [...old]; + newList.splice(idx, 1); + + // If we closed the current tab, pick a new active tab + if (tabMenuTabId === activeTabId && newList.length > 0) { + setActiveTabId(newList[0].id); + } else if (newList.length === 0) { + // If we closed the only tab, create a fresh one + newList.push({ id: "flow_1", tab_name: "Flow 1", nodes: [], edges: [] + }); + setActiveTabId("flow_1"); } - ]); - const [activeTabId, setActiveTabId] = useState("flow_1"); - - const [aboutAnchorEl, setAboutAnchorEl] = useState(null); - const [confirmCloseOpen, setConfirmCloseOpen] = useState(false); - const [creditsDialogOpen, setCreditsDialogOpen] = useState(false); - const fileInputRef = useRef(null); - - const [renameDialogOpen, setRenameDialogOpen] = useState(false); - const [renameTabId, setRenameTabId] = useState(null); - const [renameValue, setRenameValue] = useState(""); - - const [tabMenuAnchor, setTabMenuAnchor] = useState(null); - const [tabMenuTabId, setTabMenuTabId] = useState(null); - - // Update nodes/edges in a particular tab - const handleSetNodes = useCallback( - (callbackOrArray, tId) => { - const targetId = tId || activeTabId; - setTabs((old) => - old.map((tab) => { - if (tab.id !== targetId) return tab; - const newNodes = - typeof callbackOrArray === "function" - ? callbackOrArray(tab.nodes) - : callbackOrArray; - return { ...tab, nodes: newNodes }; - }) - ); - }, - [activeTabId] - ); - - const handleSetEdges = useCallback( - (callbackOrArray, tId) => { - const targetId = tId || activeTabId; - setTabs((old) => - old.map((tab) => { - if (tab.id !== targetId) return tab; - const newEdges = - typeof callbackOrArray === "function" - ? callbackOrArray(tab.edges) - : callbackOrArray; - return { ...tab, edges: newEdges }; - }) - ); - }, - [activeTabId] - ); - - // About menu - const handleAboutMenuOpen = (event) => setAboutAnchorEl(event.currentTarget); - const handleAboutMenuClose = () => setAboutAnchorEl(null); - - // Close all flows - const handleOpenCloseAllDialog = () => setConfirmCloseOpen(true); - const handleCloseDialog = () => setConfirmCloseOpen(false); - const handleConfirmCloseAll = () => { - setTabs([ - { - id: "flow_1", - tab_name: "Flow 1", - nodes: [], - edges: [] - } - ]); - setActiveTabId("flow_1"); - setConfirmCloseOpen(false); - }; - - // Create new tab - const createNewTab = () => { - const nextIndex = tabs.length + 1; - const newId = "flow_" + nextIndex; - setTabs((old) => [ - ...old, - { - id: newId, - tab_name: "Flow " + nextIndex, - nodes: [], - edges: [] - } - ]); - setActiveTabId(newId); - }; - - // Right-click tab menu - const handleTabRightClick = (evt, tabId) => { - evt.preventDefault(); - setTabMenuAnchor({ x: evt.clientX, y: evt.clientY }); - setTabMenuTabId(tabId); - }; - const handleCloseTabMenu = () => { - setTabMenuAnchor(null); - setTabMenuTabId(null); - }; - - // Rename / close tab - const handleRenameTab = () => { - setRenameDialogOpen(true); - setRenameTabId(tabMenuTabId); - const t = tabs.find((x) => x.id === tabMenuTabId); - setRenameValue(t ? t.tab_name : ""); - handleCloseTabMenu(); - }; - const handleCloseTab = () => { - setTabs((old) => { - const idx = old.findIndex((t) => t.id === tabMenuTabId); - if (idx === -1) return old; - - const newList = [...old]; - newList.splice(idx, 1); - - if (tabMenuTabId === activeTabId && newList.length > 0) { - setActiveTabId(newList[0].id); - } else if (newList.length === 0) { - newList.push({ - id: "flow_1", - tab_name: "Flow 1", - nodes: [], - edges: [] - }); - setActiveTabId("flow_1"); - } - return newList; - }); - handleCloseTabMenu(); + return newList; + }); + handleCloseTabMenu(); }; + const handleRenameDialogSave = () => { - if (!renameTabId) { - setRenameDialogOpen(false); - return; - } - setTabs((old) => - old.map((tab) => - tab.id === renameTabId - ? { ...tab, tab_name: renameValue } - : tab - ) - ); + if (!renameTabId) { setRenameDialogOpen(false); + return; + } + setTabs((old) => + old.map((tab) => + tab.id === renameTabId + ? { ...tab, tab_name: renameValue } + : tab + ) + ); + setRenameDialogOpen(false); }; - + // Export current tab const handleExportFlow = async () => { - const activeTab = tabs.find((x) => x.id === activeTabId); - if (!activeTab) return; - - const data = JSON.stringify( - { - nodes: activeTab.nodes, - edges: activeTab.edges, - tab_name: activeTab.tab_name - }, - null, - 2 - ); - const blob = new Blob([data], { type: "application/json" }); - - if (window.showSaveFilePicker) { - try { - const fileHandle = await window.showSaveFilePicker({ - suggestedName: "workflow.json", - types: [ - { - description: "Workflow JSON File", - accept: { "application/json": [".json"] } - } - ] - }); - const writable = await fileHandle.createWritable(); - await writable.write(blob); - await writable.close(); - } catch (err) { - console.error("Save cancelled or failed:", err); - } - } else { - const a = document.createElement("a"); - a.href = URL.createObjectURL(blob); - a.download = "workflow.json"; - a.style.display = "none"; - document.body.appendChild(a); - a.click(); - URL.revokeObjectURL(a.href); - document.body.removeChild(a); + const activeTab = tabs.find((x) => x.id === activeTabId); + if (!activeTab) return; + + // Build JSON data from the active tab + const data = JSON.stringify( + { + nodes: activeTab.nodes, + edges: activeTab.edges, + tab_name: activeTab.tab_name + }, + null, + 2 + ); + const blob = new Blob([data], { type: "application/json" }); + + // Suggested filename based on the tab name + // e.g. "Nicole Work Flow" => "nicole_work_flow_workflow.json" + const sanitizedTabName = activeTab.tab_name + .replace(/\s+/g, "_") + .toLowerCase(); + const suggestedFilename = sanitizedTabName + "_workflow.json"; + + // Check if showSaveFilePicker is available (Chrome/Edge) + if (window.showSaveFilePicker) { + try { + const fileHandle = await window.showSaveFilePicker({ + suggestedName: suggestedFilename, + types: [ + { + description: "Workflow JSON File", + accept: { "application/json": [".json"] } + } + ] + }); + + const writable = await fileHandle.createWritable(); + await writable.write(blob); + await writable.close(); + } catch (err) { + console.error("Save cancelled or failed:", err); } + } else { + // Fallback for browsers like Firefox + // (Relies on browser settings to ask user where to save) + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = suggestedFilename; // e.g. nicole_work_flow_workflow.json + a.style.display = "none"; + document.body.appendChild(a); + a.click(); + // Cleanup + URL.revokeObjectURL(a.href); + document.body.removeChild(a); + } }; - + // Import flow -> new tab const handleImportFlow = async () => { - if (window.showOpenFilePicker) { - try { - const [fileHandle] = await window.showOpenFilePicker({ - types: [ - { - description: "Workflow JSON File", - accept: { "application/json": [".json"] } - } - ] - }); - const file = await fileHandle.getFile(); - const text = await file.text(); - const json = JSON.parse(text); - - const newId = "flow_" + (tabs.length + 1); - setTabs((prev) => [ - ...prev, - { - id: newId, - tab_name: - json.tab_name || - "Imported Flow " + (tabs.length + 1), - nodes: json.nodes || [], - edges: json.edges || [] - } - ]); - setActiveTabId(newId); - } catch (err) { - console.error("Import cancelled or failed:", err); + if (window.showOpenFilePicker) { + try { + const [fileHandle] = await window.showOpenFilePicker({ + types: [ + { + description: "Workflow JSON File", + accept: { "application/json": [".json"] } + } + ] + }); + const file = await fileHandle.getFile(); + const text = await file.text(); + const json = JSON.parse(text); + + const newId = "flow_" + (tabs.length + 1); + setTabs((prev) => [ + ...prev, + { + id: newId, + tab_name: json.tab_name || "Imported Flow " + (tabs.length + 1), + nodes: json.nodes || [], + edges: json.edges || [] } - } else { - fileInputRef.current?.click(); + ]); + setActiveTabId(newId); + } catch (err) { + console.error("Import cancelled or failed:", err); } + } else { + // Fallback for older browsers + fileInputRef.current?.click(); + } }; - + // Fallback import const handleFileInputChange = async (e) => { - const file = e.target.files[0]; - if (!file) return; - try { - const text = await file.text(); - const json = JSON.parse(text); - - const newId = "flow_" + (tabs.length + 1); - setTabs((prev) => [ - ...prev, - { - id: newId, - tab_name: - json.tab_name || - "Imported Flow " + (tabs.length + 1), - nodes: json.nodes || [], - edges: json.edges || [] - } - ]); - setActiveTabId(newId); - } catch (err) { - console.error("Failed to read file:", err); - } + const file = e.target.files[0]; + if (!file) return; + try { + const text = await file.text(); + const json = JSON.parse(text); + + const newId = "flow_" + (tabs.length + 1); + setTabs((prev) => [ + ...prev, + { + id: newId, + tab_name: json.tab_name || "Imported Flow " + (tabs.length + 1), + nodes: json.nodes || [], + edges: json.edges || [] + } + ]); + setActiveTabId(newId); + } catch (err) { + console.error("Failed to read file:", err); + } }; - - /** - * Tab onChange logic: - * If user clicks the plus β€œtab”, newValue = β€œ__addtab__”. - * Otherwise, newValue is an index: setActiveTab accordingly. - */ - const handleTabChange = (event, newValue) => { - if (newValue === "__addtab__") { - // Create the new tab - createNewTab(); - } else { - // Normal tab index - setActiveTabId(tabs[newValue].id); - } - }; - + return ( - - - - + + + + + + {/* Logo */} + - - - {/* Logo */} - - - - - - - - - - { - handleAboutMenuClose(); - window.open( - "https://git.bunny-lab.io/Borealis", - "_blank" - ); - }} - > - - Gitea Project - - { - handleAboutMenuClose(); - setCreditsDialogOpen(true); - }} - > - - Credits - - - - - - - + + + {/* Additional Title/Info if desired */} + + + + + + { + handleAboutMenuClose(); + window.open("https://git.bunny-lab.io/Borealis", "_blank"); + }} > - {/* Sidebar */} - - - } - sx={{ - backgroundColor: "#2c2c2c", - minHeight: "36px", - "& .MuiAccordionSummary-content": { - margin: 0 - } - }} - > - - Workflows - - - - - - - - - - - } - sx={{ - backgroundColor: "#2c2c2c", - minHeight: "36px", - "& .MuiAccordionSummary-content": { - margin: 0 - } - }} - > - - Nodes - - - - {Object.entries(categorizedNodes).map( - ([category, items]) => ( - - - - {category} - - - {items.map((nodeDef) => ( - - {nodeDef.description || - "Drag & Drop into Editor"} - - } - placement="right" - arrow - > - - - ))} - - ) - )} - - - - - {/* Right content area: tab bar plus flow editors */} - - {/* Tab bar with special 'add tab' value */} - - { - // Return the index of the active tab, - // or fallback to -1 if none - const idx = tabs.findIndex( - (t) => t.id === activeTabId - ); - return idx >= 0 ? idx : 0; - })()} - onChange={handleTabChange} - variant="scrollable" - scrollButtons="auto" - textColor="inherit" - TabIndicatorProps={{ - style: { backgroundColor: "#58a6ff" } - }} - sx={{ - minHeight: "36px", - height: "36px", - flexGrow: 1 - }} - > - {tabs.map((tab, index) => ( - - handleTabRightClick(evt, tab.id) - } - sx={{ - minHeight: "36px", - height: "36px", - textTransform: "none", - backgroundColor: - tab.id === activeTabId - ? "#2C2C2C" - : "transparent", - color: "#58a6ff" - }} - /> - ))} - {/* The 'plus' tab has a special value to detect in onChange */} - } - value="__addtab__" - sx={{ - minHeight: "36px", - height: "36px", - color: "#58a6ff", - textTransform: "none" - }} - /> - - - - {/* The flow editors themselves */} - - {tabs.map((tab) => ( - - - - handleSetNodes(val, tab.id) - } - setEdges={(val) => - handleSetEdges(val, tab.id) - } - nodeTypes={nodeTypes} - /> - - - ))} - - - - - {/* Bottom status bar */} - - Nodes: 0 - - Update Rate (ms): - - - - - - {/* Close All Dialog */} - - Close All Flow Tabs? - - - This will remove all existing flow tabs and - create a fresh tab named Flow 1. - - - - - - - - - {/* Credits */} - setCreditsDialogOpen(false)} - PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }} - > - Borealis Workflow Automation Tool - - - Designed by Nicole Rappe @ Bunny Lab - - - - - - - - {/* Tab Context Menu */} - - Rename - Close - - - {/* Rename Tab Dialog */} - setRenameDialogOpen(false)} - PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }} - > - Rename Tab - - setRenameValue(e.target.value)} - sx={{ - "& .MuiOutlinedInput-root": { - backgroundColor: "#2a2a2a", - color: "#ccc", - "& fieldset": { - borderColor: "#444" - }, - "&:hover fieldset": { - borderColor: "#666" - } - }, - label: { color: "#aaa" }, - mt: 1 - }} - /> - - - - - - - - {/* Hidden file input fallback */} - + Gitea Project + + + + Credits + + + + + + + {/* Sidebar */} + - + + {/* Right content: tab bar + flow editors */} + + {/* Tab bar */} + + + {/* The flow editors themselves */} + + {tabs.map((tab) => ( + + + handleSetNodes(val, tab.id)} + setEdges={(val) => handleSetEdges(val, tab.id)} + nodeTypes={nodeTypes} + categorizedNodes={categorizedNodes} + /> + + + ))} + + + + + {/* Bottom status bar */} + + Nodes: 0 + + Update Rate (ms): + + + + + + {/* Close All Dialog */} + + Close All Flow Tabs? + + + This will remove all existing flow tabs and create a fresh tab named Flow 1. + + + + + + + + + {/* Credits */} + setCreditsDialogOpen(false)} + PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }} + > + Borealis Workflow Automation Tool + + + Designed by Nicole Rappe @ Bunny Lab + + + + + + + + {/* Tab Context Menu */} + + Rename + Close + + + {/* Rename Tab Dialog */} + setRenameDialogOpen(false)} + PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }} + > + Rename Tab + + setRenameValue(e.target.value)} + sx={{ + "& .MuiOutlinedInput-root": { + backgroundColor: "#2a2a2a", + color: "#ccc", + "& fieldset": { + borderColor: "#444" + }, + "&:hover fieldset": { + borderColor: "#666" + } + }, + label: { color: "#aaa" }, + mt: 1 + }} + /> + + + + + + + ); -} + } + \ No newline at end of file diff --git a/Data/WebUI/src/Flow_Editor.jsx b/Data/WebUI/src/Flow_Editor.jsx new file mode 100644 index 0000000..7bc1436 --- /dev/null +++ b/Data/WebUI/src/Flow_Editor.jsx @@ -0,0 +1,214 @@ +// Flow_Editor.jsx + +import React, { useState, useEffect, useCallback, useRef } from "react"; +import ReactFlow, { + Background, + addEdge, + applyNodeChanges, + applyEdgeChanges, + useReactFlow +} from "reactflow"; +import { Menu, MenuItem } from "@mui/material"; +import { + Polyline as PolylineIcon, + DeleteForever as DeleteForeverIcon +} from "@mui/icons-material"; + +import "reactflow/dist/style.css"; +import "./Borealis.css"; + +/** + * Single flow editor component. + * + * Props: + * - nodes + * - edges + * - setNodes + * - setEdges + * - nodeTypes + * - categorizedNodes (used to find node meta info on drop) + */ +export default function FlowEditor({ + nodes, + edges, + setNodes, + setEdges, + nodeTypes, + categorizedNodes +}) { + const wrapperRef = useRef(null); + const { project } = useReactFlow(); + const [contextMenu, setContextMenu] = useState(null); + + const onDrop = useCallback( + (event) => { + event.preventDefault(); + const type = event.dataTransfer.getData("application/reactflow"); + if (!type) return; + + const bounds = wrapperRef.current.getBoundingClientRect(); + const position = project({ + x: event.clientX - bounds.left, + y: event.clientY - bounds.top + }); + + const id = "node-" + Date.now(); + + // Find node definition in the categorizedNodes + const nodeMeta = Object.values(categorizedNodes) + .flat() + .find((n) => n.type === type); + + const newNode = { + id: id, + type: type, + position: position, + data: { + label: nodeMeta?.label || type, + content: nodeMeta?.content + } + }; + + setNodes((nds) => [...nds, newNode]); + }, + [project, setNodes, categorizedNodes] + ); + + const onDragOver = useCallback((event) => { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + }, []); + + const onConnect = useCallback( + (params) => + setEdges((eds) => + addEdge( + { + ...params, + type: "smoothstep", + animated: true, + style: { + strokeDasharray: "6 3", + stroke: "#58a6ff" + } + }, + eds + ) + ), + [setEdges] + ); + + const onNodesChange = useCallback( + (changes) => setNodes((nds) => applyNodeChanges(changes, nds)), + [setNodes] + ); + + const onEdgesChange = useCallback( + (changes) => setEdges((eds) => applyEdgeChanges(changes, eds)), + [setEdges] + ); + + const handleRightClick = (e, node) => { + e.preventDefault(); + setContextMenu({ + mouseX: e.clientX + 2, + mouseY: e.clientY - 6, + nodeId: node.id + }); + }; + + const handleDisconnect = () => { + if (contextMenu?.nodeId) { + setEdges((eds) => + eds.filter( + (e) => + e.source !== contextMenu.nodeId && + e.target !== contextMenu.nodeId + ) + ); + } + setContextMenu(null); + }; + + const handleRemoveNode = () => { + if (contextMenu?.nodeId) { + setNodes((nds) => nds.filter((n) => n.id !== contextMenu.nodeId)); + setEdges((eds) => + eds.filter( + (e) => + e.source !== contextMenu.nodeId && + e.target !== contextMenu.nodeId + ) + ); + } + setContextMenu(null); + }; + + useEffect(() => { + const nodeCountEl = document.getElementById("nodeCount"); + if (nodeCountEl) { + nodeCountEl.innerText = nodes.length; + } + }, [nodes]); + + return ( +
+ + + + + {/* Right-click node menu */} + setContextMenu(null)} + anchorReference="anchorPosition" + anchorPosition={ + contextMenu + ? { top: contextMenu.mouseY, left: contextMenu.mouseX } + : undefined + } + PaperProps={{ + sx: { + bgcolor: "#1e1e1e", + color: "#fff", + fontSize: "13px" + } + }} + > + + + Disconnect All Edges + + + + Remove Node + + +
+ ); +} diff --git a/Data/WebUI/src/Flow_Tabs.jsx b/Data/WebUI/src/Flow_Tabs.jsx new file mode 100644 index 0000000..e7539e2 --- /dev/null +++ b/Data/WebUI/src/Flow_Tabs.jsx @@ -0,0 +1,98 @@ +// Flow_Tabs.jsx + +import React from "react"; +import { Box, Tabs, Tab } from "@mui/material"; +import { Add as AddIcon } from "@mui/icons-material"; + +/** + * Renders the tab bar (including the "add tab" button). + * + * Props: + * - tabs (array of {id, tab_name, nodes, edges}) + * - activeTabId (string) + * - onTabChange(newActiveTabId: string) + * - onAddTab() + * - onTabRightClick(evt: MouseEvent, tabId: string) + */ +export default function FlowTabs({ + tabs, + activeTabId, + onTabChange, + onAddTab, + onTabRightClick +}) { + // Determine the currently active tab index + const activeIndex = (() => { + const idx = tabs.findIndex((t) => t.id === activeTabId); + return idx >= 0 ? idx : 0; + })(); + + // Handle tab clicks + const handleChange = (event, newValue) => { + if (newValue === "__addtab__") { + // The "plus" tab + onAddTab(); + } else { + // normal tab index + const newTab = tabs[newValue]; + if (newTab) { + onTabChange(newTab.id); + } + } + }; + + return ( + + + {tabs.map((tab, index) => ( + onTabRightClick(evt, tab.id)} + sx={{ + minHeight: "36px", + height: "36px", + textTransform: "none", + backgroundColor: tab.id === activeTabId ? "#2C2C2C" : "transparent", + color: "#58a6ff" + }} + /> + ))} + {/* The "plus" tab has a special value */} + } + value="__addtab__" + sx={{ + minHeight: "36px", + height: "36px", + color: "#58a6ff", + textTransform: "none" + }} + /> + + + ); +} diff --git a/Data/WebUI/src/Node_Sidebar.jsx b/Data/WebUI/src/Node_Sidebar.jsx new file mode 100644 index 0000000..c063dc6 --- /dev/null +++ b/Data/WebUI/src/Node_Sidebar.jsx @@ -0,0 +1,265 @@ +// Node_Sidebar.jsx + +import React from "react"; +import { + Accordion, + AccordionSummary, + AccordionDetails, + Button, + Divider, + Tooltip, + Typography +} from "@mui/material"; +import { + ExpandMore as ExpandMoreIcon, + Save as SaveIcon, + FileOpen as FileOpenIcon, + DeleteForever as DeleteForeverIcon, + DragIndicator as DragIndicatorIcon, + Polyline as PolylineIcon +} from "@mui/icons-material"; + +/** + * Left sidebar for managing workflows and node categories. + * + * Props: + * - categorizedNodes (object of arrays, e.g. { "Category": [{...}, ...], ... }) + * - handleExportFlow() => void + * - handleImportFlow() => void + * - handleOpenCloseAllDialog() => void + * - fileInputRef (ref to hidden file input) + * - onFileInputChange(event) => void + */ +export default function NodeSidebar({ + categorizedNodes, + handleExportFlow, + handleImportFlow, + handleOpenCloseAllDialog, + fileInputRef, + onFileInputChange +}) { + return ( +
+ + } + sx={{ + backgroundColor: "#2c2c2c", + minHeight: "36px", + "& .MuiAccordionSummary-content": { + margin: 0 + } + }} + > + + Workflows + + + + + + + + + + + } + sx={{ + backgroundColor: "#2c2c2c", + minHeight: "36px", + "& .MuiAccordionSummary-content": { + margin: 0 + } + }} + > + + Nodes + + + + {Object.entries(categorizedNodes).map(([category, items]) => ( +
+ + + {category} + + + {items.map((nodeDef) => ( + + {nodeDef.description || "Drag & Drop into Editor"} + + } + placement="right" + arrow + > + + + ))} +
+ ))} +
+
+ + {/* Hidden file input fallback for older browsers */} + +
+ ); +}