483 lines
14 KiB
JavaScript

////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/App.jsx
// Core React Imports
import React, {
useState,
useEffect,
useCallback,
useRef
} from "react";
// Material UI - Components
import {
AppBar,
Toolbar,
Typography,
Box,
Menu,
MenuItem,
Button,
CssBaseline,
ThemeProvider,
createTheme
} from "@mui/material";
// Material UI - Icons
import {
KeyboardArrowDown as KeyboardArrowDownIcon,
InfoOutlined as InfoOutlinedIcon,
MergeType as MergeTypeIcon,
People as PeopleIcon
} from "@mui/icons-material";
// React Flow
import { ReactFlowProvider } from "reactflow";
// Styles
import "reactflow/dist/style.css";
// Import Borealis Modules
import FlowTabs from "./Flow_Tabs";
import FlowEditor from "./Flow_Editor";
import NodeSidebar from "./Node_Sidebar";
import {
CloseAllDialog,
CreditsDialog,
RenameTabDialog,
TabContextMenu
} from "./Dialogs";
import StatusBar from "./Status_Bar";
// Websocket Functionality
import { io } from "socket.io-client";
if (!window.BorealisSocket) {
window.BorealisSocket = io(window.location.origin, {
transports: ["websocket"]
});
}
if (!window.BorealisUpdateRate) {
window.BorealisUpdateRate = 200;
}
const modules = import.meta.glob('./nodes/**/*.jsx', { eager: true });
const nodeTypes = {};
const categorizedNodes = {};
Object.entries(modules).forEach(([path, mod]) => {
const comp = mod.default;
if (!comp) return;
const { type, component } = comp;
if (!type || !component) return;
const parts = path.replace('./nodes/', '').split('/');
const category = parts[0];
if (!categorizedNodes[category]) {
categorizedNodes[category] = [];
}
categorizedNodes[category].push(comp);
nodeTypes[type] = component;
});
const darkTheme = createTheme({
palette: {
mode: "dark",
background: {
default: "#121212",
paper: "#1e1e1e"
},
text: {
primary: "#ffffff"
}
},
components: {
MuiTooltip: {
styleOverrides: {
tooltip: {
backgroundColor: "#2a2a2a",
color: "#ccc",
fontSize: "0.75rem",
border: "1px solid #444"
},
arrow: {
color: "#2a2a2a"
}
}
}
}
});
const LOCAL_STORAGE_KEY = "borealis_persistent_state";
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 [creditsDialogOpen, setCreditsDialogOpen] = useState(false);
const [confirmCloseOpen, setConfirmCloseOpen] = useState(false);
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
const [renameTabId, setRenameTabId] = useState(null);
const [renameValue, setRenameValue] = useState("");
const [tabMenuAnchor, setTabMenuAnchor] = useState(null);
const [tabMenuTabId, setTabMenuTabId] = useState(null);
const fileInputRef = useRef(null);
useEffect(() => {
const saved = localStorage.getItem(LOCAL_STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed.tabs) && parsed.activeTabId) {
setTabs(parsed.tabs);
setActiveTabId(parsed.activeTabId);
}
} catch (err) {
console.warn("Failed to parse saved state:", err);
}
}
}, []);
useEffect(() => {
const timeout = setTimeout(() => {
const data = JSON.stringify({ tabs, activeTabId });
localStorage.setItem(LOCAL_STORAGE_KEY, data);
}, 1000);
return () => clearTimeout(timeout);
}, [tabs, activeTabId]);
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]
);
const handleAboutMenuOpen = (event) => setAboutAnchorEl(event.currentTarget);
const handleAboutMenuClose = () => setAboutAnchorEl(null);
const openCreditsDialog = () => {
handleAboutMenuClose();
setCreditsDialogOpen(true);
};
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);
};
const createNewTab = () => {
const nextIndex = tabs.length + 1;
const newId = "flow_" + nextIndex;
setTabs((old) => [
...old,
{
id: newId,
tab_name: "Flow " + nextIndex,
nodes: [],
edges: []
}
]);
setActiveTabId(newId);
};
const handleTabChange = (newActiveTabId) => {
setActiveTabId(newActiveTabId);
};
const handleTabRightClick = (evt, tabId) => {
evt.preventDefault();
setTabMenuAnchor({ x: evt.clientX, y: evt.clientY });
setTabMenuTabId(tabId);
};
const handleCloseTabMenu = () => {
setTabMenuAnchor(null);
setTabMenuTabId(null);
};
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);
};
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" });
const sanitizedTabName = activeTab.tab_name.replace(/\s+/g, "_").toLowerCase();
const suggestedFilename = sanitizedTabName + "_workflow.json";
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 {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = suggestedFilename;
a.style.display = "none";
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(a.href);
document.body.removeChild(a);
}
};
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);
}
} else {
fileInputRef.current?.click();
}
};
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);
}
};
return (
<ThemeProvider theme={darkTheme}>
<CssBaseline />
<Box sx={{ width: "100vw", height: "100vh", display: "flex", flexDirection: "column", overflow: "hidden" }}>
<AppBar position="static" sx={{ bgcolor: "#16191d" }}>
<Toolbar sx={{ minHeight: "36px" }}>
<Box component="img" src="/Borealis_Logo_Full.png" alt="Borealis Logo" sx={{ height: "52px", marginRight: "8px" }} />
<Typography variant="h6" sx={{ flexGrow: 1, fontSize: "1rem" }}></Typography>
<Button
color="inherit"
onClick={handleAboutMenuOpen}
endIcon={<KeyboardArrowDownIcon />}
startIcon={<InfoOutlinedIcon />}
sx={{ height: "36px" }}
>
About
</Button>
<Menu anchorEl={aboutAnchorEl} open={Boolean(aboutAnchorEl)} onClose={handleAboutMenuClose}>
<MenuItem onClick={() => { handleAboutMenuClose(); window.open("https://git.bunny-lab.io/bunny-lab/Borealis", "_blank"); }}>
<MergeTypeIcon sx={{ fontSize: 18, color: "#58a6ff", mr: 1 }} /> Gitea Project
</MenuItem>
<MenuItem onClick={openCreditsDialog}>
<PeopleIcon sx={{ fontSize: 18, color: "#58a6ff", mr: 1 }} /> Credits
</MenuItem>
</Menu>
</Toolbar>
</AppBar>
<Box sx={{ display: "flex", flexGrow: 1, overflow: "hidden" }}>
<NodeSidebar
categorizedNodes={categorizedNodes}
handleExportFlow={handleExportFlow}
handleImportFlow={handleImportFlow}
handleOpenCloseAllDialog={handleOpenCloseAllDialog}
fileInputRef={fileInputRef}
onFileInputChange={handleFileInputChange}
/>
<Box sx={{ display: "flex", flexDirection: "column", flexGrow: 1, overflow: "hidden" }}>
<FlowTabs
tabs={tabs}
activeTabId={activeTabId}
onTabChange={handleTabChange}
onAddTab={createNewTab}
onTabRightClick={handleTabRightClick}
/>
<Box sx={{ flexGrow: 1, position: "relative" }}>
{tabs.map((tab) => (
<Box
key={tab.id}
sx={{
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
display: tab.id === activeTabId ? "block" : "none"
}}
>
<ReactFlowProvider id={tab.id}>
<FlowEditor
flowId={tab.id}
nodes={tab.nodes}
edges={tab.edges}
setNodes={(val) => handleSetNodes(val, tab.id)}
setEdges={(val) => handleSetEdges(val, tab.id)}
nodeTypes={nodeTypes}
categorizedNodes={categorizedNodes}
/>
</ReactFlowProvider>
</Box>
))}
</Box>
</Box>
</Box>
<StatusBar />
</Box>
<CloseAllDialog
open={confirmCloseOpen}
onClose={handleCloseDialog}
onConfirm={handleConfirmCloseAll}
/>
<CreditsDialog open={creditsDialogOpen} onClose={() => setCreditsDialogOpen(false)} />
<RenameTabDialog
open={renameDialogOpen}
value={renameValue}
onChange={setRenameValue}
onCancel={() => setRenameDialogOpen(false)}
onSave={handleRenameDialogSave}
/>
<TabContextMenu
anchor={tabMenuAnchor}
onClose={handleCloseTabMenu}
onRename={handleRenameTab}
onCloseTab={handleCloseTab}
/>
</ThemeProvider>
);
}