diff --git a/Data/WebUI/src/App.jsx b/Data/WebUI/src/App.jsx index 6209cf7..dc707b4 100644 --- a/Data/WebUI/src/App.jsx +++ b/Data/WebUI/src/App.jsx @@ -1,5 +1,10 @@ // Core React Imports -import React, { useState, useEffect, useCallback, useRef } from "react"; +import React, { + useState, + useEffect, + useCallback, + useRef +} from "react"; // Material UI - Components import { @@ -22,7 +27,10 @@ import { DialogContentText, DialogActions, Divider, - Tooltip + Tooltip, + Tabs, + Tab, + TextField } from "@mui/material"; // Material UI - Icons @@ -36,7 +44,8 @@ import { InfoOutlined as InfoOutlinedIcon, Polyline as PolylineIcon, MergeType as MergeTypeIcon, - People as PeopleIcon + People as PeopleIcon, + Add as AddIcon } from "@mui/icons-material"; // React Flow @@ -55,10 +64,11 @@ import "./Borealis.css"; // Global Node Update Timer Variable if (!window.BorealisUpdateRate) { - window.BorealisUpdateRate = 200; // Default Update Rate: 100ms + window.BorealisUpdateRate = 200; } -const nodeContext = require.context("./nodes", true, /\.jsx$/); // Dynamically import all node components from the nodes directory +// Dynamically load all node components +const nodeContext = require.context("./nodes", true, /\.jsx$/); const nodeTypes = {}; const categorizedNodes = {}; @@ -75,14 +85,15 @@ nodeContext.keys().forEach((path) => { if (!categorizedNodes[category]) { categorizedNodes[category] = []; } - categorizedNodes[category].push(mod.default); // includes type, label, component, defaultContent + categorizedNodes[category].push(mod.default); nodeTypes[type] = component; }); +// Single flow editor function FlowEditor({ nodes, edges, setNodes, setEdges, nodeTypes }) { - const reactFlowWrapper = useRef(null); + const wrapperRef = useRef(null); const { project } = useReactFlow(); - const [contextMenu, setContextMenu] = useState(null); // Node Right-Click Context Menu + const [contextMenu, setContextMenu] = useState(null); const onDrop = useCallback( (event) => { @@ -90,22 +101,22 @@ function FlowEditor({ nodes, edges, setNodes, setEdges, nodeTypes }) { const type = event.dataTransfer.getData("application/reactflow"); if (!type) return; - const bounds = reactFlowWrapper.current.getBoundingClientRect(); + const bounds = wrapperRef.current.getBoundingClientRect(); const position = project({ x: event.clientX - bounds.left, y: event.clientY - bounds.top }); - const id = `node-${Date.now()}`; + const id = "node-" + Date.now(); const nodeMeta = Object.values(categorizedNodes) .flat() .find((n) => n.type === type); const newNode = { - id, - type, - position, + id: id, + type: type, + position: position, data: { label: nodeMeta?.label || type, content: nodeMeta?.content @@ -151,11 +162,11 @@ function FlowEditor({ nodes, edges, setNodes, setEdges, nodeTypes }) { [setEdges] ); - const handleRightClick = (event, node) => { - event.preventDefault(); + const handleRightClick = (e, node) => { + e.preventDefault(); setContextMenu({ - mouseX: event.clientX + 2, - mouseY: event.clientY - 6, + mouseX: e.clientX + 2, + mouseY: e.clientY - 6, nodeId: node.id }); }; @@ -163,7 +174,11 @@ function FlowEditor({ nodes, edges, setNodes, setEdges, nodeTypes }) { const handleDisconnect = () => { if (contextMenu?.nodeId) { setEdges((eds) => - eds.filter((e) => e.source !== contextMenu.nodeId && e.target !== contextMenu.nodeId) + eds.filter( + (e) => + e.source !== contextMenu.nodeId && + e.target !== contextMenu.nodeId + ) ); } setContextMenu(null); @@ -171,9 +186,15 @@ function FlowEditor({ nodes, edges, setNodes, setEdges, nodeTypes }) { const handleRemoveNode = () => { if (contextMenu?.nodeId) { - setNodes((nds) => nds.filter((n) => n.id !== contextMenu.nodeId)); + setNodes((nds) => + nds.filter((n) => n.id !== contextMenu.nodeId) + ); setEdges((eds) => - eds.filter((e) => e.source !== contextMenu.nodeId && e.target !== contextMenu.nodeId) + eds.filter( + (e) => + e.source !== contextMenu.nodeId && + e.target !== contextMenu.nodeId + ) ); } setContextMenu(null); @@ -187,9 +208,8 @@ function FlowEditor({ nodes, edges, setNodes, setEdges, nodeTypes }) { }, [nodes]); return ( -
+
- {/* Right-Click Node Menu */} + {/* Right-click node menu */} setContextMenu(null)} anchorReference="anchorPosition" anchorPosition={ - contextMenu !== null + contextMenu ? { top: contextMenu.mouseY, left: contextMenu.mouseX } : undefined } @@ -244,12 +265,10 @@ function FlowEditor({ nodes, edges, setNodes, setEdges, nodeTypes }) { Remove Node -
); } - const darkTheme = createTheme({ palette: { mode: "dark", @@ -264,41 +283,188 @@ const darkTheme = createTheme({ }); export default function App() { + const [tabs, setTabs] = useState([ + { + id: "flow_1", + tab_name: "Flow 1", + nodes: [], + edges: [] + } + ]); + const [activeTabId, setActiveTabId] = useState("flow_1"); + const [aboutAnchorEl, setAboutAnchorEl] = useState(null); - const [nodes, setNodes] = useState([]); - const [edges, setEdges] = useState([]); 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); - const handleOpenCloseDialog = () => setConfirmCloseOpen(true); + + // Close all flows + const handleOpenCloseAllDialog = () => setConfirmCloseOpen(true); const handleCloseDialog = () => setConfirmCloseOpen(false); - const handleConfirmCloseWorkflow = () => { - setNodes([]); - setEdges([]); + const handleConfirmCloseAll = () => { + setTabs([ + { + id: "flow_1", + tab_name: "Flow 1", + nodes: [], + edges: [] + } + ]); + setActiveTabId("flow_1"); setConfirmCloseOpen(false); }; - const handleSaveWorkflow = async () => { - const data = JSON.stringify({ nodes, edges }, null, 2); + // 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(); + }; + const handleRenameDialogSave = () => { + 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"] } - }] + types: [ + { + description: "Workflow JSON File", + accept: { "application/json": [".json"] } + } + ] }); const writable = await fileHandle.createWritable(); await writable.write(blob); await writable.close(); - } catch (error) { - console.error("Save cancelled or failed:", error); + } catch (err) { + console.error("Save cancelled or failed:", err); } } else { const a = document.createElement("a"); @@ -312,174 +478,537 @@ export default function App() { } }; - const handleOpenWorkflow = async () => { + // 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"] } - }] + types: [ + { + description: "Workflow JSON File", + accept: { "application/json": [".json"] } + } + ] }); const file = await fileHandle.getFile(); const text = await file.text(); - const { nodes: loadedNodes, edges: loadedEdges } = JSON.parse(text); - const confirm = window.confirm("Opening a workflow will overwrite your current one. Continue?"); - if (!confirm) return; - setNodes(loadedNodes); - setEdges(loadedEdges); - } catch (error) { - console.error("Open cancelled or failed:", error); + 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); } } else { 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); + } + }; + + /** + * 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 ( - - - - Borealis - Workflow Automation Tool - - - + + + + Borealis - Workflow Automation Tool + + + + { + handleAboutMenuClose(); + window.open( + "https://git.bunny-lab.io/Borealis", + "_blank" + ); + }} + > + + Gitea Project + + { + handleAboutMenuClose(); + setCreditsDialogOpen(true); + }} + > + + Credits + + + + + + + {/* Sidebar */} + - { - handleAboutMenuClose(); - window.open("https://git.bunny-lab.io/Borealis", "_blank"); + - - Gitea Project - - { - handleAboutMenuClose(); - setCreditsDialogOpen(true); - }} - > - - Credits - - - - - - - - - - } sx={accordionHeaderStyle}> - Workflows + } + sx={{ + backgroundColor: "#2c2c2c", + minHeight: "36px", + "& .MuiAccordionSummary-content": { + margin: 0 + } + }} + > + + Workflows + - - - - - } sx={accordionHeaderStyle}> - Nodes + + } + sx={{ + backgroundColor: "#2c2c2c", + minHeight: "36px", + "& .MuiAccordionSummary-content": { + margin: 0 + } + }} + > + + Nodes + - {Object.entries(categorizedNodes).map(([category, items]) => ( - - ( + - - {category} - - - {items.map(({ type, label, description }) => ( - - {description || "Drag & Drop into Editor"} - - } - placement="right" - arrow - > - - - ))} - - ))} + {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): { - const val = parseInt(document.getElementById("updateRateInput")?.value); + const val = parseInt( + document.getElementById("updateRateInput") + ?.value + ); if (!isNaN(val) && val >= 50) { window.BorealisUpdateRate = val; console.log("Global update rate set to", val + "ms"); } else { - alert("Please enter a valid number (minimum 50)"); + alert("Please enter a valid number (min 50)."); } }} - sx={{ color: "#58a6ff", borderColor: "#58a6ff", fontSize: "0.75rem", textTransform: "none", px: 1.5 }} + sx={{ + color: "#58a6ff", + borderColor: "#58a6ff", + fontSize: "0.75rem", + textTransform: "none", + px: 1.5 + }} > Apply Rate - - Close Workflow? + {/* Close All Dialog */} + + Close All Flow Tabs? - Are you sure you want to reset the workflow? All nodes will be removed. + This will remove all existing flow tabs and + create a fresh tab named Flow 1. - - + + + {/* Credits */} setCreditsDialogOpen(false)} @@ -541,50 +1096,93 @@ export default function App() { - + {/* 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 */} { - const file = e.target.files[0]; - if (!file) return; - try { - const text = await file.text(); - const { nodes: loadedNodes, edges: loadedEdges } = JSON.parse(text); - const confirm = window.confirm("Opening a workflow will overwrite your current one. Continue?"); - if (!confirm) return; - setNodes(loadedNodes); - setEdges(loadedEdges); - } catch (err) { - console.error("Failed to read file:", err); - } - }} + onChange={handleFileInputChange} /> ); } - -const sidebarBtnStyle = { - color: "#ccc", - backgroundColor: "#232323", - justifyContent: "flex-start", - pl: 2, - fontSize: "0.9rem", - textTransform: "none", - "&:hover": { - backgroundColor: "#2a2a2a" - } -}; - -const accordionHeaderStyle = { - backgroundColor: "#2c2c2c", - minHeight: "36px", - "& .MuiAccordionSummary-content": { margin: 0 } -}; diff --git a/Data/WebUI/src/Borealis.css b/Data/WebUI/src/Borealis.css index b1bec8a..45b4a8a 100644 --- a/Data/WebUI/src/Borealis.css +++ b/Data/WebUI/src/Borealis.css @@ -5,7 +5,6 @@ height: 100vh; } - /* Blue Gradient Overlay */ .flow-editor-container::before { content: ""; @@ -14,16 +13,17 @@ left: 0; width: 100%; height: 100%; - pointer-events: none; /* Ensures grid and nodes remain fully interactive */ - background: linear-gradient( to bottom, rgba(9, 44, 68, 0.9) 0%, /* Deep blue at the top */ - rgba(30, 30, 30, 0) 45%, /* Fade out towards center */ - rgba(30, 30, 30, 0) 75%, /* No gradient in the middle */ - rgba(9, 44, 68, 0.7) 100% /* Deep blue at the bottom */ + pointer-events: none; + background: linear-gradient( + to bottom, + rgba(9, 44, 68, 0.9) 0%, + rgba(30, 30, 30, 0) 45%, + rgba(30, 30, 30, 0) 75%, + rgba(9, 44, 68, 0.7) 100% ); - z-index: -1; /* Ensures it stays behind the React Flow elements */ + z-index: -1; } - /* Emphasize Drag & Drop Node Functionality */ .sidebar-button:hover { background-color: #2a2a2a !important; @@ -31,7 +31,6 @@ cursor: grab; } - /* Borealis Node Styling */ .borealis-node { background: #2c2c2c; @@ -42,7 +41,8 @@ min-width: 160px; max-width: 260px; position: relative; - box-shadow: 0 0 5px rgba(88, 166, 255, 0.15), 0 0 10px rgba(88, 166, 255, 0.15); + box-shadow: 0 0 5px rgba(88, 166, 255, 0.15), + 0 0 10px rgba(88, 166, 255, 0.15); transition: box-shadow 0.3s ease-in-out; } .borealis-node-header { @@ -65,7 +65,9 @@ } /* Global dark form inputs */ -input, select, button { +input, +select, +button { background-color: #2a2a2a; color: #ccc; border: 1px solid #444; @@ -77,3 +79,17 @@ label { color: #aaa; font-size: 10px; } + +/* Multi-Tab Bar Adjustments */ +.MuiTabs-root { + min-height: 32px !important; +} + +.MuiTab-root { + min-height: 32px !important; + padding: 6px 12px !important; + color: #58a6ff !important; + text-transform: none !important; +} + +/* We rely on the TabIndicatorProps to show the underline highlight for active tabs. */