mirror of
https://github.com/vndangkhoa/Sys-Arc-Visl.git
synced 2026-04-05 01:17:57 +07:00
feat: enhance layout engine, edge styles, and editor header
This commit is contained in:
parent
6c0adaae2c
commit
b5b2261efb
42 changed files with 2754 additions and 871 deletions
|
|
@ -1,8 +1,13 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<meta name="theme-color" content="#0B1221" />
|
||||||
|
<meta name="description" content="AI-Powered System Design Visualizer - Transform ideas into beautiful, interactive flowcharts" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||||
<title>SysVis.AI - System Design Visualizer</title>
|
<title>SysVis.AI - System Design Visualizer</title>
|
||||||
<!-- Fonts -->
|
<!-- Fonts -->
|
||||||
<link
|
<link
|
||||||
|
|
|
||||||
346
package-lock.json
generated
346
package-lock.json
generated
|
|
@ -13,15 +13,21 @@
|
||||||
"@mlc-ai/web-llm": "^0.2.80",
|
"@mlc-ai/web-llm": "^0.2.80",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@types/dagre": "^0.7.53",
|
"@types/dagre": "^0.7.53",
|
||||||
|
"@types/randomcolor": "^0.5.9",
|
||||||
"@xyflow/react": "^12.10.0",
|
"@xyflow/react": "^12.10.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"mermaid": "^11.12.2",
|
"mermaid": "^11.12.2",
|
||||||
|
"randomcolor": "^0.6.2",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router-dom": "^7.11.0",
|
"react-router-dom": "^7.11.0",
|
||||||
|
"y-indexeddb": "^9.0.12",
|
||||||
|
"y-webrtc": "^10.3.0",
|
||||||
|
"yjs": "^13.6.28",
|
||||||
|
"zundo": "^2.3.0",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -3156,6 +3162,12 @@
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/randomcolor": {
|
||||||
|
"version": "0.5.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/randomcolor/-/randomcolor-0.5.9.tgz",
|
||||||
|
"integrity": "sha512-k58cfpkK15AKn1m+oRd9nh5BnuiowhbyvBBdAzcddtARMr3xRzP0VlFaAKovSG6N6Knx08EicjPlOMzDejerrQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.7",
|
"version": "19.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||||
|
|
@ -3781,6 +3793,26 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/base64-js": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.9.11",
|
"version": "2.9.11",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
|
||||||
|
|
@ -3867,6 +3899,30 @@
|
||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.1",
|
||||||
|
"ieee754": "^1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/callsites": {
|
"node_modules/callsites": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||||
|
|
@ -4661,7 +4717,6 @@
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
|
|
@ -4807,6 +4862,12 @@
|
||||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/err-code": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/err-code/-/err-code-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-GiaH0KJUewYok+eeY05IIgjtAe4Yltygk9Wqp1V5yVWLdhf0hYZchRjNIT9bb0mSwRcIusT3cx7PJUf3zEIfUA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/es-define-property": {
|
"node_modules/es-define-property": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
|
|
@ -5259,6 +5320,12 @@
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-browser-rtc": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-browser-rtc/-/get-browser-rtc-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-MghbMJ61EJrRsDe7w1Bvqt3ZsBuqhce5nrn/XAwgwOXhcsz53/ltdxOse1h/8eKXj5slzxdsz56g5rzOFSGwfQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/glob-parent": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
|
|
@ -5468,6 +5535,26 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ieee754": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
|
|
@ -5515,6 +5602,12 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/internmap": {
|
"node_modules/internmap": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
|
@ -5571,6 +5664,16 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/isomorphic.js": {
|
||||||
|
"version": "0.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
|
||||||
|
"integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jest-diff": {
|
"node_modules/jest-diff": {
|
||||||
"version": "30.2.0",
|
"version": "30.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz",
|
||||||
|
|
@ -5972,6 +6075,27 @@
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lib0": {
|
||||||
|
"version": "0.2.116",
|
||||||
|
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.116.tgz",
|
||||||
|
"integrity": "sha512-4zsosjzmt33rx5XjmFVYUAeLNh+BTeDTiwGdLt4muxiir2btsc60Nal0EvkvDRizg+pnlK1q+BtYi7M+d4eStw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"isomorphic.js": "^0.2.4"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
|
||||||
|
"0gentesthtml": "bin/gentesthtml.js",
|
||||||
|
"0serve": "bin/0serve.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.30.2",
|
"version": "1.30.2",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||||
|
|
@ -6495,7 +6619,6 @@
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
|
|
@ -6966,6 +7089,41 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/queue-microtask": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/randombytes": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/randomcolor": {
|
||||||
|
"version": "0.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/randomcolor/-/randomcolor-0.6.2.tgz",
|
||||||
|
"integrity": "sha512-Mn6TbyYpFgwFuQ8KJKqf3bqqY9O1y37/0jgSK/61PUxV4QfIMv0+K2ioq8DfOjkBslcjwSzRfIDEXfzA9aCx7A==",
|
||||||
|
"license": "CC0"
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.2.3",
|
"version": "19.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||||
|
|
@ -7044,6 +7202,20 @@
|
||||||
"react-dom": ">=18"
|
"react-dom": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/readable-stream": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/redent": {
|
"node_modules/redent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||||
|
|
@ -7161,6 +7333,26 @@
|
||||||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/safer-buffer": {
|
"node_modules/safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
|
@ -7309,6 +7501,35 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/simple-peer": {
|
||||||
|
"version": "9.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/simple-peer/-/simple-peer-9.11.1.tgz",
|
||||||
|
"integrity": "sha512-D1SaWpOW8afq1CZGWB8xTfrT3FekjQmPValrqncJMX7QFl8YwhrPTZvMCANLtgBwwdS+7zURyqxDDEmY558tTw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer": "^6.0.3",
|
||||||
|
"debug": "^4.3.2",
|
||||||
|
"err-code": "^3.0.1",
|
||||||
|
"get-browser-rtc": "^1.1.0",
|
||||||
|
"queue-microtask": "^1.2.3",
|
||||||
|
"randombytes": "^2.1.0",
|
||||||
|
"readable-stream": "^3.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/slash": {
|
"node_modules/slash": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
|
||||||
|
|
@ -7378,6 +7599,15 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/strip-indent": {
|
"node_modules/strip-indent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
||||||
|
|
@ -7733,6 +7963,12 @@
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/util-deprecate": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "11.1.0",
|
"version": "11.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||||
|
|
@ -8043,7 +8279,7 @@
|
||||||
"version": "8.18.3",
|
"version": "8.18.3",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
|
|
@ -8078,6 +8314,73 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/y-indexeddb": {
|
||||||
|
"version": "9.0.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/y-indexeddb/-/y-indexeddb-9.0.12.tgz",
|
||||||
|
"integrity": "sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lib0": "^0.2.74"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0",
|
||||||
|
"npm": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"yjs": "^13.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/y-protocols": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lib0": "^0.2.85"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0",
|
||||||
|
"npm": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"yjs": "^13.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/y-webrtc": {
|
||||||
|
"version": "10.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/y-webrtc/-/y-webrtc-10.3.0.tgz",
|
||||||
|
"integrity": "sha512-KalJr7dCgUgyVFxoG3CQYbpS0O2qybegD0vI4bYnYHI0MOwoVbucED3RZ5f2o1a5HZb1qEssUKS0H/Upc6p1lA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lib0": "^0.2.42",
|
||||||
|
"simple-peer": "^9.11.0",
|
||||||
|
"y-protocols": "^1.0.6"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"y-webrtc-signaling": "bin/server.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"ws": "^8.14.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"yjs": "^13.6.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|
@ -8085,6 +8388,24 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/yjs": {
|
||||||
|
"version": "13.6.28",
|
||||||
|
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.28.tgz",
|
||||||
|
"integrity": "sha512-EgnDOXs8+hBVm6mq3/S89Kiwzh5JRbn7w2wXwbrMRyKy/8dOFsLvuIfC+x19ZdtaDc0tA9rQmdZzbqqNHG44wA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"lib0": "^0.2.99"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0",
|
||||||
|
"npm": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|
@ -8122,11 +8443,30 @@
|
||||||
"zod": "^3.25.0 || ^4.0.0"
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/zundo": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/zundo/-/zundo-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-4GXYxXA17SIKYhVbWHdSEU04P697IMyVGXrC2TnzoyohEAWytFNOKqOp5gTGvaW93F/PM5Y0evbGtOPF0PWQwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/charkour"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zustand": "^4.3.0 || ^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"zustand": {
|
||||||
|
"optional": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/zustand": {
|
"node_modules/zustand": {
|
||||||
"version": "5.0.9",
|
"version": "5.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz",
|
||||||
"integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==",
|
"integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.20.0"
|
"node": ">=12.20.0"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -31,15 +31,21 @@
|
||||||
"@mlc-ai/web-llm": "^0.2.80",
|
"@mlc-ai/web-llm": "^0.2.80",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@types/dagre": "^0.7.53",
|
"@types/dagre": "^0.7.53",
|
||||||
|
"@types/randomcolor": "^0.5.9",
|
||||||
"@xyflow/react": "^12.10.0",
|
"@xyflow/react": "^12.10.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"mermaid": "^11.12.2",
|
"mermaid": "^11.12.2",
|
||||||
|
"randomcolor": "^0.6.2",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router-dom": "^7.11.0",
|
"react-router-dom": "^7.11.0",
|
||||||
|
"y-indexeddb": "^9.0.12",
|
||||||
|
"y-webrtc": "^10.3.0",
|
||||||
|
"yjs": "^13.6.28",
|
||||||
|
"zundo": "^2.3.0",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
25
public/manifest.json
Normal file
25
public/manifest.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"name": "SysVis.AI - System Design Visualizer",
|
||||||
|
"short_name": "SysVis.AI",
|
||||||
|
"description": "AI-Powered System Design Visualizer - Transform ideas into beautiful, interactive flowcharts",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0B1221",
|
||||||
|
"theme_color": "#0B1221",
|
||||||
|
"orientation": "any",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/vite.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"categories": [
|
||||||
|
"productivity",
|
||||||
|
"developer tools",
|
||||||
|
"design"
|
||||||
|
],
|
||||||
|
"scope": "/",
|
||||||
|
"lang": "en"
|
||||||
|
}
|
||||||
11
src/App.tsx
11
src/App.tsx
|
|
@ -3,7 +3,18 @@ import { Dashboard } from './pages/Dashboard';
|
||||||
import { Editor } from './pages/Editor';
|
import { Editor } from './pages/Editor';
|
||||||
import { History } from './pages/History';
|
import { History } from './pages/History';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { usePluginStore } from './store/pluginStore';
|
||||||
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const { registerPlugin } = usePluginStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Register Plugins
|
||||||
|
// StatsPlugin removed
|
||||||
|
}, [registerPlugin]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,54 @@
|
||||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import Editor from '@monaco-editor/react';
|
import { useStore } from 'zustand';
|
||||||
import { useFlowStore } from '../store';
|
import { useFlowStore } from '../store';
|
||||||
|
import { useDiagramStore } from '../store/diagramStore';
|
||||||
import { parseMermaid, detectInputType } from '../lib/mermaidParser';
|
import { parseMermaid, detectInputType } from '../lib/mermaidParser';
|
||||||
import { getLayoutedElements } from '../lib/layoutEngine';
|
import { getLayoutedElements } from '../lib/layoutEngine';
|
||||||
import { interpretText, suggestFix } from '../lib/aiService';
|
import { interpretText } from '../lib/aiService';
|
||||||
import {
|
import { MonacoWrapper } from './editor/MonacoWrapper';
|
||||||
Loader2, Zap, Trash2, FileText, Lightbulb,
|
import { EditorToolbar } from './editor/EditorToolbar';
|
||||||
AlertCircle
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
const SAMPLE_MERMAID = `flowchart TD
|
|
||||||
subgraph AI [AI Director]
|
|
||||||
A1[Analyze Trends]
|
|
||||||
A2[Generate Script]
|
|
||||||
A3[Create Draft]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Team [Intern Team]
|
|
||||||
B1[Fine-tune Ideas]
|
|
||||||
B2[Edit Content]
|
|
||||||
B3[Review & Approve]
|
|
||||||
end
|
|
||||||
|
|
||||||
A1 --> A2 --> A3
|
|
||||||
A3 --> B1
|
|
||||||
B1 --> B2 --> B3`;
|
|
||||||
|
|
||||||
export function CodeEditor() {
|
export function CodeEditor() {
|
||||||
const [code, setCode] = useState<string>('');
|
// ... (store access)
|
||||||
|
// Use global mermaidCode from store for persistence across panel toggles
|
||||||
|
const {
|
||||||
|
setNodes, setEdges, setLoading, setError, setSourceCode, isLoading,
|
||||||
|
ollamaUrl, modelName, aiMode, onlineProvider, apiKey, theme,
|
||||||
|
mermaidCode: code, setMermaidCode: setCode
|
||||||
|
} = useFlowStore();
|
||||||
|
|
||||||
const [inputType, setInputType] = useState<'mermaid' | 'natural'>('mermaid');
|
const [inputType, setInputType] = useState<'mermaid' | 'natural'>('mermaid');
|
||||||
const [syntaxErrors, setSyntaxErrors] = useState<{ line: number; message: string }[]>([]);
|
const [syntaxErrors, setSyntaxErrors] = useState<{ line: number; message: string }[]>([]);
|
||||||
const [highlightedLine, setHighlightedLine] = useState<number | null>(null);
|
|
||||||
|
|
||||||
|
|
||||||
|
// ... (rest of logic up to handleTemplateSelect)
|
||||||
|
|
||||||
|
// We need to inject the ShapePicker logic.
|
||||||
|
// Since I cannot match the exact lines perfectly without context, I will replace the component return and adding imports/states.
|
||||||
|
// But replace_file_content with 'context' is better. I will try to target specific blocks.
|
||||||
|
|
||||||
|
// I will use replace_file_content to add imports first.
|
||||||
|
// Then add state.
|
||||||
|
// Then add handleShapeSelect.
|
||||||
|
// Then update return.
|
||||||
|
|
||||||
|
// Wait, I am doing all of this in one tool call? Use separate blocks if possible?
|
||||||
|
// The previous tool calls are queued. This is the 5th tool call in this turn? No, 5th.
|
||||||
|
|
||||||
|
// I will replace the imports first in this call.
|
||||||
|
// Actually, I'll do a MultiReplace for CodeEditor.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Use any for editor refs since OnMount type isn't reliably exported
|
// Use any for editor refs since OnMount type isn't reliably exported
|
||||||
|
// We'll keep these refs here to handle the node selection highlighting logic
|
||||||
const editorRef = useRef<any>(null);
|
const editorRef = useRef<any>(null);
|
||||||
const monacoRef = useRef<any>(null);
|
const monacoRef = useRef<any>(null);
|
||||||
const decorationsRef = useRef<string[]>([]);
|
const decorationsRef = useRef<string[]>([]);
|
||||||
|
|
||||||
const {
|
|
||||||
setNodes, setEdges, setLoading, setError, setSourceCode, isLoading,
|
|
||||||
ollamaUrl, modelName, aiMode, onlineProvider, apiKey, nodes, setSelectedNode, theme
|
|
||||||
} = useFlowStore();
|
|
||||||
|
|
||||||
// Listen for node click events from the canvas (bidirectional highlighting)
|
// Listen for node click events from the canvas (bidirectional highlighting)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleNodeSelected = (event: CustomEvent<{ nodeId: string; label: string }>) => {
|
const handleNodeSelected = (event: CustomEvent<{ nodeId: string; label: string }>) => {
|
||||||
|
|
@ -59,8 +66,6 @@ export function CodeEditor() {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (lineIndex !== -1) {
|
if (lineIndex !== -1) {
|
||||||
setHighlightedLine(lineIndex + 1);
|
|
||||||
|
|
||||||
// Scroll to and highlight the line
|
// Scroll to and highlight the line
|
||||||
editorRef.current.revealLineInCenter(lineIndex + 1);
|
editorRef.current.revealLineInCenter(lineIndex + 1);
|
||||||
|
|
||||||
|
|
@ -82,7 +87,6 @@ export function CodeEditor() {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
decorationsRef.current = editorRef.current.deltaDecorations(decorationsRef.current, []);
|
decorationsRef.current = editorRef.current.deltaDecorations(decorationsRef.current, []);
|
||||||
setHighlightedLine(null);
|
|
||||||
}
|
}
|
||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
@ -97,7 +101,7 @@ export function CodeEditor() {
|
||||||
const newCode = value || '';
|
const newCode = value || '';
|
||||||
setCode(newCode);
|
setCode(newCode);
|
||||||
if (newCode.trim()) setInputType(detectInputType(newCode));
|
if (newCode.trim()) setInputType(detectInputType(newCode));
|
||||||
}, [inputType]);
|
}, [setCode]);
|
||||||
|
|
||||||
const handleGenerate = useCallback(async () => {
|
const handleGenerate = useCallback(async () => {
|
||||||
if (!code.trim()) return;
|
if (!code.trim()) return;
|
||||||
|
|
@ -114,21 +118,51 @@ export function CodeEditor() {
|
||||||
const result = await interpretText(code, ollamaUrl, modelName, aiMode, onlineProvider, apiKey);
|
const result = await interpretText(code, ollamaUrl, modelName, aiMode, onlineProvider, apiKey);
|
||||||
if (!result.success || !result.mermaidCode) throw new Error(result.error || 'Interpretation failed');
|
if (!result.success || !result.mermaidCode) throw new Error(result.error || 'Interpretation failed');
|
||||||
mermaidCode = result.mermaidCode;
|
mermaidCode = result.mermaidCode;
|
||||||
|
metadata = result.metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSourceCode(mermaidCode);
|
setSourceCode(mermaidCode);
|
||||||
const { nodes: parsedNodes, edges: parsedEdges } = await parseMermaid(mermaidCode);
|
|
||||||
|
|
||||||
if (metadata) {
|
// First attempt to parse
|
||||||
parsedNodes.forEach(node => {
|
try {
|
||||||
const label = (node.data.label as string) || '';
|
const { nodes: parsedNodes, edges: parsedEdges } = await parseMermaid(mermaidCode);
|
||||||
if (label && metadata && metadata[label]) node.data.metadata = metadata[label];
|
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(parsedNodes, parsedEdges);
|
||||||
});
|
setNodes(layoutedNodes);
|
||||||
|
setEdges(layoutedEdges);
|
||||||
|
|
||||||
|
if (metadata) {
|
||||||
|
parsedNodes.forEach(node => {
|
||||||
|
const label = (node.data.label as string) || '';
|
||||||
|
if (label && metadata && metadata[label]) node.data.metadata = metadata[label];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (initialError) {
|
||||||
|
// If parsing fails, try to Auto-Fix if we have an API key
|
||||||
|
const errorMessage = initialError instanceof Error ? initialError.message : 'Error processing code';
|
||||||
|
|
||||||
|
if (onlineProvider === 'gemini' && apiKey) {
|
||||||
|
console.log('Parsing failed, attempting Auto-Fix...');
|
||||||
|
try {
|
||||||
|
const { fixDiagram } = await import('../lib/aiService');
|
||||||
|
const fixedCode = await fixDiagram(mermaidCode, apiKey, errorMessage);
|
||||||
|
|
||||||
|
if (fixedCode && fixedCode !== mermaidCode) {
|
||||||
|
setCode(fixedCode);
|
||||||
|
// Retry parsing with fixed code
|
||||||
|
const { nodes: newNodes, edges: newEdges } = await parseMermaid(fixedCode);
|
||||||
|
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(newNodes, newEdges);
|
||||||
|
setNodes(layoutedNodes);
|
||||||
|
setEdges(layoutedEdges);
|
||||||
|
return; // Success!
|
||||||
|
}
|
||||||
|
} catch (fixError) {
|
||||||
|
console.warn('Auto-Fix failed:', fixError);
|
||||||
|
// Fall through to throw original error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw initialError;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(parsedNodes, parsedEdges);
|
|
||||||
setNodes(layoutedNodes);
|
|
||||||
setEdges(layoutedEdges);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Error processing code';
|
const errorMessage = error instanceof Error ? error.message : 'Error processing code';
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
|
|
@ -141,59 +175,19 @@ export function CodeEditor() {
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [code, inputType, ollamaUrl, modelName, aiMode, onlineProvider, apiKey, setNodes, setEdges, setLoading, setError, setSourceCode]);
|
}, [code, inputType, ollamaUrl, modelName, aiMode, onlineProvider, apiKey, setNodes, setEdges, setLoading, setError, setSourceCode, setCode]);
|
||||||
|
|
||||||
const [suggestion, setSuggestion] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleSuggest = useCallback(async () => {
|
|
||||||
if (!code.trim()) return;
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
setSuggestion(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await suggestFix(code, ollamaUrl, modelName, aiMode, onlineProvider, apiKey);
|
|
||||||
if (result.success && result.mermaidCode) {
|
|
||||||
setCode(result.mermaidCode);
|
|
||||||
setSuggestion(result.explanation || 'Code improved');
|
|
||||||
setTimeout(() => setSuggestion(null), 5000);
|
|
||||||
} else {
|
|
||||||
throw new Error(result.error || 'Could not get suggestion');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Suggestion failed');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [code, ollamaUrl, modelName, aiMode, onlineProvider, apiKey, setLoading, setError]);
|
|
||||||
|
|
||||||
// Click on node ID in code to highlight on canvas
|
|
||||||
const handleEditorClick = useCallback(() => {
|
|
||||||
if (!editorRef.current) return;
|
|
||||||
|
|
||||||
const position = editorRef.current.getPosition();
|
// Handle Editor mount
|
||||||
if (!position) return;
|
const handleEditorMount = useCallback((editor: any, monaco: any) => {
|
||||||
|
editorRef.current = editor;
|
||||||
|
monacoRef.current = monaco;
|
||||||
|
|
||||||
const line = editorRef.current.getModel()?.getLineContent(position.lineNumber);
|
// Define themes
|
||||||
if (!line) return;
|
monaco.editor.defineTheme('architect-dark', {
|
||||||
|
|
||||||
// Extract node ID from line (e.g., "A1[Label]" -> find node with label "Label")
|
|
||||||
const labelMatch = line.match(/\[([^\]]+)\]/) || line.match(/\(([^)]+)\)/) || line.match(/\{([^}]+)\}/);
|
|
||||||
if (labelMatch) {
|
|
||||||
const label = labelMatch[1];
|
|
||||||
const matchingNode = nodes.find(n => (n.data.label as string)?.includes(label));
|
|
||||||
if (matchingNode) {
|
|
||||||
setSelectedNode(matchingNode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [nodes, setSelectedNode]);
|
|
||||||
|
|
||||||
// Define themes once on mount, but update selection on theme change
|
|
||||||
useEffect(() => {
|
|
||||||
if (!monacoRef.current) return;
|
|
||||||
|
|
||||||
// Define Dark Theme
|
|
||||||
monacoRef.current.editor.defineTheme('architect-dark', {
|
|
||||||
base: 'vs-dark',
|
base: 'vs-dark',
|
||||||
inherit: true,
|
inherit: true,
|
||||||
rules: [
|
rules: [
|
||||||
|
|
@ -211,162 +205,66 @@ export function CodeEditor() {
|
||||||
'editor.lineHighlightBackground': '#1e293b',
|
'editor.lineHighlightBackground': '#1e293b',
|
||||||
'editor.selectionBackground': '#334155',
|
'editor.selectionBackground': '#334155',
|
||||||
'editorCursor.foreground': '#60a5fa',
|
'editorCursor.foreground': '#60a5fa',
|
||||||
|
'editor.lineHighlightBorder': '#00000000', // No border for line highlight
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Define Light Theme
|
monaco.editor.defineTheme('architect-light', {
|
||||||
monacoRef.current.editor.defineTheme('architect-light', {
|
|
||||||
base: 'vs',
|
base: 'vs',
|
||||||
inherit: true,
|
inherit: true,
|
||||||
rules: [
|
rules: [
|
||||||
{ token: 'keyword', foreground: '2563eb', fontStyle: 'bold' }, // Blue-600
|
{ token: 'keyword', foreground: '2563eb', fontStyle: 'bold' },
|
||||||
{ token: 'comment', foreground: '94a3b8', fontStyle: 'italic' }, // Slate-400
|
{ token: 'comment', foreground: '94a3b8', fontStyle: 'italic' },
|
||||||
{ token: 'string', foreground: '059669' }, // Emerald-600
|
{ token: 'string', foreground: '059669' },
|
||||||
{ token: 'number', foreground: 'd97706' }, // Amber-600
|
{ token: 'number', foreground: 'd97706' },
|
||||||
{ token: 'type', foreground: '9333ea' }, // Purple-600
|
{ token: 'type', foreground: '9333ea' },
|
||||||
],
|
],
|
||||||
colors: {
|
colors: {
|
||||||
'editor.background': '#f8fafc', // Slate-50
|
'editor.background': '#f8fafc',
|
||||||
'editor.foreground': '#334155', // Slate-700
|
'editor.foreground': '#334155',
|
||||||
'editorLineNumber.foreground': '#cbd5e1', // Slate-300
|
'editorLineNumber.foreground': '#cbd5e1',
|
||||||
'editorLineNumber.activeForeground': '#2563eb', // Blue-600
|
'editorLineNumber.activeForeground': '#2563eb',
|
||||||
'editor.lineHighlightBackground': '#f1f5f9', // Slate-100
|
'editor.lineHighlightBackground': '#f1f5f9',
|
||||||
'editor.selectionBackground': '#e2e8f0', // Slate-200
|
'editor.selectionBackground': '#e2e8f0',
|
||||||
'editorCursor.foreground': '#2563eb', // Blue-600
|
'editorCursor.foreground': '#2563eb',
|
||||||
|
'editor.lineHighlightBorder': '#00000000',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
monacoRef.current.editor.setTheme(theme === 'dark' ? 'architect-dark' : 'architect-light');
|
monaco.editor.setTheme(theme === 'dark' ? 'architect-dark' : 'architect-light');
|
||||||
|
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
const handleEditorMount = useCallback((editor: any, monaco: any) => {
|
// Update theme when it changes
|
||||||
editorRef.current = editor;
|
useEffect(() => {
|
||||||
monacoRef.current = monaco;
|
if (monacoRef.current) {
|
||||||
// Initial theme set handled by useEffect
|
monacoRef.current.editor.setTheme(theme === 'dark' ? 'architect-dark' : 'architect-light');
|
||||||
}, []);
|
}
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col gap-4 animate-slide-up">
|
<div className="h-full flex flex-col gap-4 animate-slide-up relative">
|
||||||
{/* Editor Container with Badges */}
|
<MonacoWrapper
|
||||||
<div className={`flex-1 rounded-2xl overflow-hidden border relative group shadow-inner transition-colors ${theme === 'dark' ? 'bg-[#0B1221] border-white/5' : 'bg-slate-50 border-slate-200'
|
code={code}
|
||||||
}`}>
|
onChange={handleCodeChange}
|
||||||
{/* Internal Badges */}
|
onMount={handleEditorMount}
|
||||||
<div className="absolute top-4 left-4 z-10 pointer-events-none">
|
theme={(theme === 'light' || theme === 'dark') ? theme : 'dark'}
|
||||||
<span className="text-[10px] font-black uppercase tracking-widest px-3 py-1.5 rounded-lg bg-blue-500/10 text-blue-400 border border-blue-500/20 backdrop-blur-md">
|
syntaxErrors={syntaxErrors}
|
||||||
Mermaid
|
setCode={setCode}
|
||||||
</span>
|
/>
|
||||||
</div>
|
|
||||||
<div className="absolute top-4 right-4 z-10">
|
|
||||||
<span className="text-[10px] font-black uppercase tracking-widest px-3 py-1.5 rounded-lg bg-white/5 text-slate-400 border border-white/10 backdrop-blur-md">
|
|
||||||
Manual
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Editor
|
|
||||||
height="100%"
|
|
||||||
defaultLanguage="markdown"
|
|
||||||
// theme prop is controlled by monaco.editor.setTheme
|
|
||||||
options={{
|
|
||||||
minimap: { enabled: false },
|
|
||||||
fontSize: 13,
|
|
||||||
lineNumbers: 'on',
|
|
||||||
padding: { top: 50, bottom: 20 },
|
|
||||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
|
||||||
fontLigatures: true,
|
|
||||||
renderLineHighlight: 'all',
|
|
||||||
scrollbar: {
|
|
||||||
vertical: 'visible',
|
|
||||||
horizontal: 'visible',
|
|
||||||
useShadows: false,
|
|
||||||
verticalSliderSize: 6,
|
|
||||||
horizontalSliderSize: 6
|
|
||||||
},
|
|
||||||
lineHeight: 1.7,
|
|
||||||
cursorSmoothCaretAnimation: 'on',
|
|
||||||
smoothScrolling: true,
|
|
||||||
contextmenu: false,
|
|
||||||
fixedOverflowWidgets: true,
|
|
||||||
wordWrap: 'on',
|
|
||||||
glyphMargin: true,
|
|
||||||
}}
|
|
||||||
value={code}
|
|
||||||
onChange={handleCodeChange}
|
|
||||||
onMount={handleEditorMount}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Floating Action Buttons */}
|
<EditorToolbar
|
||||||
<div className="absolute top-4 right-24 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity z-20">
|
handleGenerate={handleGenerate}
|
||||||
<button
|
isLoading={isLoading}
|
||||||
onClick={() => setCode(SAMPLE_MERMAID)}
|
hasCode={!!code.trim()}
|
||||||
className="w-8 h-8 rounded-lg bg-white/5 border border-white/10 flex items-center justify-center hover:bg-white/10 text-slate-400 hover:text-white transition-all backdrop-blur-md shadow-lg"
|
|
||||||
title="Load Sample"
|
|
||||||
>
|
|
||||||
<FileText className="w-3.5 h-3.5" />
|
/>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setCode('')}
|
|
||||||
className="w-8 h-8 rounded-lg bg-white/5 border border-white/10 flex items-center justify-center hover:bg-red-500/20 text-slate-400 hover:text-red-400 transition-all backdrop-blur-md shadow-lg"
|
|
||||||
title="Clear"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Syntax Errors */}
|
|
||||||
{syntaxErrors.length > 0 && (
|
|
||||||
<div className="absolute bottom-4 left-4 right-4 p-3 bg-red-500/10 border border-red-500/30 rounded-xl animate-fade-in">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<AlertCircle className="w-4 h-4 text-red-400 shrink-0 mt-0.5" />
|
|
||||||
<div>
|
|
||||||
<p className="text-[10px] font-bold text-red-400 uppercase tracking-wider">Syntax Error</p>
|
|
||||||
<p className="text-[11px] text-red-300 mt-1">{syntaxErrors[0].message}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* AI Suggestion Toast */}
|
|
||||||
{suggestion && (
|
|
||||||
<div className="absolute bottom-4 left-4 right-4 p-4 bg-blue-500/10 border border-blue-500/30 rounded-xl animate-fade-in z-20">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Lightbulb className="w-4 h-4 text-blue-400 shrink-0" />
|
|
||||||
<p className="text-[11px] text-blue-200 font-medium leading-relaxed">{suggestion}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex gap-4 pt-2">
|
|
||||||
<button
|
|
||||||
onClick={handleSuggest}
|
|
||||||
disabled={!code.trim() || isLoading}
|
|
||||||
className="flex-1 py-4 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-slate-300 transition-all active:scale-[0.98] flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group"
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin text-slate-400" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Lightbulb className="w-4 h-4 text-slate-400 group-hover:text-yellow-400 transition-colors" />
|
|
||||||
<span className="text-[11px] font-black uppercase tracking-wider">AI Fix</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleGenerate}
|
|
||||||
disabled={!code.trim() || isLoading}
|
|
||||||
className="flex-[1.5] py-4 rounded-xl bg-gradient-to-r from-indigo-600 to-violet-600 hover:from-indigo-500 hover:to-violet-500 text-white shadow-lg shadow-indigo-900/30 transition-all active:scale-[0.98] flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin text-white/70" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Zap className="w-4 h-4 text-white" />
|
|
||||||
<span className="text-[11px] font-black uppercase tracking-wider">Visualize</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,22 +9,31 @@ import {
|
||||||
SelectionMode,
|
SelectionMode,
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import { useFlowStore } from '../store';
|
import { useFlowStore } from '../store';
|
||||||
|
import { useMobileDetect } from '../hooks/useMobileDetect';
|
||||||
import { nodeTypes } from './nodes/CustomNodes';
|
import { nodeTypes } from './nodes/CustomNodes';
|
||||||
import { edgeTypes, EdgeDefs } from './edges/AnimatedEdge';
|
import { edgeTypes, EdgeDefs } from './edges/AnimatedEdge';
|
||||||
import {
|
import {
|
||||||
Spline, Minus, Plus, Maximize, Map, Wand2,
|
Spline, Minus, Plus, Maximize, Map, Wand2,
|
||||||
RotateCcw, Download, Command, Hand, MousePointer2, Settings2, ChevronDown
|
Hand, MousePointer2, Settings2, ChevronDown
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { getLayoutedElements } from '../lib/layoutEngine';
|
import { getLayoutedElements } from '../lib/layoutEngine';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function FlowCanvas() {
|
export function FlowCanvas() {
|
||||||
const {
|
const {
|
||||||
nodes, edges, onNodesChange, onEdgesChange, onConnect,
|
nodes, edges, onNodesChange, onEdgesChange, onConnect,
|
||||||
setSelectedNode, edgeStyle, setEdgeStyle, theme, activeFilters,
|
setSelectedNode, edgeStyle, setEdgeStyle, theme, activeFilters,
|
||||||
setNodes, setEdges
|
setNodes, setEdges, focusMode
|
||||||
} = useFlowStore();
|
} = useFlowStore();
|
||||||
const { zoomIn, zoomOut, fitView, getViewport } = useReactFlow();
|
const { isMobile } = useMobileDetect();
|
||||||
|
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
||||||
const [showToolkit, setShowToolkit] = useState(false); // New State
|
const [showToolkit, setShowToolkit] = useState(false); // New State
|
||||||
|
|
||||||
|
// Track cursor - Removed for single user mode
|
||||||
|
// const handleMouseMove = useCallback((e: React.MouseEvent) => { ... }, []);
|
||||||
|
|
||||||
const [showMiniMap, setShowMiniMap] = useState(true);
|
const [showMiniMap, setShowMiniMap] = useState(true);
|
||||||
const [isSelectionMode, setIsSelectionMode] = useState(false); // false = Pan, true = Select
|
const [isSelectionMode, setIsSelectionMode] = useState(false); // false = Pan, true = Select
|
||||||
|
|
||||||
|
|
@ -71,9 +80,35 @@ export function FlowCanvas() {
|
||||||
// Filter nodes based on active filters
|
// Filter nodes based on active filters
|
||||||
const filteredNodes = useMemo(() => {
|
const filteredNodes = useMemo(() => {
|
||||||
return nodes.map(node => {
|
return nodes.map(node => {
|
||||||
if (node.type === 'group') return node;
|
if (node.type === 'group') {
|
||||||
|
const category = (node.data?.category as string) || 'filter-other';
|
||||||
|
// Group is visible if Groups are enabled AND its specific category is enabled
|
||||||
|
const isGroupEnabled = activeFilters.includes('filter-group');
|
||||||
|
const isCategoryEnabled = activeFilters.includes(category);
|
||||||
|
|
||||||
const category = (node.data?.category as string) || (node.type === 'database' ? 'filter-db' : node.type === 'client' ? 'filter-client' : 'filter-server');
|
return {
|
||||||
|
...node,
|
||||||
|
hidden: !isGroupEnabled || !isCategoryEnabled
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let category = (node.data?.category as string);
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
switch (node.type) {
|
||||||
|
case 'client':
|
||||||
|
category = 'filter-client';
|
||||||
|
break;
|
||||||
|
case 'database':
|
||||||
|
category = 'filter-db';
|
||||||
|
break;
|
||||||
|
case 'server':
|
||||||
|
category = 'filter-server';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
category = 'filter-other'; // Start, End, Process, Decision, etc.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...node,
|
...node,
|
||||||
|
|
@ -157,6 +192,7 @@ export function FlowCanvas() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full relative bg-void">
|
<div className="w-full h-full relative bg-void">
|
||||||
|
|
||||||
<EdgeDefs />
|
<EdgeDefs />
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={filteredNodes}
|
nodes={filteredNodes}
|
||||||
|
|
@ -172,6 +208,8 @@ export function FlowCanvas() {
|
||||||
fitViewOptions={{ padding: 0.2, maxZoom: 1.5 }}
|
fitViewOptions={{ padding: 0.2, maxZoom: 1.5 }}
|
||||||
minZoom={0.1}
|
minZoom={0.1}
|
||||||
maxZoom={4}
|
maxZoom={4}
|
||||||
|
panOnScroll={!isMobile} // Disable on mobile to prevent scroll conflicts
|
||||||
|
zoomOnPinch={true} // Enable pinch-to-zoom on mobile
|
||||||
panOnDrag={!isSelectionMode}
|
panOnDrag={!isSelectionMode}
|
||||||
selectionOnDrag={isSelectionMode}
|
selectionOnDrag={isSelectionMode}
|
||||||
selectionMode={SelectionMode.Partial}
|
selectionMode={SelectionMode.Partial}
|
||||||
|
|
@ -194,7 +232,7 @@ export function FlowCanvas() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Control Panel - Top Right (Unified Toolkit) */}
|
{/* Control Panel - Top Right (Unified Toolkit) */}
|
||||||
<Panel position="top-right" className="!m-4 flex flex-col items-end gap-3 z-50">
|
<Panel position="top-right" className={`!m-4 !mr-6 flex flex-col items-end gap-3 z-50 transition-all duration-300 ${focusMode ? '!mt-20' : ''}`}>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowToolkit(!showToolkit)}
|
onClick={() => setShowToolkit(!showToolkit)}
|
||||||
|
|
@ -287,9 +325,9 @@ export function FlowCanvas() {
|
||||||
|
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* MiniMap Container - Bottom Right */}
|
{/* MiniMap Container - Bottom Right (Hidden on Mobile) */}
|
||||||
<Panel position="bottom-right" className="!m-4 z-40">
|
<Panel position="bottom-right" className="!m-4 z-40">
|
||||||
{showMiniMap && nodes.length > 0 && (
|
{showMiniMap && nodes.length > 0 && !isMobile && (
|
||||||
<div className="w-52 h-36 rounded-xl border border-slate-200 dark:border-white/10 bg-white/50 dark:bg-slate-900/50 backdrop-blur-md shadow-xl overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-300">
|
<div className="w-52 h-36 rounded-xl border border-slate-200 dark:border-white/10 bg-white/50 dark:bg-slate-900/50 backdrop-blur-md shadow-xl overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-300">
|
||||||
<MiniMap
|
<MiniMap
|
||||||
nodeColor={miniMapNodeColor}
|
nodeColor={miniMapNodeColor}
|
||||||
|
|
@ -322,40 +360,11 @@ export function FlowCanvas() {
|
||||||
</Panel>
|
</Panel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Command Palette Hint - Top Center */}
|
{/* Command Palette Hint - Top Center (Desktop only) */}
|
||||||
<Panel position="top-center" className="!mt-6">
|
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-white/60 dark:bg-surface/60 backdrop-blur-md border border-black/5 dark:border-white/10 rounded-lg text-slate-500 dark:text-tertiary hover:text-slate-800 dark:hover:text-secondary transition-colors cursor-pointer opacity-60 hover:opacity-100">
|
|
||||||
<Command className="w-3 h-3" />
|
|
||||||
<span className="text-[9px] font-bold uppercase tracking-wider">K</span>
|
|
||||||
<span className="text-[9px] text-slate-400 dark:text-slate-500">Quick Actions</span>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Control Button Component
|
|
||||||
interface ControlButtonProps {
|
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
|
||||||
onClick: () => void;
|
|
||||||
title: string;
|
|
||||||
highlight?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ControlButton({ icon: Icon, onClick, title, highlight }: ControlButtonProps) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); onClick(); }}
|
|
||||||
className={`
|
|
||||||
w-10 h-10 flex items-center justify-center
|
|
||||||
text-secondary hover:text-primary hover:bg-blue-500/10
|
|
||||||
transition-all
|
|
||||||
${highlight ? 'text-blue-500' : ''}
|
|
||||||
`}
|
|
||||||
title={title}
|
|
||||||
>
|
|
||||||
<Icon className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useRef } from 'react';
|
||||||
import { useFlowStore } from '../store';
|
import { useFlowStore } from '../store';
|
||||||
import { analyzeImage, analyzeSVG } from '../lib/aiService';
|
import { analyzeImage, analyzeSVG } from '../lib/aiService';
|
||||||
import { parseMermaid } from '../lib/mermaidParser';
|
import { parseMermaid } from '../lib/mermaidParser';
|
||||||
|
|
@ -10,6 +10,7 @@ export function ImageUpload() {
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [fileType, setFileType] = useState<'image' | 'svg' | null>(null);
|
const [fileType, setFileType] = useState<'image' | 'svg' | null>(null);
|
||||||
const [svgContent, setSvgContent] = useState<string>('');
|
const [svgContent, setSvgContent] = useState<string>('');
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
setNodes, setEdges, setLoading, setError, setSourceCode, isLoading,
|
setNodes, setEdges, setLoading, setError, setSourceCode, isLoading,
|
||||||
|
|
@ -65,10 +66,26 @@ export function ImageUpload() {
|
||||||
}, [handleFile]);
|
}, [handleFile]);
|
||||||
|
|
||||||
const handleGenerate = useCallback(async () => {
|
const handleGenerate = useCallback(async () => {
|
||||||
if (aiMode === 'offline' && !ollamaUrl) {
|
// Validate AI configuration before processing
|
||||||
setError('Please configure Ollama URL in settings');
|
if (aiMode === 'offline') {
|
||||||
|
if (!ollamaUrl) {
|
||||||
|
setError('Please configure Ollama URL in Settings → Local mode');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (aiMode === 'online') {
|
||||||
|
if (onlineProvider !== 'ollama-cloud' && !apiKey) {
|
||||||
|
setError(`Please enter your ${onlineProvider === 'gemini' ? 'Google Gemini' : 'OpenAI'} API key in Settings → Cloud mode`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (onlineProvider === 'ollama-cloud' && !ollamaUrl) {
|
||||||
|
setError('Please configure the Remote Ollama endpoint in Settings → Cloud mode');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (aiMode === 'browser') {
|
||||||
|
setError('Browser mode does not support image analysis yet. Please use Cloud mode with an API key in Settings.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
|
@ -81,8 +98,7 @@ export function ImageUpload() {
|
||||||
modelName,
|
modelName,
|
||||||
aiMode,
|
aiMode,
|
||||||
onlineProvider,
|
onlineProvider,
|
||||||
apiKey,
|
apiKey
|
||||||
generationComplexity
|
|
||||||
);
|
);
|
||||||
} else if (preview) {
|
} else if (preview) {
|
||||||
result = await analyzeImage(
|
result = await analyzeImage(
|
||||||
|
|
@ -130,7 +146,7 @@ export function ImageUpload() {
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onClick={() => !preview && document.getElementById('image-input')?.click()}
|
onClick={() => !preview && fileInputRef.current?.click()}
|
||||||
className={`flex-1 relative rounded-2xl flex flex-col items-center justify-center cursor-pointer transition-all border border-dashed
|
className={`flex-1 relative rounded-2xl flex flex-col items-center justify-center cursor-pointer transition-all border border-dashed
|
||||||
${isDragging
|
${isDragging
|
||||||
? 'bg-blue-500/10 border-blue-500/50 scale-[1.01]'
|
? 'bg-blue-500/10 border-blue-500/50 scale-[1.01]'
|
||||||
|
|
@ -138,7 +154,7 @@ export function ImageUpload() {
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
id="image-input"
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".jpg,.jpeg,.png,.webp,.svg"
|
accept=".jpg,.jpeg,.png,.webp,.svg"
|
||||||
onChange={handleFileInput}
|
onChange={handleFileInput}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { ImageUpload } from './ImageUpload';
|
import { ImageUpload } from './ImageUpload';
|
||||||
import { CodeEditor } from './CodeEditor';
|
import { CodeEditor } from './CodeEditor';
|
||||||
import { Image, Code, MessageSquare, Loader2, Zap } from 'lucide-react';
|
import { Image, Code, MessageSquare, Loader2, Zap } from 'lucide-react';
|
||||||
|
|
@ -10,20 +10,39 @@ import { getLayoutedElements } from '../lib/layoutEngine';
|
||||||
type Tab = 'image' | 'code' | 'describe';
|
type Tab = 'image' | 'code' | 'describe';
|
||||||
|
|
||||||
export function InputPanel() {
|
export function InputPanel() {
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('image');
|
|
||||||
const [description, setDescription] = useState('');
|
|
||||||
const {
|
const {
|
||||||
setNodes, setEdges, setLoading, setError,
|
setNodes, setEdges, setLoading, setError,
|
||||||
setSourceCode, isLoading, ollamaUrl, modelName,
|
setSourceCode, isLoading, ollamaUrl, modelName,
|
||||||
aiMode, onlineProvider, apiKey,
|
aiMode, onlineProvider, apiKey,
|
||||||
generationComplexity, setGenerationComplexity
|
generationComplexity, setGenerationComplexity,
|
||||||
|
// Use global state for input persistence
|
||||||
|
inputDescription: description, setInputDescription: setDescription,
|
||||||
|
inputActiveTab: activeTab, setInputActiveTab: setActiveTab,
|
||||||
|
|
||||||
|
// Actions for auto-sync and save
|
||||||
|
setMermaidCode, saveDiagram
|
||||||
} = useFlowStore();
|
} = useFlowStore();
|
||||||
|
|
||||||
const handleTextGenerate = useCallback(async () => {
|
const handleTextGenerate = useCallback(async () => {
|
||||||
if (!description.trim()) return;
|
if (!description.trim()) return;
|
||||||
if (aiMode === 'offline' && !ollamaUrl) {
|
|
||||||
setError('Please configure Ollama URL in settings');
|
// Validate AI configuration before processing
|
||||||
return;
|
if (aiMode === 'offline') {
|
||||||
|
if (!ollamaUrl) {
|
||||||
|
setError('Please configure Ollama URL in Settings → Local mode');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (aiMode === 'online') {
|
||||||
|
if (onlineProvider !== 'ollama-cloud' && !apiKey) {
|
||||||
|
setError(`Please enter your ${onlineProvider === 'gemini' ? 'Google Gemini' : 'OpenAI'} API key in Settings → Cloud mode`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (onlineProvider === 'ollama-cloud' && !ollamaUrl) {
|
||||||
|
setError('Please configure the Remote Ollama endpoint in Settings → Cloud mode');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (aiMode === 'browser') {
|
||||||
|
// Browser mode is okay for text - just needs model loaded
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -44,7 +63,10 @@ export function InputPanel() {
|
||||||
throw new Error(result.error || 'Could not interpret flow from description');
|
throw new Error(result.error || 'Could not interpret flow from description');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync with global Mermaid code store so it appears in the Code tab
|
||||||
|
setMermaidCode(result.mermaidCode);
|
||||||
setSourceCode(result.mermaidCode);
|
setSourceCode(result.mermaidCode);
|
||||||
|
|
||||||
const { nodes: parsedNodes, edges: parsedEdges } = await parseMermaid(result.mermaidCode);
|
const { nodes: parsedNodes, edges: parsedEdges } = await parseMermaid(result.mermaidCode);
|
||||||
|
|
||||||
if (result.metadata) {
|
if (result.metadata) {
|
||||||
|
|
@ -59,12 +81,18 @@ export function InputPanel() {
|
||||||
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(parsedNodes, parsedEdges);
|
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(parsedNodes, parsedEdges);
|
||||||
setNodes(layoutedNodes);
|
setNodes(layoutedNodes);
|
||||||
setEdges(layoutedEdges);
|
setEdges(layoutedEdges);
|
||||||
|
|
||||||
|
// Auto-save the generated diagram
|
||||||
|
// Generate a name based on the description or a timestamp
|
||||||
|
const autoSaveName = description.split(' ').slice(0, 4).join(' ') || `Auto-Save ${new Date().toLocaleTimeString()}`;
|
||||||
|
saveDiagram(autoSaveName);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(error instanceof Error ? error.message : 'Failed to process description');
|
setError(error instanceof Error ? error.message : 'Failed to process description');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [description, ollamaUrl, modelName, aiMode, onlineProvider, apiKey, generationComplexity, setNodes, setEdges, setLoading, setError, setSourceCode]);
|
}, [description, ollamaUrl, modelName, aiMode, onlineProvider, apiKey, generationComplexity, setNodes, setEdges, setLoading, setError, setSourceCode, setMermaidCode, saveDiagram]);
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'image' as Tab, icon: Image, label: 'Upload' },
|
{ id: 'image' as Tab, icon: Image, label: 'Upload' },
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,33 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Eye, Server, Database, Smartphone } from 'lucide-react';
|
import { Eye, Server, Database, Smartphone, Layers, BoxSelect } from 'lucide-react';
|
||||||
import { useFlowStore } from '../store';
|
import { useFlowStore } from '../store';
|
||||||
|
|
||||||
const filters = [
|
const filters = [
|
||||||
{ id: 'filter-client', label: 'Client', icon: Smartphone, color: '#a855f7' },
|
{ id: 'filter-client', label: 'Client', icon: Smartphone, color: '#a855f7' },
|
||||||
{ id: 'filter-server', label: 'Server', icon: Server, color: '#3b82f6' },
|
{ id: 'filter-server', label: 'Server', icon: Server, color: '#3b82f6' },
|
||||||
{ id: 'filter-db', label: 'Database', icon: Database, color: '#10b981' },
|
{ id: 'filter-db', label: 'Database', icon: Database, color: '#10b981' },
|
||||||
|
{ id: 'filter-group', label: 'Groups', icon: BoxSelect, color: '#94a3b8' },
|
||||||
|
{ id: 'filter-other', label: 'Flow / Other', icon: Layers, color: '#f59e0b' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function InteractiveLegend() {
|
export default function InteractiveLegend() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const { focusMode, activeFilters, toggleFilter } = useFlowStore();
|
const { focusMode, activeFilters, toggleFilter, nodes } = useFlowStore();
|
||||||
|
|
||||||
if (focusMode) return null;
|
if (focusMode) return null;
|
||||||
|
|
||||||
|
// Calculate available categories from current nodes
|
||||||
|
const availableCategories = new Set(nodes.map(node => node.data.category));
|
||||||
|
|
||||||
|
// Always include 'filter-group' if there are groups, or maybe check node type
|
||||||
|
if (nodes.some(n => n.type === 'group')) {
|
||||||
|
availableCategories.add('filter-group');
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleFilters = filters.filter(f => availableCategories.has(f.id));
|
||||||
|
|
||||||
|
if (visibleFilters.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute bottom-20 left-4 z-50">
|
<div className="absolute bottom-20 left-4 z-50">
|
||||||
{/* Toggle Button */}
|
{/* Toggle Button */}
|
||||||
|
|
@ -35,7 +49,7 @@ export default function InteractiveLegend() {
|
||||||
Legend Filters
|
Legend Filters
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{filters.map(f => {
|
{visibleFilters.map(f => {
|
||||||
const isActive = activeFilters.includes(f.id);
|
const isActive = activeFilters.includes(f.id);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import {
|
||||||
FileText, Activity, Zap, Cpu, Wifi, BarChart3
|
FileText, Activity, Zap, Cpu, Wifi, BarChart3
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { VisualOrganizerPanel } from './VisualOrganizerPanel';
|
|
||||||
import { SmartGuide } from './SmartGuide';
|
import { SmartGuide } from './SmartGuide';
|
||||||
|
|
||||||
export function NodeDetailsPanel() {
|
export function NodeDetailsPanel() {
|
||||||
|
|
@ -38,8 +37,6 @@ export function NodeDetailsPanel() {
|
||||||
<div className="w-full h-px bg-gradient-to-r from-transparent via-blue-500/20 to-transparent" />
|
<div className="w-full h-px bg-gradient-to-r from-transparent via-blue-500/20 to-transparent" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VisualOrganizerPanel />
|
|
||||||
|
|
||||||
<div className="mt-auto">
|
<div className="mt-auto">
|
||||||
<SmartGuide />
|
<SmartGuide />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -102,6 +99,12 @@ export function NodeDetailsPanel() {
|
||||||
className="w-full bg-white dark:bg-black/20 border border-slate-200 dark:border-white/10 rounded-xl p-3 text-sm font-semibold outline-none focus:border-blue-500/50 transition-all text-slate-800 dark:text-primary cursor-pointer appearance-none"
|
className="w-full bg-white dark:bg-black/20 border border-slate-200 dark:border-white/10 rounded-xl p-3 text-sm font-semibold outline-none focus:border-blue-500/50 transition-all text-slate-800 dark:text-primary cursor-pointer appearance-none"
|
||||||
>
|
>
|
||||||
<option value="default">Default</option>
|
<option value="default">Default</option>
|
||||||
|
<option value="group">Group / Container</option>
|
||||||
|
<option value="ai">AI Director / Agent</option>
|
||||||
|
<option value="team">Human Team</option>
|
||||||
|
<option value="platform">External Platform</option>
|
||||||
|
<option value="data">Data / Analytics</option>
|
||||||
|
<option value="tech">Tech Infrastructure</option>
|
||||||
<option value="start">Start</option>
|
<option value="start">Start</option>
|
||||||
<option value="end">End</option>
|
<option value="end">End</option>
|
||||||
<option value="decision">Decision</option>
|
<option value="decision">Decision</option>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Cpu, X, Database, Globe, ShieldCheck, ChevronDown, RefreshCw, Zap, Download, Eye } from 'lucide-react';
|
import { Cpu, X, Database, Globe, ShieldCheck, ChevronDown, RefreshCw, Zap, Download, Eye, Smartphone } from 'lucide-react';
|
||||||
import { useFlowStore } from '../store';
|
import { useFlowStore } from '../store';
|
||||||
|
import { useMobileDetect } from '../hooks/useMobileDetect';
|
||||||
import { webLlmService } from '../lib/webLlmService';
|
import { webLlmService } from '../lib/webLlmService';
|
||||||
import type { WebLlmProgress } from '../lib/webLlmService';
|
import type { WebLlmProgress } from '../lib/webLlmService';
|
||||||
import { visionService } from '../lib/visionService';
|
import { visionService } from '../lib/visionService';
|
||||||
|
|
@ -25,6 +26,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||||
aiMode, setAiMode,
|
aiMode, setAiMode,
|
||||||
onlineProvider, setOnlineProvider
|
onlineProvider, setOnlineProvider
|
||||||
} = useFlowStore();
|
} = useFlowStore();
|
||||||
|
const { isMobile } = useMobileDetect();
|
||||||
|
|
||||||
const [systemStatus, setSystemStatus] = useState<'online' | 'offline'>('offline');
|
const [systemStatus, setSystemStatus] = useState<'online' | 'offline'>('offline');
|
||||||
const [isVerifying, setIsVerifying] = useState(false);
|
const [isVerifying, setIsVerifying] = useState(false);
|
||||||
|
|
@ -41,6 +43,10 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||||
const [isVisionLoading, setIsVisionLoading] = useState(false);
|
const [isVisionLoading, setIsVisionLoading] = useState(false);
|
||||||
const [isVisionReady, setIsVisionReady] = useState(false);
|
const [isVisionReady, setIsVisionReady] = useState(false);
|
||||||
|
|
||||||
|
// API Key Verification State
|
||||||
|
const [keyVerificationStatus, setKeyVerificationStatus] = useState<'idle' | 'checking' | 'valid' | 'invalid'>('idle');
|
||||||
|
const [keyVerificationError, setKeyVerificationError] = useState<string | null>(null);
|
||||||
|
|
||||||
const checkBrowserStatus = useCallback(() => {
|
const checkBrowserStatus = useCallback(() => {
|
||||||
const llmStatus = webLlmService.getStatus();
|
const llmStatus = webLlmService.getStatus();
|
||||||
setIsBrowserReady(llmStatus.isReady);
|
setIsBrowserReady(llmStatus.isReady);
|
||||||
|
|
@ -114,12 +120,59 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||||
if (onlineProvider === 'ollama-cloud') {
|
if (onlineProvider === 'ollama-cloud') {
|
||||||
await fetchModels();
|
await fetchModels();
|
||||||
} else {
|
} else {
|
||||||
const isKeyValid = apiKey.length > 20 && (apiKey.startsWith('sk-') || apiKey.startsWith('AIza') || apiKey.length > 30);
|
// Just check format, actual verification is done with verify button
|
||||||
setSystemStatus(isKeyValid ? 'online' : 'offline');
|
const isKeyFormatValid = apiKey.length > 20 && (apiKey.startsWith('sk-') || apiKey.startsWith('AIza') || apiKey.length > 30);
|
||||||
|
setSystemStatus(isKeyFormatValid && keyVerificationStatus === 'valid' ? 'online' : 'offline');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setIsVerifying(false);
|
setIsVerifying(false);
|
||||||
}, [aiMode, fetchModels, apiKey, onlineProvider, checkBrowserStatus]);
|
}, [aiMode, fetchModels, apiKey, onlineProvider, checkBrowserStatus, keyVerificationStatus]);
|
||||||
|
|
||||||
|
// Verify API key by making a test request
|
||||||
|
const verifyApiKey = async () => {
|
||||||
|
if (!apiKey || apiKey.length < 10) {
|
||||||
|
setKeyVerificationStatus('invalid');
|
||||||
|
setKeyVerificationError('API key is too short');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setKeyVerificationStatus('checking');
|
||||||
|
setKeyVerificationError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (onlineProvider === 'openai') {
|
||||||
|
// Test OpenAI key with models list endpoint (lightweight)
|
||||||
|
const response = await fetch('https://api.openai.com/v1/models', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Authorization': `Bearer ${apiKey}` }
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
setKeyVerificationStatus('valid');
|
||||||
|
setSystemStatus('online');
|
||||||
|
} else {
|
||||||
|
const error = await response.json().catch(() => ({}));
|
||||||
|
setKeyVerificationStatus('invalid');
|
||||||
|
setKeyVerificationError(error.error?.message || `Error: ${response.status}`);
|
||||||
|
}
|
||||||
|
} else if (onlineProvider === 'gemini') {
|
||||||
|
// Test Gemini key with a minimal request
|
||||||
|
const response = await fetch(
|
||||||
|
`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`
|
||||||
|
);
|
||||||
|
if (response.ok) {
|
||||||
|
setKeyVerificationStatus('valid');
|
||||||
|
setSystemStatus('online');
|
||||||
|
} else {
|
||||||
|
const error = await response.json().catch(() => ({}));
|
||||||
|
setKeyVerificationStatus('invalid');
|
||||||
|
setKeyVerificationError(error.error?.message || `Error: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setKeyVerificationStatus('invalid');
|
||||||
|
setKeyVerificationError(error instanceof Error ? error.message : 'Connection failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
|
|
@ -134,7 +187,12 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-[9998] bg-black/40 backdrop-blur-sm animate-fade-in" onClick={onClose} />
|
<div className="fixed inset-0 z-[9998] bg-black/40 backdrop-blur-sm animate-fade-in" onClick={onClose} />
|
||||||
<div className="fixed top-24 right-12 w-96 floating-glass p-8 rounded-[2rem] z-[9999] animate-slide-up titanium-border shadow-2xl flex flex-col gap-6">
|
<div className={`fixed z-[9999] flex flex-col gap-4
|
||||||
|
${isMobile
|
||||||
|
? 'left-4 right-4 top-16 max-h-[80vh] rounded-[2rem]'
|
||||||
|
: 'top-24 right-12 w-96 rounded-[2rem]'
|
||||||
|
} floating-glass p-6 titanium-border shadow-2xl overflow-hidden`}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-xl bg-blue-500/10 flex items-center justify-center border border-blue-500/20">
|
<div className="w-10 h-10 rounded-xl bg-blue-500/10 flex items-center justify-center border border-blue-500/20">
|
||||||
|
|
@ -145,12 +203,21 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||||
<p className="text-[10px] text-tertiary font-medium uppercase tracking-wider">Configuration</p>
|
<p className="text-[10px] text-tertiary font-medium uppercase tracking-wider">Configuration</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="p-2 hover:bg-white/5 rounded-full transition-colors group">
|
<div className="flex items-center gap-2">
|
||||||
<X className="w-5 h-5 text-tertiary group-hover:text-primary transition-colors" />
|
{isMobile && (
|
||||||
</button>
|
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-amber-500/10 border border-amber-500/20">
|
||||||
|
<Smartphone className="w-3 h-3 text-amber-400" />
|
||||||
|
<span className="text-[9px] font-bold text-amber-400 uppercase tracking-wider">Lite</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button onClick={onClose} className="p-2 hover:bg-white/5 rounded-full transition-colors group">
|
||||||
|
<X className="w-5 h-5 text-tertiary group-hover:text-primary transition-colors" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
{/* Scrollable Content Area */}
|
||||||
|
<div className={`${isMobile ? 'flex-1 overflow-y-auto' : ''} space-y-6`}>
|
||||||
{/* Mode Selection */}
|
{/* Mode Selection */}
|
||||||
<div className="flex items-center gap-1 p-1 bg-black/20 rounded-xl border border-white/5">
|
<div className="flex items-center gap-1 p-1 bg-black/20 rounded-xl border border-white/5">
|
||||||
<button
|
<button
|
||||||
|
|
@ -190,6 +257,18 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||||
|
|
||||||
{aiMode === 'browser' && (
|
{aiMode === 'browser' && (
|
||||||
<div className="space-y-4 animate-fade-in max-h-[60vh] overflow-y-auto pr-2 custom-scrollbar">
|
<div className="space-y-4 animate-fade-in max-h-[60vh] overflow-y-auto pr-2 custom-scrollbar">
|
||||||
|
{/* Mobile Warning */}
|
||||||
|
{isMobile && (
|
||||||
|
<div className="p-3 rounded-xl bg-amber-500/10 border border-amber-500/20">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Smartphone className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-bold text-amber-300 uppercase tracking-wider">Performance Warning</p>
|
||||||
|
<p className="text-[9px] text-amber-400/70 mt-1 leading-relaxed">Browser AI requires 1GB+ download and may drain battery. Cloud mode is recommended for mobile.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Neural Engine Card */}
|
{/* Neural Engine Card */}
|
||||||
<div className="p-4 rounded-xl bg-violet-500/5 border border-violet-500/10">
|
<div className="p-4 rounded-xl bg-violet-500/5 border border-violet-500/10">
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
|
@ -358,9 +437,9 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||||
<label className="text-[10px] font-bold uppercase tracking-wider text-tertiary pl-1">AI Engine</label>
|
<label className="text-[10px] font-bold uppercase tracking-wider text-tertiary pl-1">AI Engine</label>
|
||||||
<div className="grid grid-cols-1 gap-2">
|
<div className="grid grid-cols-1 gap-2">
|
||||||
{[
|
{[
|
||||||
{ id: 'openai', label: 'OpenAI (GPT-4o)', color: 'bg-green-500' },
|
{ id: 'openai', label: 'OpenAI (GPT-4o)', color: 'bg-green-500', apiUrl: 'https://platform.openai.com/api-keys' },
|
||||||
{ id: 'gemini', label: 'Google Gemini Pro', color: 'bg-blue-500' },
|
{ id: 'gemini', label: 'Google Gemini Pro', color: 'bg-blue-500', apiUrl: 'https://aistudio.google.com/apikey' },
|
||||||
{ id: 'ollama-cloud', label: 'Remote Ollama', color: 'bg-orange-500' }
|
{ id: 'ollama-cloud', label: 'Remote Ollama', color: 'bg-orange-500', apiUrl: null }
|
||||||
].map(p => (
|
].map(p => (
|
||||||
<button
|
<button
|
||||||
key={p.id}
|
key={p.id}
|
||||||
|
|
@ -386,13 +465,64 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||||
<label className="text-[10px] font-bold uppercase tracking-wider text-tertiary pl-1">
|
<label className="text-[10px] font-bold uppercase tracking-wider text-tertiary pl-1">
|
||||||
{onlineProvider === 'ollama-cloud' ? 'API Endpoint' : 'Secret Key'}
|
{onlineProvider === 'ollama-cloud' ? 'API Endpoint' : 'Secret Key'}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div className="flex gap-2">
|
||||||
type={onlineProvider === 'ollama-cloud' ? 'text' : 'password'}
|
<input
|
||||||
value={onlineProvider === 'ollama-cloud' ? ollamaUrl : apiKey}
|
type={onlineProvider === 'ollama-cloud' ? 'text' : 'password'}
|
||||||
onChange={(e) => onlineProvider === 'ollama-cloud' ? setOllamaUrl(e.target.value) : setApiKey(e.target.value)}
|
value={onlineProvider === 'ollama-cloud' ? ollamaUrl : apiKey}
|
||||||
className="w-full bg-black/20 border border-white/10 rounded-xl p-3 text-[12px] outline-none focus:border-blue-500/50 font-mono transition-all text-primary placeholder:text-slate-600"
|
onChange={(e) => {
|
||||||
placeholder={onlineProvider === 'ollama-cloud' ? 'https://api.example.com' : 'sk-...'}
|
if (onlineProvider === 'ollama-cloud') {
|
||||||
/>
|
setOllamaUrl(e.target.value);
|
||||||
|
} else {
|
||||||
|
setApiKey(e.target.value);
|
||||||
|
setKeyVerificationStatus('idle');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex-1 bg-black/20 border border-white/10 rounded-xl p-3 text-[12px] outline-none focus:border-blue-500/50 font-mono transition-all text-primary placeholder:text-slate-600"
|
||||||
|
placeholder={onlineProvider === 'ollama-cloud' ? 'https://api.example.com' : 'sk-...'}
|
||||||
|
/>
|
||||||
|
{onlineProvider !== 'ollama-cloud' && (
|
||||||
|
<button
|
||||||
|
onClick={verifyApiKey}
|
||||||
|
disabled={keyVerificationStatus === 'checking' || !apiKey}
|
||||||
|
className={`px-4 rounded-xl border transition-all flex items-center justify-center min-w-[80px] text-[10px] font-bold uppercase tracking-wider
|
||||||
|
${keyVerificationStatus === 'valid'
|
||||||
|
? 'bg-emerald-500/20 border-emerald-500/30 text-emerald-400'
|
||||||
|
: keyVerificationStatus === 'invalid'
|
||||||
|
? 'bg-red-500/20 border-red-500/30 text-red-400'
|
||||||
|
: 'bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:text-white'}`}
|
||||||
|
>
|
||||||
|
{keyVerificationStatus === 'checking' ? (
|
||||||
|
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : keyVerificationStatus === 'valid' ? (
|
||||||
|
<>✓ Valid</>
|
||||||
|
) : keyVerificationStatus === 'invalid' ? (
|
||||||
|
<>✗ Invalid</>
|
||||||
|
) : (
|
||||||
|
'Verify'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{keyVerificationStatus === 'invalid' && keyVerificationError && (
|
||||||
|
<p className="text-[9px] text-red-400 pl-1">{keyVerificationError}</p>
|
||||||
|
)}
|
||||||
|
{keyVerificationStatus === 'valid' && (
|
||||||
|
<p className="text-[9px] text-emerald-400 pl-1">API key verified successfully!</p>
|
||||||
|
)}
|
||||||
|
{/* Get API Key Link */}
|
||||||
|
{onlineProvider !== 'ollama-cloud' && (
|
||||||
|
<a
|
||||||
|
href={onlineProvider === 'openai'
|
||||||
|
? 'https://platform.openai.com/api-keys'
|
||||||
|
: 'https://aistudio.google.com/apikey'}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-[10px] text-blue-400 hover:text-blue-300 transition-colors pl-1 mt-1"
|
||||||
|
>
|
||||||
|
<Globe className="w-3 h-3" />
|
||||||
|
Get {onlineProvider === 'openai' ? 'OpenAI' : 'Google Gemini'} API Key →
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Lightbulb, MousePointer2, Keyboard, Layout } from 'lucide-react';
|
import { Lightbulb, MousePointer2, Keyboard, Layout } from 'lucide-react';
|
||||||
|
|
||||||
const TIPS = [
|
const TIPS = [
|
||||||
|
|
|
||||||
142
src/components/VisualOrganizerFAB.tsx
Normal file
142
src/components/VisualOrganizerFAB.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Button } from './ui/Button';
|
||||||
|
import { Card } from './ui/Card';
|
||||||
|
import { useVisualOrganizer } from '../hooks/useVisualOrganizer';
|
||||||
|
import { useDiagramStore } from '../store';
|
||||||
|
import { Sparkles, Wand2, Scan, CheckCircle2, X } from 'lucide-react';
|
||||||
|
import type { LayoutSuggestion } from '../types/visualOrganization';
|
||||||
|
|
||||||
|
export const VisualOrganizerFAB: React.FC = () => {
|
||||||
|
const { analyzeLayout, generateSuggestions, applySuggestion } = useVisualOrganizer();
|
||||||
|
const { nodes, edges, setNodes, setEdges } = useDiagramStore();
|
||||||
|
|
||||||
|
// UI States
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [status, setStatus] = useState<'idle' | 'analyzing' | 'ready' | 'applied'>('idle');
|
||||||
|
const [bestSuggestion, setBestSuggestion] = useState<LayoutSuggestion | null>(null);
|
||||||
|
const [snapshot, setSnapshot] = useState<{ nodes: any[], edges: any[] } | null>(null);
|
||||||
|
|
||||||
|
const handleAIOrganize = async () => {
|
||||||
|
setStatus('analyzing');
|
||||||
|
setIsOpen(true);
|
||||||
|
|
||||||
|
analyzeLayout();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
const results = await generateSuggestions();
|
||||||
|
|
||||||
|
if (results.length > 0) {
|
||||||
|
setBestSuggestion(results[0]);
|
||||||
|
setStatus('ready');
|
||||||
|
} else {
|
||||||
|
setStatus('idle');
|
||||||
|
// Could show a generic "No suggestions" toast here
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setStatus('idle');
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
if (!bestSuggestion) return;
|
||||||
|
setSnapshot({ nodes: [...nodes], edges: [...edges] });
|
||||||
|
applySuggestion(bestSuggestion);
|
||||||
|
setStatus('applied');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUndo = () => {
|
||||||
|
if (snapshot) {
|
||||||
|
setNodes(snapshot.nodes);
|
||||||
|
setEdges(snapshot.edges);
|
||||||
|
setSnapshot(null);
|
||||||
|
setStatus('ready');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
if (status === 'applied') {
|
||||||
|
setStatus('idle');
|
||||||
|
setSnapshot(null);
|
||||||
|
setBestSuggestion(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// If closed, show just the FAB
|
||||||
|
if (!isOpen) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleAIOrganize}
|
||||||
|
className="w-12 h-12 rounded-full bg-white dark:bg-slate-800 border border-indigo-100 dark:border-indigo-900/30 shadow-lg hover:shadow-indigo-500/20 flex items-center justify-center group transition-all hover:scale-110 active:scale-95"
|
||||||
|
title="AI Visual Organizer"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-5 h-5 text-indigo-500 group-hover:rotate-12 transition-transform" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expanded State (Floating Card)
|
||||||
|
return (
|
||||||
|
<div className="relative animate-in slide-in-from-bottom-4 fade-in duration-300">
|
||||||
|
<Card className="w-80 p-0 overflow-hidden shadow-2xl border-indigo-100 dark:border-indigo-900/30">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-900/50 border-b border-slate-100 dark:border-white/5 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Wand2 className="w-4 h-4 text-indigo-500" />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-slate-600 dark:text-slate-300">Organizer</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleClose} className="text-slate-400 hover:text-slate-600 dark:hover:text-white">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-5">
|
||||||
|
{status === 'analyzing' && (
|
||||||
|
<div className="flex flex-col items-center py-4 space-y-4">
|
||||||
|
<div className="relative w-16 h-16">
|
||||||
|
<div className="absolute inset-0 border-4 border-indigo-100 dark:border-indigo-900/30 rounded-full"></div>
|
||||||
|
<div className="absolute inset-0 border-4 border-t-indigo-500 rounded-full animate-spin"></div>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<Scan className="w-6 h-6 text-indigo-500 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-medium text-slate-500 animate-pulse">Analyzing structure...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'ready' && bestSuggestion && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<CheckCircle2 className="w-8 h-8 text-green-500 mx-auto mb-2" />
|
||||||
|
<h4 className="font-bold text-sm text-slate-800 dark:text-white">Optimization Ready</h4>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">{bestSuggestion.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Button size="sm" variant="secondary" onClick={handleClose}>Cancel</Button>
|
||||||
|
<Button size="sm" onClick={handleApply} className="bg-indigo-600 hover:bg-indigo-500 text-white">Apply</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'applied' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<Sparkles className="w-8 h-8 text-indigo-500 mx-auto mb-2" />
|
||||||
|
<h4 className="font-bold text-sm text-slate-800 dark:text-white">Clean & Organized!</h4>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Button size="sm" variant="secondary" onClick={handleUndo}>Undo</Button>
|
||||||
|
<Button size="sm" onClick={handleClose} className="bg-slate-900 text-white dark:bg-white dark:text-slate-900">Done</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -3,194 +3,192 @@ import { Button } from './ui/Button';
|
||||||
import { Card } from './ui/Card';
|
import { Card } from './ui/Card';
|
||||||
import { useVisualOrganizer } from '../hooks/useVisualOrganizer';
|
import { useVisualOrganizer } from '../hooks/useVisualOrganizer';
|
||||||
import { useDiagramStore } from '../store';
|
import { useDiagramStore } from '../store';
|
||||||
|
import { Sparkles, Wand2, Layout, Scan, CheckCircle2, RotateCcw } from 'lucide-react';
|
||||||
import type { LayoutSuggestion } from '../types/visualOrganization';
|
import type { LayoutSuggestion } from '../types/visualOrganization';
|
||||||
|
|
||||||
export const VisualOrganizerPanel: React.FC = () => {
|
export const VisualOrganizerPanel: React.FC = () => {
|
||||||
const { analyzeLayout, generateSuggestions, applySuggestion, getPresets } = useVisualOrganizer();
|
const { analyzeLayout, generateSuggestions, applySuggestion } = useVisualOrganizer();
|
||||||
const { nodes, edges, setNodes, setEdges } = useDiagramStore(); // Needed for snapshotting
|
const { nodes, edges, setNodes, setEdges } = useDiagramStore();
|
||||||
const [suggestions, setSuggestions] = useState<LayoutSuggestion[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [analysis, setAnalysis] = useState<any>(null);
|
|
||||||
const [snapshotHistory, setSnapshotHistory] = useState<Array<{ id: string, timestamp: number, nodes: any[], edges: any[], name: string }>>([]);
|
|
||||||
const [previewState, setPreviewState] = useState<{
|
|
||||||
originalNodes: any[];
|
|
||||||
originalEdges: any[];
|
|
||||||
suggestionId: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const takeSnapshot = (name: string) => {
|
// UI States
|
||||||
setSnapshotHistory(prev => [
|
const [status, setStatus] = useState<'idle' | 'analyzing' | 'ready' | 'applied'>('idle');
|
||||||
{ id: Math.random().toString(36).substr(2, 9), timestamp: Date.now(), nodes: [...nodes], edges: [...edges], name },
|
const [bestSuggestion, setBestSuggestion] = useState<LayoutSuggestion | null>(null);
|
||||||
...prev.slice(0, 4) // Keep last 5
|
const [snapshot, setSnapshot] = useState<{ nodes: any[], edges: any[] } | null>(null);
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const restoreSnapshot = (snapshot: any) => {
|
// AI Organize Handler
|
||||||
setNodes(snapshot.nodes);
|
const handleAIOrganize = async () => {
|
||||||
setEdges(snapshot.edges);
|
setStatus('analyzing');
|
||||||
};
|
|
||||||
|
|
||||||
const handleAnalyze = () => {
|
// 1. Analyze (Simulate brief delay for effect)
|
||||||
const result = analyzeLayout();
|
analyzeLayout();
|
||||||
setAnalysis(result);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGenerateSuggestions = async () => {
|
// 2. Generate Suggestions
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const result = await generateSuggestions();
|
// Artificial delay for "Scanning" animation effect
|
||||||
setSuggestions(result);
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
|
const results = await generateSuggestions();
|
||||||
|
|
||||||
|
// Pick best suggestion (or default to first non-current)
|
||||||
|
if (results.length > 0) {
|
||||||
|
setBestSuggestion(results[0]);
|
||||||
|
setStatus('ready');
|
||||||
|
} else {
|
||||||
|
// Fallback if no suggestions
|
||||||
|
setStatus('idle');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to generate suggestions:', error);
|
console.error(error);
|
||||||
} finally {
|
setStatus('idle');
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePreviewSuggestion = (suggestion: LayoutSuggestion) => {
|
const handleApply = () => {
|
||||||
// Save current state
|
if (!bestSuggestion) return;
|
||||||
if (!previewState) {
|
|
||||||
setPreviewState({
|
// Take snapshot before applying
|
||||||
originalNodes: [...nodes],
|
setSnapshot({ nodes: [...nodes], edges: [...edges] });
|
||||||
originalEdges: [...edges],
|
|
||||||
suggestionId: suggestion.id
|
applySuggestion(bestSuggestion);
|
||||||
});
|
setStatus('applied');
|
||||||
}
|
|
||||||
// Apply suggestion
|
|
||||||
applySuggestion(suggestion);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirmPreview = (suggestion: LayoutSuggestion) => {
|
const handleUndo = () => {
|
||||||
takeSnapshot(`Before ${suggestion.title}`);
|
if (snapshot) {
|
||||||
setPreviewState(null);
|
setNodes(snapshot.nodes);
|
||||||
setSuggestions(suggestions.filter(s => s.id !== suggestion.id));
|
setEdges(snapshot.edges);
|
||||||
|
setSnapshot(null);
|
||||||
|
setStatus('ready');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelPreview = () => {
|
const handleReset = () => {
|
||||||
if (previewState) {
|
setStatus('idle');
|
||||||
setNodes(previewState.originalNodes);
|
setSnapshot(null);
|
||||||
setEdges(previewState.originalEdges);
|
setBestSuggestion(null);
|
||||||
setPreviewState(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="visual-organizer-panel">
|
<div className="visual-organizer-panel w-full">
|
||||||
<Card className="mb-4">
|
<Card className="p-0 overflow-hidden border-none shadow-none bg-transparent">
|
||||||
<h3 className="text-lg font-semibold mb-2">Visual Organizer</h3>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button onClick={handleAnalyze} variant="secondary">
|
|
||||||
Analyze Layout
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleGenerateSuggestions} disabled={isLoading}>
|
|
||||||
{isLoading ? 'Generating...' : 'Get Suggestions'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{analysis && (
|
{/* IDLE STATE: Main AI Button */}
|
||||||
<Card className="mb-4">
|
{status === 'idle' && (
|
||||||
<h4 className="font-medium mb-2">Layout Analysis</h4>
|
<div className="flex flex-col items-center justify-center p-8 text-center space-y-6">
|
||||||
<div className="text-sm">
|
<div className="relative group cursor-pointer" onClick={handleAIOrganize}>
|
||||||
<p>Nodes: {analysis.metrics.nodeCount}</p>
|
<div className="absolute inset-0 bg-blue-500 rounded-full blur-xl opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-pulse"></div>
|
||||||
<p>Edges: {analysis.metrics.edgeCount}</p>
|
<div className="relative w-24 h-24 bg-gradient-to-br from-blue-600 to-indigo-600 rounded-2xl flex items-center justify-center shadow-2xl transform group-hover:scale-105 transition-all duration-300 border border-white/20">
|
||||||
<p>Issues: {analysis.issues.length}</p>
|
<Sparkles className="w-10 h-10 text-white animate-pulse" />
|
||||||
<p>Strengths: {analysis.strengths.length}</p>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card className="mb-4">
|
|
||||||
<h4 className="font-medium mb-2">Quick Layout Presets</h4>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{getPresets().map(preset => (
|
|
||||||
<Button
|
|
||||||
key={preset.id}
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handlePreviewSuggestion(preset)}
|
|
||||||
className="h-auto flex flex-col items-start p-3 text-left"
|
|
||||||
>
|
|
||||||
<span className="font-bold text-xs mb-1">{preset.title}</span>
|
|
||||||
<span className="text-[10px] text-gray-500 font-normal leading-tight">{preset.description}</span>
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{suggestions.length > 0 && (
|
|
||||||
<Card>
|
|
||||||
<h4 className="font-medium mb-2">AI Suggestions</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{suggestions.map((suggestion) => {
|
|
||||||
const isPreviewing = previewState?.suggestionId === suggestion.id;
|
|
||||||
const isOtherPreviewing = previewState !== null && !isPreviewing;
|
|
||||||
|
|
||||||
if (isOtherPreviewing) return null; // Hide other suggestions while previewing
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={suggestion.id} className={`border rounded p-2 ${isPreviewing ? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20' : ''}`}>
|
|
||||||
<h5 className="font-medium">{suggestion.title}</h5>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">{suggestion.description}</p>
|
|
||||||
|
|
||||||
<div className="flex gap-2 mt-2">
|
|
||||||
{!isPreviewing ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handlePreviewSuggestion(suggestion)}
|
|
||||||
>
|
|
||||||
Preview
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => setSuggestions(suggestions.filter(s => s.id !== suggestion.id))}
|
|
||||||
>
|
|
||||||
Dismiss
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleConfirmPreview(suggestion)}
|
|
||||||
className="bg-green-600 hover:bg-green-700"
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="danger"
|
|
||||||
onClick={handleCancelPreview}
|
|
||||||
>
|
|
||||||
Revert
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{snapshotHistory.length > 0 && (
|
|
||||||
<Card>
|
|
||||||
<h4 className="font-medium mb-2">History & Comparison</h4>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{snapshotHistory.map(snap => (
|
|
||||||
<div key={snap.id} className="flex items-center justify-between text-xs p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded">
|
|
||||||
<span>{snap.name}</span>
|
|
||||||
<Button size="sm" variant="ghost" onClick={() => restoreSnapshot(snap)} className="h-6 px-2">
|
|
||||||
Restore
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="absolute -bottom-2 -right-2 bg-white dark:bg-slate-800 p-2 rounded-full shadow-lg border border-slate-100 dark:border-slate-700">
|
||||||
|
<Wand2 className="w-4 h-4 text-purple-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-xl font-display font-bold text-slate-800 dark:text-white">AI Visual Organizer</h3>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400 max-w-[200px] mx-auto leading-relaxed">
|
||||||
|
Click to instantly analyze and reorganize your flow for maximum clarity.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleAIOrganize}
|
||||||
|
className="bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-800 hover:dark:bg-slate-100 px-8 py-6 rounded-xl shadow-xl hover:shadow-2xl transition-all font-bold tracking-wide"
|
||||||
|
>
|
||||||
|
<Scan className="w-4 h-4 mr-2" />
|
||||||
|
Start Organization
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
)}
|
||||||
)}
|
|
||||||
|
{/* ANALYZING STATE: Scanning Animation */}
|
||||||
|
{status === 'analyzing' && (
|
||||||
|
<div className="flex flex-col items-center justify-center p-12 text-center space-y-6 animate-in fade-in zoom-in duration-300">
|
||||||
|
<div className="relative w-20 h-20">
|
||||||
|
<div className="absolute inset-0 border-4 border-blue-500/20 rounded-full"></div>
|
||||||
|
<div className="absolute inset-0 border-4 border-t-blue-500 rounded-full animate-spin"></div>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<Scan className="w-8 h-8 text-blue-500 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-blue-600 dark:text-blue-400 animate-pulse">
|
||||||
|
Analyzing Layout Logic...
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* READY STATE: Suggestion Found */}
|
||||||
|
{status === 'ready' && bestSuggestion && (
|
||||||
|
<div className="flex flex-col p-6 space-y-6 animate-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="flex items-center gap-3 text-green-600 dark:text-green-400 mb-2">
|
||||||
|
<CheckCircle2 className="w-6 h-6" />
|
||||||
|
<span className="font-bold text-lg">Optimization Found!</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-slate-50 dark:bg-white/5 rounded-xl border border-slate-200 dark:border-white/10">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<Layout className="w-5 h-5 text-indigo-500" />
|
||||||
|
<h4 className="font-bold">{bestSuggestion.title}</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400 leading-relaxed">
|
||||||
|
{bestSuggestion.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={handleReset}
|
||||||
|
variant="secondary"
|
||||||
|
className="h-12"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleApply}
|
||||||
|
className="h-12 bg-indigo-600 hover:bg-indigo-500 text-white shadow-lg hover:shadow-indigo-500/25"
|
||||||
|
>
|
||||||
|
<Wand2 className="w-4 h-4 mr-2" />
|
||||||
|
Apply Magic
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* APPLIED STATE: Success & Undo */}
|
||||||
|
{status === 'applied' && (
|
||||||
|
<div className="flex flex-col items-center justify-center p-8 text-center space-y-6 animate-in zoom-in duration-300">
|
||||||
|
<div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mb-2">
|
||||||
|
<CheckCircle2 className="w-8 h-8 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold text-slate-800 dark:text-white mb-2">Beautifully Organized!</h3>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
Your graph has been transformed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 w-full">
|
||||||
|
<Button
|
||||||
|
onClick={handleUndo}
|
||||||
|
variant="secondary"
|
||||||
|
className="flex-1 h-12 border-slate-200 dark:border-white/10 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/10 dark:hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4 mr-2" />
|
||||||
|
Undo
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleReset} // Goes back to idle
|
||||||
|
className="flex-1 h-12"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Edit3, Code, Download, Zap, Sun, Moon, Maximize2, Minimize2, Settings, Save,
|
Edit3, Code, Download, Zap, Sun, Moon, Maximize2, Minimize2, Settings, Save,
|
||||||
ChevronDown, FileCode, ImageIcon, FileText, Frame
|
ChevronDown, FileCode, ImageIcon, FileText, Frame, Cloud, Server, Cpu, RotateCcw, RotateCw
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useFlowStore } from '../../store';
|
import { useStore } from 'zustand';
|
||||||
|
import { useFlowStore, useDiagramStore } from '../../store';
|
||||||
|
import { useMobileDetect } from '../../hooks/useMobileDetect';
|
||||||
import {
|
import {
|
||||||
exportToPng, exportToJpg, exportToSvg,
|
exportToPng, exportToJpg, exportToSvg,
|
||||||
exportToTxt, downloadMermaid
|
exportToTxt, downloadMermaid, exportToJson
|
||||||
} from '../../lib/exportUtils';
|
} from '../../lib/exportUtils';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { SettingsModal } from '../Settings';
|
import { SettingsModal } from '../Settings';
|
||||||
|
|
@ -17,23 +19,30 @@ export function EditorHeader() {
|
||||||
rightPanelOpen, setRightPanelOpen,
|
rightPanelOpen, setRightPanelOpen,
|
||||||
theme, toggleTheme,
|
theme, toggleTheme,
|
||||||
focusMode, setFocusMode,
|
focusMode, setFocusMode,
|
||||||
saveDiagram
|
saveDiagram,
|
||||||
|
aiMode, onlineProvider
|
||||||
} = useFlowStore();
|
} = useFlowStore();
|
||||||
|
const { isMobile } = useMobileDetect();
|
||||||
|
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
|
||||||
const [showExportMenu, setShowExportMenu] = useState(false);
|
const [showExportMenu, setShowExportMenu] = useState(false);
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
setIsSaving(true);
|
|
||||||
const name = prompt('Enter diagram name:', `Diagram ${new Date().toLocaleDateString()}`);
|
const name = prompt('Enter diagram name:', `Diagram ${new Date().toLocaleDateString()}`);
|
||||||
if (name) {
|
if (name) {
|
||||||
|
setSaveStatus('saving');
|
||||||
saveDiagram(name);
|
saveDiagram(name);
|
||||||
|
|
||||||
|
// Show success state
|
||||||
|
setTimeout(() => {
|
||||||
|
setSaveStatus('saved');
|
||||||
|
setTimeout(() => setSaveStatus('idle'), 2000);
|
||||||
|
}, 600);
|
||||||
}
|
}
|
||||||
setTimeout(() => setIsSaving(false), 800);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExport = async (format: 'png' | 'jpg' | 'svg' | 'txt' | 'code') => {
|
const handleExport = async (format: 'png' | 'jpg' | 'svg' | 'txt' | 'code' | 'json') => {
|
||||||
setShowExportMenu(false);
|
setShowExportMenu(false);
|
||||||
const viewport = document.querySelector('.react-flow__viewport') as HTMLElement;
|
const viewport = document.querySelector('.react-flow__viewport') as HTMLElement;
|
||||||
|
|
||||||
|
|
@ -46,7 +55,7 @@ export function EditorHeader() {
|
||||||
if (viewport) await exportToJpg(viewport);
|
if (viewport) await exportToJpg(viewport);
|
||||||
break;
|
break;
|
||||||
case 'svg':
|
case 'svg':
|
||||||
exportToSvg(nodes, edges);
|
if (viewport) await exportToSvg(viewport);
|
||||||
break;
|
break;
|
||||||
case 'txt':
|
case 'txt':
|
||||||
exportToTxt(nodes, edges);
|
exportToTxt(nodes, edges);
|
||||||
|
|
@ -54,6 +63,9 @@ export function EditorHeader() {
|
||||||
case 'code':
|
case 'code':
|
||||||
downloadMermaid(nodes, edges);
|
downloadMermaid(nodes, edges);
|
||||||
break;
|
break;
|
||||||
|
case 'json':
|
||||||
|
exportToJson(nodes, edges);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Export failed:', error);
|
console.error('Export failed:', error);
|
||||||
|
|
@ -67,37 +79,86 @@ export function EditorHeader() {
|
||||||
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20 group-hover:scale-110 transition-all duration-500">
|
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20 group-hover:scale-110 transition-all duration-500">
|
||||||
<Zap className="w-3.5 h-3.5 text-white fill-white/20" />
|
<Zap className="w-3.5 h-3.5 text-white fill-white/20" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-black tracking-tight text-slate-800 dark:text-primary">SystemArchitect</span>
|
<span className="text-sm font-black tracking-tight text-slate-800 dark:text-primary hidden sm:inline">SystemArchitect</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
{/* Panel toggles - hidden on mobile */}
|
||||||
<div className="h-4 w-px bg-slate-200 dark:bg-white/10 mx-2"></div>
|
<div className="h-4 w-px bg-slate-200 dark:bg-white/10 mx-2"></div>
|
||||||
<div className="flex items-center gap-1.5 bg-slate-100 dark:bg-slate-800/50 p-1 rounded-xl border border-black/5 dark:border-white/5">
|
|
||||||
|
{/* History Controls */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => setLeftPanelOpen(!leftPanelOpen)}
|
onClick={() => useDiagramStore.temporal.getState().undo()}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all ${leftPanelOpen
|
disabled={!useStore(useDiagramStore.temporal, (state: any) => state.pastStates.length > 0)}
|
||||||
? 'bg-white dark:bg-blue-600/15 text-blue-600 dark:text-blue-500 shadow-sm dark:shadow-none border border-black/5 dark:border-blue-500/20'
|
className="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-black/5 dark:hover:bg-white/5 border border-transparent'}`}
|
title="Undo"
|
||||||
title="Toggle Input Panel"
|
|
||||||
>
|
>
|
||||||
<Edit3 className="w-3.5 h-3.5" />
|
<RotateCcw className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline">Input</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setRightPanelOpen(!rightPanelOpen)}
|
onClick={() => useDiagramStore.temporal.getState().redo()}
|
||||||
disabled={nodes.length === 0}
|
disabled={!useStore(useDiagramStore.temporal, (state: any) => state.futureStates.length > 0)}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all ${nodes.length === 0
|
className="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
? 'opacity-30 cursor-not-allowed text-slate-400 dark:text-slate-600'
|
title="Redo"
|
||||||
: (rightPanelOpen
|
|
||||||
? 'bg-white dark:bg-blue-600/15 text-blue-600 dark:text-blue-500 shadow-sm dark:shadow-none border border-black/5 dark:border-blue-500/20'
|
|
||||||
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-black/5 dark:hover:bg-white/5 border border-transparent')}`}
|
|
||||||
title="Toggle Code Panel"
|
|
||||||
>
|
>
|
||||||
<Code className="w-3.5 h-3.5" />
|
<RotateCw className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline">Code</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isMobile && (
|
||||||
|
<>
|
||||||
|
<div className="h-4 w-px bg-slate-200 dark:bg-white/10 mx-2"></div>
|
||||||
|
<div className="flex items-center gap-1.5 bg-slate-100 dark:bg-slate-800/50 p-1 rounded-xl border border-black/5 dark:border-white/5">
|
||||||
|
<button
|
||||||
|
onClick={() => setLeftPanelOpen(!leftPanelOpen)}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all ${leftPanelOpen
|
||||||
|
? 'bg-white dark:bg-blue-600/15 text-blue-600 dark:text-blue-500 shadow-sm dark:shadow-none border border-black/5 dark:border-blue-500/20'
|
||||||
|
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-black/5 dark:hover:bg-white/5 border border-transparent'}`}
|
||||||
|
title="Toggle Input Panel"
|
||||||
|
>
|
||||||
|
<Edit3 className="w-3.5 h-3.5" />
|
||||||
|
<span className="hidden sm:inline">Input</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setRightPanelOpen(!rightPanelOpen)}
|
||||||
|
disabled={nodes.length === 0}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all ${nodes.length === 0
|
||||||
|
? 'opacity-30 cursor-not-allowed text-slate-400 dark:text-slate-600'
|
||||||
|
: (rightPanelOpen
|
||||||
|
? 'bg-white dark:bg-blue-600/15 text-blue-600 dark:text-blue-500 shadow-sm dark:shadow-none border border-black/5 dark:border-blue-500/20'
|
||||||
|
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-black/5 dark:hover:bg-white/5 border border-transparent')}`}
|
||||||
|
title="Toggle Code Panel"
|
||||||
|
>
|
||||||
|
<Code className="w-3.5 h-3.5" />
|
||||||
|
<span className="hidden sm:inline">Code</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{/* AI Mode Status - Desktop Only */}
|
||||||
|
{!isMobile && (
|
||||||
|
<div className="hidden md:flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-white/5 mr-2">
|
||||||
|
{aiMode === 'offline' ? (
|
||||||
|
<>
|
||||||
|
<Server className="w-3 h-3 text-blue-500" />
|
||||||
|
<span className="text-[10px] font-bold text-slate-500 dark:text-slate-400">Local</span>
|
||||||
|
</>
|
||||||
|
) : aiMode === 'browser' ? (
|
||||||
|
<>
|
||||||
|
<Cpu className="w-3 h-3 text-purple-500" />
|
||||||
|
<span className="text-[10px] font-bold text-slate-500 dark:text-slate-400">Browser</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Cloud className="w-3 h-3 text-emerald-500" />
|
||||||
|
<span className="text-[10px] font-bold text-slate-500 dark:text-slate-400">Cloud</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setFocusMode(!focusMode)}
|
onClick={() => setFocusMode(!focusMode)}
|
||||||
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all ${focusMode ? 'text-blue-600 dark:text-blue-500 bg-blue-50 dark:bg-blue-500/10' : 'text-slate-500 dark:text-secondary hover:text-slate-800 dark:hover:text-primary hover:bg-black/5 dark:hover:bg-void'}`}
|
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all ${focusMode ? 'text-blue-600 dark:text-blue-500 bg-blue-50 dark:bg-blue-500/10' : 'text-slate-500 dark:text-secondary hover:text-slate-800 dark:hover:text-primary hover:bg-black/5 dark:hover:bg-void'}`}
|
||||||
|
|
@ -125,11 +186,14 @@ export function EditorHeader() {
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={nodes.length === 0 || isSaving}
|
disabled={nodes.length === 0 || saveStatus === 'saving'}
|
||||||
className="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-white dark:bg-surface border border-slate-200 dark:border-white/10 text-slate-600 dark:text-secondary hover:text-blue-600 dark:hover:text-blue-500 hover:border-blue-300 dark:hover:border-blue-500/30 text-[9px] font-black uppercase tracking-widest transition-all active:scale-95 disabled:opacity-20 disabled:cursor-not-allowed"
|
className="flex items-center gap-2 px-3 sm:px-4 py-1.5 rounded-lg bg-white dark:bg-surface border border-slate-200 dark:border-white/10 text-slate-600 dark:text-secondary hover:text-blue-600 dark:hover:text-blue-500 hover:border-blue-300 dark:hover:border-blue-500/30 text-[9px] font-black uppercase tracking-widest transition-all active:scale-95 disabled:opacity-20 disabled:cursor-not-allowed"
|
||||||
|
title="Save Draft"
|
||||||
>
|
>
|
||||||
<Save className={`w-3 h-3 ${isSaving ? 'animate-bounce' : ''}`} />
|
<Save className={`w-3.5 h-3.5 ${saveStatus === 'saving' ? 'animate-bounce' : ''} ${saveStatus === 'saved' ? 'text-green-500' : ''}`} />
|
||||||
<span>{isSaving ? 'Saving...' : 'Save Draft'}</span>
|
<span className={`hidden sm:inline ${saveStatus === 'saved' ? 'text-green-500' : ''}`}>
|
||||||
|
{saveStatus === 'saving' ? 'Saving...' : saveStatus === 'saved' ? 'Saved!' : 'Save'}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Export Dropdown */}
|
{/* Export Dropdown */}
|
||||||
|
|
@ -137,11 +201,12 @@ export function EditorHeader() {
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowExportMenu(!showExportMenu)}
|
onClick={() => setShowExportMenu(!showExportMenu)}
|
||||||
disabled={nodes.length === 0}
|
disabled={nodes.length === 0}
|
||||||
className={`flex items-center gap-2 px-4 py-1.5 rounded-lg text-[9px] font-black uppercase tracking-widest transition-all shadow-lg active:scale-95 disabled:opacity-20 disabled:cursor-not-allowed ${showExportMenu ? 'bg-blue-500 text-white' : 'bg-blue-600 hover:bg-blue-500 text-white'}`}
|
className={`flex items-center gap-2 px-3 sm:px-4 py-1.5 rounded-lg text-[9px] font-black uppercase tracking-widest transition-all shadow-lg active:scale-95 disabled:opacity-20 disabled:cursor-not-allowed ${showExportMenu ? 'bg-blue-500 text-white' : 'bg-blue-600 hover:bg-blue-500 text-white'}`}
|
||||||
|
title="Export"
|
||||||
>
|
>
|
||||||
<Download className="w-3 h-3" />
|
<Download className="w-3.5 h-3.5" />
|
||||||
<span>Export</span>
|
<span className="hidden sm:inline">Export</span>
|
||||||
<ChevronDown className={`w-3 h-3 transition-transform ${showExportMenu ? 'rotate-180' : ''}`} />
|
<ChevronDown className={`w-3 h-3 hidden sm:block transition-transform ${showExportMenu ? 'rotate-180' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showExportMenu && (
|
{showExportMenu && (
|
||||||
|
|
@ -150,7 +215,11 @@ export function EditorHeader() {
|
||||||
<div className="absolute top-full mt-2 right-0 w-48 floating-glass border titanium-border rounded-xl overflow-hidden z-[80] shadow-2xl animate-slide-up p-1">
|
<div className="absolute top-full mt-2 right-0 w-48 floating-glass border titanium-border rounded-xl overflow-hidden z-[80] shadow-2xl animate-slide-up p-1">
|
||||||
<button onClick={() => handleExport('code')} className="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-white/5 text-[10px] font-bold text-secondary transition-all group">
|
<button onClick={() => handleExport('code')} className="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-white/5 text-[10px] font-bold text-secondary transition-all group">
|
||||||
<FileCode className="w-3.5 h-3.5 text-blue-500" />
|
<FileCode className="w-3.5 h-3.5 text-blue-500" />
|
||||||
Mermaid Code
|
Interactive Code
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleExport('json')} className="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-white/5 text-[10px] font-bold text-secondary transition-all group">
|
||||||
|
<FileCode className="w-3.5 h-3.5 text-purple-500" />
|
||||||
|
JSON Data
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => handleExport('png')} className="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-white/5 text-[10px] font-bold text-secondary transition-all">
|
<button onClick={() => handleExport('png')} className="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-white/5 text-[10px] font-bold text-secondary transition-all">
|
||||||
<ImageIcon className="w-3.5 h-3.5 text-indigo-500" />
|
<ImageIcon className="w-3.5 h-3.5 text-indigo-500" />
|
||||||
|
|
|
||||||
61
src/components/editor/EditorToolbar.tsx
Normal file
61
src/components/editor/EditorToolbar.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { Loader2, Zap } from 'lucide-react';
|
||||||
|
|
||||||
|
interface EditorToolbarProps {
|
||||||
|
handleGenerate: () => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
hasCode: boolean;
|
||||||
|
hasCode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
import { usePluginStore } from '../../store/pluginStore';
|
||||||
|
|
||||||
|
export function EditorToolbar({
|
||||||
|
handleGenerate,
|
||||||
|
isLoading,
|
||||||
|
hasCode,
|
||||||
|
}: EditorToolbarProps) {
|
||||||
|
const { toolbarItems } = usePluginStore();
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between w-full pt-2 px-2">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* Center: Insert Tools */}
|
||||||
|
<div className="flex items-center gap-6 absolute left-1/2 -translate-x-1/2">
|
||||||
|
|
||||||
|
|
||||||
|
{/* Plugin Items */}
|
||||||
|
{toolbarItems.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={item.onClick}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex items-center gap-2 group px-2 py-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-white/5 transition-all disabled:opacity-50"
|
||||||
|
title={item.tooltip || item.label}
|
||||||
|
>
|
||||||
|
<item.icon className="w-4 h-4 text-slate-400 group-hover:text-blue-500 transition-colors" />
|
||||||
|
<span className="text-xs font-black uppercase tracking-widest text-slate-300 group-hover:text-slate-200 transition-colors">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Actions */}
|
||||||
|
<div className="flex items-center">
|
||||||
|
<button
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={!hasCode || isLoading}
|
||||||
|
className="px-6 py-2.5 rounded-xl bg-gradient-to-r from-[#8b5cf6] to-[#a78bfa] hover:from-[#7c3aed] hover:to-[#8b5cf6] text-white shadow-lg shadow-purple-900/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-white/90" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Zap className="w-4 h-4 fill-white" />
|
||||||
|
<span className="text-xs font-black uppercase tracking-widest">Visualize</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
src/components/editor/MonacoWrapper.tsx
Normal file
107
src/components/editor/MonacoWrapper.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
|
||||||
|
import Editor, { type OnMount } from '@monaco-editor/react';
|
||||||
|
import { AlertCircle, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface MonacoWrapperProps {
|
||||||
|
code: string;
|
||||||
|
onChange: (value: string | undefined) => void;
|
||||||
|
onMount: OnMount;
|
||||||
|
theme: 'light' | 'dark';
|
||||||
|
syntaxErrors: { line: number; message: string }[];
|
||||||
|
|
||||||
|
setCode: (code: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MonacoWrapper({
|
||||||
|
code,
|
||||||
|
onChange,
|
||||||
|
onMount,
|
||||||
|
theme,
|
||||||
|
syntaxErrors,
|
||||||
|
setCode
|
||||||
|
}: MonacoWrapperProps) {
|
||||||
|
// monacoRef removed as it was unused
|
||||||
|
|
||||||
|
// Define themes once on mount or when theme prop changes appropriately
|
||||||
|
// For simplicity, we can do it inside a useEffect here if we have access to monaco instance,
|
||||||
|
// but usually it's better to do it once.
|
||||||
|
// However, since we need monaco instance, we'll assume the parent component or this component handles it via onMount/useEffect.
|
||||||
|
|
||||||
|
// Actually, let's keep the theme definition inside the component for now or rely on the parent to pass the ref if needed.
|
||||||
|
// But better yet, let's expose specific theme logic or keep it self-contained if possible.
|
||||||
|
// The original code defined themes in useEffect when monacoRef was available.
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex-1 rounded-2xl overflow-hidden border relative group shadow-inner transition-colors ${theme === 'dark' ? 'bg-[#0B1221] border-white/5' : 'bg-slate-50 border-slate-200'
|
||||||
|
}`}>
|
||||||
|
{/* Internal Badges */}
|
||||||
|
<div className="absolute top-4 left-4 z-10 pointer-events-none">
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest px-3 py-1.5 rounded-lg bg-blue-500/10 text-blue-400 border border-blue-500/20 backdrop-blur-md">
|
||||||
|
Mermaid
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-4 right-4 z-10">
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest px-3 py-1.5 rounded-lg bg-white/5 text-slate-400 border border-white/10 backdrop-blur-md">
|
||||||
|
Manual
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Editor
|
||||||
|
height="100%"
|
||||||
|
defaultLanguage="markdown"
|
||||||
|
// theme prop is controlled by monaco.editor.setTheme
|
||||||
|
options={{
|
||||||
|
minimap: { enabled: false },
|
||||||
|
fontSize: 13,
|
||||||
|
lineNumbers: 'on',
|
||||||
|
padding: { top: 50, bottom: 20 },
|
||||||
|
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||||
|
fontLigatures: true,
|
||||||
|
renderLineHighlight: 'all',
|
||||||
|
scrollbar: {
|
||||||
|
vertical: 'visible',
|
||||||
|
horizontal: 'visible',
|
||||||
|
useShadows: false,
|
||||||
|
verticalSliderSize: 6,
|
||||||
|
horizontalSliderSize: 6
|
||||||
|
},
|
||||||
|
lineHeight: 1.7,
|
||||||
|
cursorSmoothCaretAnimation: 'on',
|
||||||
|
smoothScrolling: true,
|
||||||
|
contextmenu: false,
|
||||||
|
fixedOverflowWidgets: true,
|
||||||
|
wordWrap: 'on',
|
||||||
|
glyphMargin: true,
|
||||||
|
}}
|
||||||
|
value={code}
|
||||||
|
onChange={onChange}
|
||||||
|
onMount={onMount}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Floating Action Buttons */}
|
||||||
|
<div className="absolute top-4 right-24 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity z-20">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setCode('')}
|
||||||
|
className="p-1.5 rounded-lg bg-white/5 border border-white/10 flex items-center justify-center hover:bg-red-500/20 text-slate-400 hover:text-red-400 transition-all backdrop-blur-md shadow-lg"
|
||||||
|
title="Clear Code"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Syntax Errors */}
|
||||||
|
{syntaxErrors.length > 0 && (
|
||||||
|
<div className="absolute bottom-4 left-4 right-4 p-3 bg-red-500/10 border border-red-500/30 rounded-xl animate-fade-in">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 text-red-400 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-bold text-red-400 uppercase tracking-wider">Syntax Error</p>
|
||||||
|
<p className="text-[11px] text-red-300 mt-1">{syntaxErrors[0].message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@ import { Database, Cpu, Users, Globe, Server, Zap, Play, Square, GitBranch } fro
|
||||||
* High-contrast, accessible node color palette
|
* High-contrast, accessible node color palette
|
||||||
* Each color has a background, border, and text color for maximum readability
|
* Each color has a background, border, and text color for maximum readability
|
||||||
*/
|
*/
|
||||||
const NODE_STYLES = {
|
export const NODE_STYLES = {
|
||||||
ai: {
|
ai: {
|
||||||
bg: 'bg-violet-500/15 dark:bg-violet-500/20',
|
bg: 'bg-violet-500/15 dark:bg-violet-500/20',
|
||||||
solid: 'bg-violet-200 dark:bg-violet-900',
|
solid: 'bg-violet-200 dark:bg-violet-900',
|
||||||
|
|
@ -90,7 +90,7 @@ const NODE_STYLES = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
type NodeStyleKey = keyof typeof NODE_STYLES;
|
export type NodeStyleKey = keyof typeof NODE_STYLES;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine node style based on label content
|
* Determine node style based on label content
|
||||||
|
|
@ -415,6 +415,8 @@ export const EndNode = memo((props: any) => <TerminalNode {...props} />);
|
||||||
export const DecisionNode = memo((props: any) => <DecisionNodeComponent {...props} />);
|
export const DecisionNode = memo((props: any) => <DecisionNodeComponent {...props} />);
|
||||||
export const DatabaseNode = memo((props: any) => <DatabaseNodeComponent {...props} />);
|
export const DatabaseNode = memo((props: any) => <DatabaseNodeComponent {...props} />);
|
||||||
|
|
||||||
|
import { ShapeNode } from './ShapeNode';
|
||||||
|
|
||||||
export const nodeTypes = {
|
export const nodeTypes = {
|
||||||
start: StartNode,
|
start: StartNode,
|
||||||
startNode: StartNode,
|
startNode: StartNode,
|
||||||
|
|
@ -427,8 +429,20 @@ export const nodeTypes = {
|
||||||
process: StandardNode,
|
process: StandardNode,
|
||||||
processNode: StandardNode,
|
processNode: StandardNode,
|
||||||
client: StandardNode,
|
client: StandardNode,
|
||||||
|
|
||||||
|
// Rich Types (Implicitly styled by StandardNode logic)
|
||||||
|
ai: StandardNode,
|
||||||
|
team: StandardNode,
|
||||||
|
platform: StandardNode,
|
||||||
|
data: DatabaseNode, // Use Database component for Data type
|
||||||
|
tech: SystemNode, // Use System component for Tech type
|
||||||
|
|
||||||
server: SystemNode,
|
server: SystemNode,
|
||||||
system: SystemNode,
|
system: SystemNode,
|
||||||
default: StandardNode,
|
default: StandardNode,
|
||||||
group: GroupNode,
|
group: GroupNode,
|
||||||
|
|
||||||
|
// Custom Shape Node
|
||||||
|
'custom-shape': ShapeNode,
|
||||||
|
'shape': ShapeNode, // Alias
|
||||||
};
|
};
|
||||||
|
|
|
||||||
69
src/components/nodes/ShapeNode.tsx
Normal file
69
src/components/nodes/ShapeNode.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { memo } from 'react';
|
||||||
|
import { Handle, Position } from '@xyflow/react';
|
||||||
|
import { NODE_STYLES } from './CustomNodes';
|
||||||
|
import { SHAPE_PATHS } from '../../lib/shapes';
|
||||||
|
|
||||||
|
// Map mermaid types to shape names if needed, or use raw type
|
||||||
|
// Default fallback
|
||||||
|
const DEFAULT_SHAPE = 'rect';
|
||||||
|
|
||||||
|
export const ShapeNode = memo(({ data, selected, type }: any) => {
|
||||||
|
const rawShape = (data.shape || 'rect').toLowerCase();
|
||||||
|
|
||||||
|
// Normalize shape names
|
||||||
|
let shapeKey = rawShape;
|
||||||
|
if (rawShape === 'database') shapeKey = 'cyl';
|
||||||
|
if (rawShape === 'decision') shapeKey = 'diamond';
|
||||||
|
if (rawShape === 'process') shapeKey = 'rect';
|
||||||
|
if (rawShape === 'start') shapeKey = 'stadium';
|
||||||
|
if (rawShape === 'end') shapeKey = 'stadium';
|
||||||
|
|
||||||
|
const pathData = SHAPE_PATHS[shapeKey] || SHAPE_PATHS[DEFAULT_SHAPE];
|
||||||
|
|
||||||
|
// Style logic similar to StandardNode
|
||||||
|
// We can infer style from category or just use default
|
||||||
|
const category = data.category || 'default';
|
||||||
|
// Mapping categories to styles in CustomNodes is logic we might want to reuse or replicate
|
||||||
|
// For now let's use a default blue or check if we can import logic.
|
||||||
|
// Since we are in a separate file, we might not have access to helper 'getNodeStyle'.
|
||||||
|
// Let's rely on data.category which mermaidParser sets.
|
||||||
|
|
||||||
|
// Map parser categories to NODE_STYLES keys
|
||||||
|
let styleKey = 'default';
|
||||||
|
if (category === 'filter-db') styleKey = 'data';
|
||||||
|
else if (category === 'filter-client') styleKey = 'team'; // or platform
|
||||||
|
else if (category === 'filter-server') styleKey = 'tech';
|
||||||
|
|
||||||
|
// Override based on shape
|
||||||
|
if (shapeKey === 'cyl') styleKey = 'data';
|
||||||
|
if (shapeKey === 'diamond') styleKey = 'decision';
|
||||||
|
if (shapeKey === 'stadium' && (type === 'start' || type === 'end')) styleKey = type;
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const themeStyle = NODE_STYLES[styleKey] || NODE_STYLES.default;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative group w-32 h-20 flex items-center justify-center transition-all ${selected ? 'drop-shadow-lg scale-105' : ''}`}>
|
||||||
|
<svg viewBox="0 0 100 70" className={`w-full h-full overflow-visible drop-shadow-sm`}>
|
||||||
|
<path
|
||||||
|
d={pathData}
|
||||||
|
className={`${themeStyle.solid} ${themeStyle.border} fill-current stroke-2 stroke-current opacity-90`}
|
||||||
|
style={{ fill: 'var(--bg-color)', stroke: 'var(--border-color)' }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Label Overlay */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center p-2 text-center pointer-events-none">
|
||||||
|
<span className={`text-[10px] font-bold leading-tight ${themeStyle.text}`}>
|
||||||
|
{data.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Handles - Positions might need to be dynamic based on shape, but default Top/Bottom/Left/Right usually works */}
|
||||||
|
<Handle type="target" position={Position.Top} className="!w-2 !h-2 !bg-transparent" />
|
||||||
|
<Handle type="source" position={Position.Bottom} className="!w-2 !h-2 !bg-transparent" />
|
||||||
|
<Handle type="source" position={Position.Right} className="!w-2 !h-2 !bg-transparent" />
|
||||||
|
<Handle type="source" position={Position.Left} className="!w-2 !h-2 !bg-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export function OrchestratorLoader() {
|
export function OrchestratorLoader() {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,4 @@
|
||||||
|
|
||||||
export { useAIGeneration } from './useAIGeneration';
|
export { useAIGeneration } from './useAIGeneration';
|
||||||
export { useKeyboardShortcuts, getShortcutDisplay } from './useKeyboardShortcuts';
|
export { useKeyboardShortcuts, getShortcutDisplay } from './useKeyboardShortcuts';
|
||||||
|
export { useMobileDetect, checkIsMobile, MOBILE_BREAKPOINT } from './useMobileDetect';
|
||||||
|
|
|
||||||
|
|
@ -28,17 +28,17 @@ export function useAIGeneration(): UseAIGenerationReturn {
|
||||||
const { isLoading, error, setLoading, setError } = useUIStore();
|
const { isLoading, error, setLoading, setError } = useUIStore();
|
||||||
|
|
||||||
const processAIResponse = useCallback(
|
const processAIResponse = useCallback(
|
||||||
(result: AIResponse) => {
|
async (result: AIResponse) => {
|
||||||
if (!result.success || !result.mermaidCode) {
|
if (!result.success || !result.mermaidCode) {
|
||||||
throw new Error(result.error || 'Failed to generate diagram');
|
throw new Error(result.error || 'Failed to generate diagram');
|
||||||
}
|
}
|
||||||
|
|
||||||
setSourceCode(result.mermaidCode);
|
setSourceCode(result.mermaidCode);
|
||||||
const { nodes: parsedNodes, edges: parsedEdges } = parseMermaid(result.mermaidCode);
|
const { nodes: parsedNodes, edges: parsedEdges } = await parseMermaid(result.mermaidCode);
|
||||||
|
|
||||||
// Attach metadata if available
|
// Attach metadata if available
|
||||||
if (result.metadata) {
|
if (result.metadata) {
|
||||||
parsedNodes.forEach((node) => {
|
parsedNodes.forEach((node: any) => {
|
||||||
const label = (node.data.label as string) || '';
|
const label = (node.data.label as string) || '';
|
||||||
if (label && result.metadata && result.metadata[label]) {
|
if (label && result.metadata && result.metadata[label]) {
|
||||||
node.data.metadata = result.metadata[label] as NodeMetadata;
|
node.data.metadata = result.metadata[label] as NodeMetadata;
|
||||||
|
|
|
||||||
70
src/hooks/useDiagramAPI.ts
Normal file
70
src/hooks/useDiagramAPI.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useFlowStore } from '../store';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useDiagramAPI
|
||||||
|
*
|
||||||
|
* Enables programmatic control of the diagram editor via:
|
||||||
|
* 1. URL Parameters: ?code=<base64_mermaid>
|
||||||
|
* 2. Window Messages: PostMessage for embedding scenarios
|
||||||
|
*/
|
||||||
|
export function useDiagramAPI() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const { setSourceCode, sourceCode } = useFlowStore();
|
||||||
|
|
||||||
|
// Create a ref for sourceCode to access current value in event listener without re-binding
|
||||||
|
const sourceCodeRef = useRef(sourceCode);
|
||||||
|
useEffect(() => {
|
||||||
|
sourceCodeRef.current = sourceCode;
|
||||||
|
}, [sourceCode]);
|
||||||
|
|
||||||
|
// 1. Handle URL Parameters
|
||||||
|
useEffect(() => {
|
||||||
|
const codeParam = searchParams.get('code');
|
||||||
|
if (codeParam) {
|
||||||
|
try {
|
||||||
|
// Decode Base64
|
||||||
|
const decodedCode = atob(codeParam);
|
||||||
|
setSourceCode(decodedCode);
|
||||||
|
|
||||||
|
// Clear param after consumption to avoid re-triggering or cluttering URL
|
||||||
|
// causing a reload? No, using router API.
|
||||||
|
// But better to keep it if user wants to share URL.
|
||||||
|
// Let's decided to keep it for "stateful URL" behavior for now.
|
||||||
|
// If we want to support "cleaning", we can uncomment below:
|
||||||
|
// setSearchParams(prev => {
|
||||||
|
// const next = new URLSearchParams(prev);
|
||||||
|
// next.delete('code');
|
||||||
|
// return next;
|
||||||
|
// });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to decode diagram code from URL:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [searchParams, setSourceCode]);
|
||||||
|
|
||||||
|
// 2. Handle Window Messages (for generic iframe control)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMessage = (event: MessageEvent) => {
|
||||||
|
// Security check: You might want to check event.origin here in production
|
||||||
|
|
||||||
|
const { type, payload } = event.data;
|
||||||
|
|
||||||
|
if (type === 'KVGRAPH_LOAD' && payload?.code) {
|
||||||
|
setSourceCode(payload.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'KVGRAPH_GET_CODE') {
|
||||||
|
// Reply with current code
|
||||||
|
event.source?.postMessage({
|
||||||
|
type: 'KVGRAPH_CODE_RESPONSE',
|
||||||
|
payload: { code: sourceCodeRef.current }
|
||||||
|
}, { targetOrigin: event.origin });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('message', handleMessage);
|
||||||
|
return () => window.removeEventListener('message', handleMessage);
|
||||||
|
}, [setSourceCode]);
|
||||||
|
}
|
||||||
88
src/hooks/useMobileDetect.ts
Normal file
88
src/hooks/useMobileDetect.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
|
||||||
|
interface MobileDetectResult {
|
||||||
|
isMobile: boolean;
|
||||||
|
isTouchDevice: boolean;
|
||||||
|
prefersReducedMotion: boolean;
|
||||||
|
viewportWidth: number;
|
||||||
|
viewportHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for detecting mobile devices and touch capabilities.
|
||||||
|
* Provides reactive updates on viewport changes.
|
||||||
|
*/
|
||||||
|
export function useMobileDetect(): MobileDetectResult {
|
||||||
|
const [state, setState] = useState<MobileDetectResult>(() => ({
|
||||||
|
isMobile: typeof window !== 'undefined' ? window.innerWidth < MOBILE_BREAKPOINT : false,
|
||||||
|
isTouchDevice: typeof window !== 'undefined' ? 'ontouchstart' in window || navigator.maxTouchPoints > 0 : false,
|
||||||
|
prefersReducedMotion: typeof window !== 'undefined'
|
||||||
|
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
: false,
|
||||||
|
viewportWidth: typeof window !== 'undefined' ? window.innerWidth : 1024,
|
||||||
|
viewportHeight: typeof window !== 'undefined' ? window.innerHeight : 768,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleResize = useCallback(() => {
|
||||||
|
setState(prev => {
|
||||||
|
const newIsMobile = window.innerWidth < MOBILE_BREAKPOINT;
|
||||||
|
// Only update if values changed to prevent unnecessary re-renders
|
||||||
|
if (
|
||||||
|
prev.isMobile === newIsMobile &&
|
||||||
|
prev.viewportWidth === window.innerWidth &&
|
||||||
|
prev.viewportHeight === window.innerHeight
|
||||||
|
) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
isMobile: newIsMobile,
|
||||||
|
viewportWidth: window.innerWidth,
|
||||||
|
viewportHeight: window.innerHeight,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Use visualViewport for better mobile keyboard handling
|
||||||
|
const viewport = window.visualViewport;
|
||||||
|
|
||||||
|
const handleViewportResize = () => {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
viewportHeight: viewport?.height || window.innerHeight,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
viewport?.addEventListener('resize', handleViewportResize);
|
||||||
|
|
||||||
|
// Check reduced motion preference changes
|
||||||
|
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
|
const handleMotionChange = (e: MediaQueryListEvent) => {
|
||||||
|
setState(prev => ({ ...prev, prefersReducedMotion: e.matches }));
|
||||||
|
};
|
||||||
|
motionQuery.addEventListener('change', handleMotionChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
viewport?.removeEventListener('resize', handleViewportResize);
|
||||||
|
motionQuery.removeEventListener('change', handleMotionChange);
|
||||||
|
};
|
||||||
|
}, [handleResize]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function for one-time mobile check (non-reactive).
|
||||||
|
* Use in store initialization or outside React components.
|
||||||
|
*/
|
||||||
|
export function checkIsMobile(): boolean {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
return window.innerWidth < MOBILE_BREAKPOINT || 'ontouchstart' in window;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { MOBILE_BREAKPOINT };
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { useDiagramStore } from '../store';
|
import { useDiagramStore } from '../store';
|
||||||
import { useSettingsStore } from '../store/settingsStore';
|
import { useSettingsStore } from '../store/settingsStore';
|
||||||
import { VisualOrganizer, createVisualOrganizer } from '../lib/visualOrganizer';
|
import { createVisualOrganizer } from '../lib/visualOrganizer';
|
||||||
import { analyzeVisualLayout } from '../lib/aiService';
|
import { analyzeVisualLayout } from '../lib/aiService';
|
||||||
import type { LayoutSuggestion, VisualIssue, LayoutMetrics } from '../types/visualOrganization';
|
import type { LayoutSuggestion, VisualIssue, LayoutMetrics } from '../types/visualOrganization';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,40 +14,13 @@ export interface AIResponse {
|
||||||
|
|
||||||
import { webLlmService } from './webLlmService';
|
import { webLlmService } from './webLlmService';
|
||||||
import { visionService } from './visionService';
|
import { visionService } from './visionService';
|
||||||
|
import {
|
||||||
const SYSTEM_PROMPT = `You are an expert System Architect AI. Your task is to transform technical descriptions into precise Mermaid.js diagrams and high-fidelity metadata.
|
SYSTEM_PROMPT,
|
||||||
|
SYSTEM_PROMPT_SIMPLE,
|
||||||
Return ONLY a strictly valid JSON object. No Markdown headers, no preamble.
|
SYSTEM_PROMPT_COMPLEX,
|
||||||
|
SUGGEST_PROMPT,
|
||||||
EXPECTED JSON FORMAT:
|
VISUAL_ANALYSIS_PROMPT
|
||||||
{
|
} from './prompts';
|
||||||
"mermaidCode": "flowchart TD\\n A[Client] --> B[API Server]\\n ...",
|
|
||||||
"metadata": {
|
|
||||||
"NodeLabel": {
|
|
||||||
"techStack": ["Technologies used"],
|
|
||||||
"role": "Specific role (e.g., Load Balancer, Cache, Database)",
|
|
||||||
"description": "Concise technical responsibility"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ARCHITECTURAL RULES:
|
|
||||||
1. Use 'flowchart TD' or 'flowchart LR'.
|
|
||||||
2. Use descriptive but concise ID/Labels (e.g., 'API', 'DB_PROD').
|
|
||||||
3. Labels must match the keys in the 'metadata' object EXACTLY.
|
|
||||||
4. If input is already Mermaid code, wrap it in the JSON 'mermaidCode' field and infer metadata.
|
|
||||||
5. Identify semantic roles: use keywords like 'Client', 'Server', 'Worker', 'Database', 'Queue' in labels.
|
|
||||||
6. Escape double quotes inside the mermaid string correctly.
|
|
||||||
|
|
||||||
DIAGRAM QUALITY RULES:
|
|
||||||
7. NEVER use HTML tags (like <br/>, <b>, etc.) in node labels. Use short, clean text only.
|
|
||||||
8. Use DIAMOND shapes {Decision} for review, approval, or decision steps (e.g., A{Approve?}).
|
|
||||||
9. Use CYLINDER shapes [(Database)] for data stores.
|
|
||||||
10. Use ROUNDED shapes (Process) for human/manual tasks.
|
|
||||||
11. For complex workflows, add step numbers as edge labels: A -->|1| B -->|2| C.
|
|
||||||
12. Include feedback loops where logical (e.g., connect "Collect Feedback" back to analysis nodes).
|
|
||||||
13. Use subgraphs/swimlanes to group related components by team, role, or domain.
|
|
||||||
14. Ensure consistent node shapes within each swimlane/subgraph.`;
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -92,20 +65,54 @@ async function callOnlineAI(
|
||||||
if (provider === 'openai') {
|
if (provider === 'openai') {
|
||||||
url = 'https://api.openai.com/v1/chat/completions';
|
url = 'https://api.openai.com/v1/chat/completions';
|
||||||
headers['Authorization'] = `Bearer ${apiKey}`;
|
headers['Authorization'] = `Bearer ${apiKey}`;
|
||||||
|
|
||||||
|
// Transform messages to OpenAI format with image support
|
||||||
|
const formattedMessages = messages.map(msg => {
|
||||||
|
if (msg.images && msg.images.length > 0) {
|
||||||
|
// Vision message with image
|
||||||
|
return {
|
||||||
|
role: msg.role,
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: msg.content },
|
||||||
|
...msg.images.map((img: string) => ({
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: { url: `data:image/png;base64,${img}` }
|
||||||
|
}))
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { role: msg.role, content: msg.content };
|
||||||
|
});
|
||||||
|
|
||||||
body = {
|
body = {
|
||||||
model: 'gpt-4o',
|
model: 'gpt-4o',
|
||||||
messages: [{ role: 'system', content: activePrompt }, ...messages],
|
messages: [{ role: 'system', content: activePrompt }, ...formattedMessages],
|
||||||
response_format: { type: 'json_object' }
|
response_format: { type: 'json_object' }
|
||||||
};
|
};
|
||||||
} else if (provider === 'gemini') {
|
} else if (provider === 'gemini') {
|
||||||
// Simple Gemini API call (v1beta)
|
// Gemini API with vision support - using gemini-2.0-flash-exp for better compatibility
|
||||||
url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${apiKey}`;
|
url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key=${apiKey}`;
|
||||||
|
|
||||||
|
// Build parts array - text first, then images
|
||||||
|
const parts: any[] = [
|
||||||
|
{ text: `${activePrompt}\n\nTask: ${messages[messages.length - 1].content}` }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add images if present
|
||||||
|
const lastMsg = messages[messages.length - 1];
|
||||||
|
if (lastMsg.images && lastMsg.images.length > 0) {
|
||||||
|
lastMsg.images.forEach((imageBase64: string) => {
|
||||||
|
parts.push({
|
||||||
|
inline_data: {
|
||||||
|
mime_type: 'image/png',
|
||||||
|
data: imageBase64
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
body = {
|
body = {
|
||||||
contents: [{
|
contents: [{ parts }],
|
||||||
parts: [{
|
|
||||||
text: `${activePrompt}\n\nTask: ${messages[messages.length - 1].content}`
|
|
||||||
}]
|
|
||||||
}],
|
|
||||||
generationConfig: { responseMimeType: 'application/json' }
|
generationConfig: { responseMimeType: 'application/json' }
|
||||||
};
|
};
|
||||||
} else if (provider === 'ollama-cloud') {
|
} else if (provider === 'ollama-cloud') {
|
||||||
|
|
@ -127,7 +134,14 @@ async function callOnlineAI(
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let errorMsg = `${provider} error: ${response.status} ${response.statusText}`;
|
let errorMsg = `${provider} error: ${response.status} ${response.statusText}`;
|
||||||
if (response.status === 401) errorMsg = 'Invalid API Key. Please check your settings.';
|
if (response.status === 401) errorMsg = 'Invalid API Key. Please check your settings.';
|
||||||
|
if (response.status === 404) errorMsg = `API endpoint not found. For ${provider}, please verify your API key is valid.`;
|
||||||
if (response.status === 429) errorMsg = 'Rate limit exceeded. Please try again later.';
|
if (response.status === 429) errorMsg = 'Rate limit exceeded. Please try again later.';
|
||||||
|
if (response.status === 400) {
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
errorMsg = errorData.error?.message || errorMsg;
|
||||||
|
} catch { /* ignore parse error */ }
|
||||||
|
}
|
||||||
throw new Error(errorMsg);
|
throw new Error(errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -292,22 +306,6 @@ async function callBrowserAI(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const SYSTEM_PROMPT_SIMPLE = `You are a System Architect. Create a SIMPLE, high-level Mermaid diagram.
|
|
||||||
- Focus on the main data flow (max 5-7 nodes).
|
|
||||||
- Use simple labels (e.g., "User", "API", "DB"). NEVER use HTML tags like <br/>.
|
|
||||||
- Use diamond {Decision?} for approval/review steps.
|
|
||||||
- Minimal metadata (role only).`;
|
|
||||||
|
|
||||||
const SYSTEM_PROMPT_COMPLEX = `You are an Expert Solution Architect. Create a DETAILED, comprehensive Mermaid diagram.
|
|
||||||
- Include all subsystems, queues, workers, and external services.
|
|
||||||
- Use swimlanes (subgraphs) to group components by role/domain.
|
|
||||||
- NEVER use HTML tags like <br/> in labels. Keep labels clean and concise.
|
|
||||||
- Use diamond {Decision?} for approval/review steps.
|
|
||||||
- Use cylinder [(DB)] for databases, rounded (Task) for human tasks.
|
|
||||||
- Add step numbers as edge labels for complex flows: A -->|1| B -->|2| C.
|
|
||||||
- Include feedback loops connecting outputs back to inputs where logical.
|
|
||||||
- Detailed metadata (techStack, role, description).`;
|
|
||||||
|
|
||||||
function getSystemPrompt(complexity: 'simple' | 'complex') {
|
function getSystemPrompt(complexity: 'simple' | 'complex') {
|
||||||
return complexity === 'simple' ? SYSTEM_PROMPT_SIMPLE : SYSTEM_PROMPT_COMPLEX;
|
return complexity === 'simple' ? SYSTEM_PROMPT_SIMPLE : SYSTEM_PROMPT_COMPLEX;
|
||||||
}
|
}
|
||||||
|
|
@ -386,74 +384,6 @@ export async function analyzeSVG(
|
||||||
return callLocalAI(ollamaUrl, model, messages);
|
return callLocalAI(ollamaUrl, model, messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
const SUGGEST_PROMPT = `You are a Mermaid.js syntax and logic expert.
|
|
||||||
Your task is to analyze the provided Mermaid flowchart code and either:
|
|
||||||
1. Fix any syntax errors that prevent it from rendering.
|
|
||||||
2. Improve the logical flow or visual clarity if the syntax is already correct.
|
|
||||||
|
|
||||||
Return ONLY a strictly valid JSON object. No Markdown headers, no preamble.
|
|
||||||
|
|
||||||
EXPECTED JSON FORMAT:
|
|
||||||
{
|
|
||||||
"mermaidCode": "flowchart TD\\n A[Fixed/Improved] --> B[Nodes]",
|
|
||||||
"explanation": "Briefly explain what was fixed or improved."
|
|
||||||
}
|
|
||||||
|
|
||||||
RULES:
|
|
||||||
- Maintain the original intent of the diagram.
|
|
||||||
- Use best practices for Mermaid layout and labeling.
|
|
||||||
- If the code is already perfect, return it as is but provide a positive explanation.`;
|
|
||||||
|
|
||||||
const VISUAL_ANALYSIS_PROMPT = `You are a Visualization and UX Expert specialized in node-graph diagrams.
|
|
||||||
Your task is to analyze the provided graph structure and metrics to suggest specific improvements for layout, grouping, and visual clarity.
|
|
||||||
|
|
||||||
Return ONLY a strictly valid JSON object. Do not include markdown formatting like \`\`\`json.
|
|
||||||
|
|
||||||
EXPECTED JSON FORMAT:
|
|
||||||
{
|
|
||||||
"analysis": {
|
|
||||||
"suggestions": [
|
|
||||||
{
|
|
||||||
"id": "unique-id",
|
|
||||||
"title": "Short title",
|
|
||||||
"description": "Detailed explanation",
|
|
||||||
"type": "spacing" | "grouping" | "routing" | "hierarchy" | "style",
|
|
||||||
"impact": "high" | "medium" | "low",
|
|
||||||
"fix_strategy": "algorithmic_spacing" | "algorithmic_routing" | "group_nodes" | "unknown"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"summary": {
|
|
||||||
"critique": "Overall analysis",
|
|
||||||
"score": 0-100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
EXAMPLE RESPONSE:
|
|
||||||
{
|
|
||||||
"analysis": {
|
|
||||||
"suggestions": [
|
|
||||||
{
|
|
||||||
"id": "sug-1",
|
|
||||||
"title": "Group Database Nodes",
|
|
||||||
"description": "Several database nodes are scattered. Group them for better logical separation.",
|
|
||||||
"type": "grouping",
|
|
||||||
"impact": "high",
|
|
||||||
"fix_strategy": "group_nodes"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"summary": {
|
|
||||||
"critique": "The flow is generally good but lacks logical grouping for backend services.",
|
|
||||||
"score": 75
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RULES:
|
|
||||||
- Focus on readability, flow, and logical grouping.
|
|
||||||
- Identify if nodes are too cluttered or if the flow is confusing.
|
|
||||||
- Suggest grouping for nodes that appear related based on their labels.`;
|
|
||||||
|
|
||||||
export async function suggestFix(
|
export async function suggestFix(
|
||||||
code: string,
|
code: string,
|
||||||
ollamaUrl: string,
|
ollamaUrl: string,
|
||||||
|
|
@ -541,3 +471,49 @@ export async function analyzeVisualLayout(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fix and Enhance Mermaid Diagram
|
||||||
|
* Adds metadata, fixes syntax, and groups nodes
|
||||||
|
*/
|
||||||
|
export async function fixDiagram(code: string, apiKey: string, errorMessage?: string): Promise<string> {
|
||||||
|
const prompt = `
|
||||||
|
You are an expert Mermaid.js Diagram Engineer.
|
||||||
|
|
||||||
|
YOUR TASK:
|
||||||
|
Fix and Enhance the following Mermaid code.
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
1. Fix any syntax errors.
|
||||||
|
2. Ensure all nodes are semantically grouped into subgraphs (e.g. "subgraph Client", "subgraph Server", "subgraph Database") if possible.
|
||||||
|
3. IMPORTANT: Generates JSON metadata for each node in comments.
|
||||||
|
Format: %% { "id": "NODE_ID", "metadata": { "role": "Specific Role", "techStack": ["Tech1", "Tech2"], "description": "Brief description" } }
|
||||||
|
4. Return ONLY the mermaid code. No markdown fences.
|
||||||
|
|
||||||
|
CODE TO FIX:
|
||||||
|
${code}
|
||||||
|
|
||||||
|
${errorMessage ? `ERROR TO FIX:\n${errorMessage}` : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=' + apiKey, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
contents: [{ parts: [{ text: prompt }] }]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
let result = data.candidates?.[0]?.content?.parts?.[0]?.text || code;
|
||||||
|
|
||||||
|
// Clean result
|
||||||
|
result = result.replace(/^```(?:mermaid)?/gm, '').replace(/```$/gm, '').trim();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('AI Fix Failed:', error);
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { toPng } from 'html-to-image';
|
import { toPng, toJpeg, toSvg } from 'html-to-image';
|
||||||
import { type Node, type Edge } from '../store';
|
import { type Node, type Edge } from '../store';
|
||||||
|
|
||||||
export async function exportToPng(element: HTMLElement): Promise<void> {
|
export async function exportToPng(element: HTMLElement): Promise<void> {
|
||||||
|
|
@ -24,7 +24,7 @@ export async function exportToPng(element: HTMLElement): Promise<void> {
|
||||||
|
|
||||||
export async function exportToJpg(element: HTMLElement): Promise<void> {
|
export async function exportToJpg(element: HTMLElement): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { toJpeg } = await import('html-to-image');
|
|
||||||
const dataUrl = await toJpeg(element, {
|
const dataUrl = await toJpeg(element, {
|
||||||
backgroundColor: '#020617',
|
backgroundColor: '#020617',
|
||||||
quality: 0.95,
|
quality: 0.95,
|
||||||
|
|
@ -44,23 +44,23 @@ export async function exportToJpg(element: HTMLElement): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function exportToSvg(nodes: Node[], _edges: Edge[]): void {
|
export async function exportToSvg(element: HTMLElement): Promise<void> {
|
||||||
// Basic SVG export logic (simplified for React Flow)
|
try {
|
||||||
const svgContent = `
|
const dataUrl = await toSvg(element, {
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="800" viewBox="0 0 1200 800">
|
backgroundColor: '#020617',
|
||||||
<rect width="100%" height="100%" fill="#020617" />
|
filter: (node) => {
|
||||||
<text x="20" y="40" fill="white" font-family="sans-serif" font-size="16">Architecture Diagram Export (SVG)</text>
|
const className = node.className?.toString() || '';
|
||||||
<!-- Simplified representation -->
|
return !className.includes('react-flow__controls') &&
|
||||||
<g transform="translate(50,100)">
|
!className.includes('react-flow__minimap') &&
|
||||||
${nodes.map(n => `<rect x="${n.position.x}" y="${n.position.y}" width="150" height="60" rx="8" fill="#1e293b" stroke="#3b82f6" stroke-width="2" />`).join('')}
|
!className.includes('react-flow__panel');
|
||||||
${nodes.map(n => `<text x="${n.position.x + 75}" y="${n.position.y + 35}" fill="white" font-size="12" text-anchor="middle" font-family="sans-serif">${(n.data as any).label || n.id}</text>`).join('')}
|
}
|
||||||
</g>
|
});
|
||||||
</svg>
|
|
||||||
`;
|
downloadFile(dataUrl, `diagram-${getTimestamp()}.svg`);
|
||||||
const blob = new Blob([svgContent], { type: 'image/svg+xml' });
|
} catch (error) {
|
||||||
const url = URL.createObjectURL(blob);
|
console.error('Failed to export SVG:', error);
|
||||||
downloadFile(url, `diagram-${getTimestamp()}.svg`);
|
throw error;
|
||||||
URL.revokeObjectURL(url);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function exportToTxt(nodes: Node[], edges: Edge[]): void {
|
export function exportToTxt(nodes: Node[], edges: Edge[]): void {
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,28 @@
|
||||||
import dagre from 'dagre';
|
import dagre from 'dagre';
|
||||||
import { type Node, type Edge } from '../store';
|
import { type Node, type Edge } from '../store';
|
||||||
|
|
||||||
const nodeWidth = 180;
|
// Enhanced constants for better spacing
|
||||||
const nodeHeight = 60;
|
const NODE_WIDTH = 180;
|
||||||
const groupPadding = 40;
|
const NODE_HEIGHT = 60;
|
||||||
const groupTitleHeight = 50;
|
const GROUP_PADDING = 60; // Increased padding
|
||||||
const groupGap = 60; // Gap between swimlane groups
|
const GROUP_TITLE_HEIGHT = 50;
|
||||||
|
const GROUP_GAP = 120; // Increased gap between groups
|
||||||
|
const MIN_NODE_SPACING = 80; // Minimum space between nodes
|
||||||
|
|
||||||
export interface LayoutOptions {
|
export interface LayoutOptions {
|
||||||
direction: 'TB' | 'LR' | 'BT' | 'RL';
|
direction: 'TB' | 'LR' | 'BT' | 'RL';
|
||||||
nodeSpacing: number;
|
nodeSpacing: number;
|
||||||
rankSpacing: number;
|
rankSpacing: number;
|
||||||
|
smartOverlapResolution?: boolean; // Enable collision detection
|
||||||
|
optimizeForReadability?: boolean; // Prioritize clear flow
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultOptions: LayoutOptions = {
|
const defaultOptions: LayoutOptions = {
|
||||||
direction: 'TB',
|
direction: 'TB',
|
||||||
nodeSpacing: 40,
|
nodeSpacing: 100, // Increased from 60 to prevent overlap
|
||||||
rankSpacing: 60,
|
rankSpacing: 150, // Increased from 80 for edge labels
|
||||||
|
smartOverlapResolution: true,
|
||||||
|
optimizeForReadability: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getLayoutedElements(
|
export function getLayoutedElements(
|
||||||
|
|
@ -85,7 +91,7 @@ export function getLayoutedElements(
|
||||||
childNodes.forEach(child => finalNodes.push(child));
|
childNodes.forEach(child => finalNodes.push(child));
|
||||||
|
|
||||||
// Move Y down for next group
|
// Move Y down for next group
|
||||||
currentY += height + groupGap;
|
currentY += height + GROUP_GAP;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Layout orphan nodes (nodes without parent) to the right of groups
|
// Layout orphan nodes (nodes without parent) to the right of groups
|
||||||
|
|
@ -130,8 +136,8 @@ function layoutGroupInternal(
|
||||||
|
|
||||||
// Add nodes
|
// Add nodes
|
||||||
childNodes.forEach(node => {
|
childNodes.forEach(node => {
|
||||||
const w = node.type === 'decision' ? 140 : nodeWidth;
|
const w = node.type === 'decision' ? 140 : NODE_WIDTH;
|
||||||
const h = node.type === 'decision' ? 90 : nodeHeight;
|
const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
|
||||||
subGraph.setNode(node.id, { width: w, height: h });
|
subGraph.setNode(node.id, { width: w, height: h });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -154,8 +160,8 @@ function layoutGroupInternal(
|
||||||
const pos = subGraph.node(node.id);
|
const pos = subGraph.node(node.id);
|
||||||
if (!pos) return;
|
if (!pos) return;
|
||||||
|
|
||||||
const w = node.type === 'decision' ? 140 : nodeWidth;
|
const w = node.type === 'decision' ? 140 : NODE_WIDTH;
|
||||||
const h = node.type === 'decision' ? 90 : nodeHeight;
|
const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
|
||||||
const x = pos.x - w / 2;
|
const x = pos.x - w / 2;
|
||||||
const y = pos.y - h / 2;
|
const y = pos.y - h / 2;
|
||||||
|
|
||||||
|
|
@ -175,14 +181,14 @@ function layoutGroupInternal(
|
||||||
|
|
||||||
// Normalize positions to start at padding
|
// Normalize positions to start at padding
|
||||||
positionedChildren.forEach(child => {
|
positionedChildren.forEach(child => {
|
||||||
child.position.x = child.position.x - minX + groupPadding;
|
child.position.x = child.position.x - minX + GROUP_PADDING;
|
||||||
child.position.y = child.position.y - minY + groupPadding + groupTitleHeight;
|
child.position.y = child.position.y - minY + GROUP_PADDING + GROUP_TITLE_HEIGHT;
|
||||||
});
|
});
|
||||||
|
|
||||||
const contentWidth = maxX - minX;
|
const contentWidth = maxX - minX;
|
||||||
const contentHeight = maxY - minY;
|
const contentHeight = maxY - minY;
|
||||||
const groupWidth = contentWidth + groupPadding * 2;
|
const groupWidth = contentWidth + GROUP_PADDING * 2;
|
||||||
const groupHeight = contentHeight + groupPadding * 2 + groupTitleHeight;
|
const groupHeight = contentHeight + GROUP_PADDING * 2 + GROUP_TITLE_HEIGHT;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
width: Math.max(groupWidth, 300),
|
width: Math.max(groupWidth, 300),
|
||||||
|
|
@ -211,8 +217,8 @@ function layoutOrphanNodes(
|
||||||
});
|
});
|
||||||
|
|
||||||
nodes.forEach(node => {
|
nodes.forEach(node => {
|
||||||
const w = node.type === 'decision' ? 140 : nodeWidth;
|
const w = node.type === 'decision' ? 140 : NODE_WIDTH;
|
||||||
const h = node.type === 'decision' ? 90 : nodeHeight;
|
const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
|
||||||
orphanGraph.setNode(node.id, { width: w, height: h });
|
orphanGraph.setNode(node.id, { width: w, height: h });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -231,8 +237,8 @@ function layoutOrphanNodes(
|
||||||
const pos = orphanGraph.node(node.id);
|
const pos = orphanGraph.node(node.id);
|
||||||
if (!pos) return node;
|
if (!pos) return node;
|
||||||
|
|
||||||
const w = node.type === 'decision' ? 140 : nodeWidth;
|
const w = node.type === 'decision' ? 140 : NODE_WIDTH;
|
||||||
const h = node.type === 'decision' ? 90 : nodeHeight;
|
const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...node,
|
...node,
|
||||||
|
|
@ -261,8 +267,8 @@ function layoutFlatNodes(
|
||||||
});
|
});
|
||||||
|
|
||||||
nodes.forEach(node => {
|
nodes.forEach(node => {
|
||||||
const w = node.type === 'decision' ? 140 : nodeWidth;
|
const w = node.type === 'decision' ? 140 : NODE_WIDTH;
|
||||||
const h = node.type === 'decision' ? 90 : nodeHeight;
|
const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
|
||||||
flatGraph.setNode(node.id, { width: w, height: h });
|
flatGraph.setNode(node.id, { width: w, height: h });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -278,8 +284,8 @@ function layoutFlatNodes(
|
||||||
const pos = flatGraph.node(node.id);
|
const pos = flatGraph.node(node.id);
|
||||||
if (!pos) return node;
|
if (!pos) return node;
|
||||||
|
|
||||||
const w = node.type === 'decision' ? 140 : nodeWidth;
|
const w = node.type === 'decision' ? 140 : NODE_WIDTH;
|
||||||
const h = node.type === 'decision' ? 90 : nodeHeight;
|
const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...node,
|
...node,
|
||||||
|
|
@ -289,5 +295,130 @@ function layoutFlatNodes(
|
||||||
} as Node;
|
} as Node;
|
||||||
});
|
});
|
||||||
|
|
||||||
return { nodes: layoutedNodes, edges };
|
// Apply smart overlap resolution if enabled
|
||||||
|
const resolvedNodes = opts.smartOverlapResolution
|
||||||
|
? resolveOverlaps(layoutedNodes)
|
||||||
|
: layoutedNodes;
|
||||||
|
|
||||||
|
return { nodes: resolvedNodes, edges };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smart collision resolution - iteratively pushes overlapping nodes apart
|
||||||
|
* Uses a force-directed approach with multiple passes for stability
|
||||||
|
*/
|
||||||
|
function resolveOverlaps(nodes: Node[], maxIterations: number = 50): Node[] {
|
||||||
|
const mutableNodes = nodes.map(n => ({
|
||||||
|
...n,
|
||||||
|
position: { ...n.position },
|
||||||
|
width: getNodeWidth(n),
|
||||||
|
height: getNodeHeight(n)
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
||||||
|
let hasOverlap = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < mutableNodes.length; i++) {
|
||||||
|
for (let j = i + 1; j < mutableNodes.length; j++) {
|
||||||
|
const nodeA = mutableNodes[i];
|
||||||
|
const nodeB = mutableNodes[j];
|
||||||
|
|
||||||
|
// Skip group nodes
|
||||||
|
if (nodeA.type === 'group' || nodeB.type === 'group') continue;
|
||||||
|
|
||||||
|
// Check for overlap with padding
|
||||||
|
const overlapX = getOverlap(
|
||||||
|
nodeA.position.x, nodeA.width,
|
||||||
|
nodeB.position.x, nodeB.width,
|
||||||
|
MIN_NODE_SPACING
|
||||||
|
);
|
||||||
|
|
||||||
|
const overlapY = getOverlap(
|
||||||
|
nodeA.position.y, nodeA.height,
|
||||||
|
nodeB.position.y, nodeB.height,
|
||||||
|
MIN_NODE_SPACING
|
||||||
|
);
|
||||||
|
|
||||||
|
// If nodes overlap in both axes, push them apart
|
||||||
|
if (overlapX > 0 && overlapY > 0) {
|
||||||
|
hasOverlap = true;
|
||||||
|
|
||||||
|
// Determine which axis needs less push (more efficient separation)
|
||||||
|
if (overlapX < overlapY) {
|
||||||
|
// Push horizontally
|
||||||
|
const pushX = overlapX / 2 + 5;
|
||||||
|
if (nodeA.position.x < nodeB.position.x) {
|
||||||
|
nodeA.position.x -= pushX;
|
||||||
|
nodeB.position.x += pushX;
|
||||||
|
} else {
|
||||||
|
nodeA.position.x += pushX;
|
||||||
|
nodeB.position.x -= pushX;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Push vertically
|
||||||
|
const pushY = overlapY / 2 + 5;
|
||||||
|
if (nodeA.position.y < nodeB.position.y) {
|
||||||
|
nodeA.position.y -= pushY;
|
||||||
|
nodeB.position.y += pushY;
|
||||||
|
} else {
|
||||||
|
nodeA.position.y += pushY;
|
||||||
|
nodeB.position.y -= pushY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no overlaps detected, we're done
|
||||||
|
if (!hasOverlap) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure no negative positions (shift everything if needed)
|
||||||
|
let minX = Infinity, minY = Infinity;
|
||||||
|
mutableNodes.forEach(n => {
|
||||||
|
if (n.type !== 'group') {
|
||||||
|
minX = Math.min(minX, n.position.x);
|
||||||
|
minY = Math.min(minY, n.position.y);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const offsetX = minX < 60 ? 60 - minX : 0;
|
||||||
|
const offsetY = minY < 60 ? 60 - minY : 0;
|
||||||
|
|
||||||
|
return mutableNodes.map(n => ({
|
||||||
|
...n,
|
||||||
|
position: {
|
||||||
|
x: n.position.x + offsetX,
|
||||||
|
y: n.position.y + offsetY
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate overlap between two rectangles with padding
|
||||||
|
*/
|
||||||
|
function getOverlap(pos1: number, size1: number, pos2: number, size2: number, padding: number): number {
|
||||||
|
const end1 = pos1 + size1 + padding;
|
||||||
|
const end2 = pos2 + size2 + padding;
|
||||||
|
|
||||||
|
return Math.min(end1 - pos2, end2 - pos1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get node width based on type
|
||||||
|
*/
|
||||||
|
function getNodeWidth(node: Node): number {
|
||||||
|
if (node.type === 'decision') return 140;
|
||||||
|
if (node.style?.width && typeof node.style.width === 'number') return node.style.width;
|
||||||
|
return NODE_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get node height based on type
|
||||||
|
*/
|
||||||
|
function getNodeHeight(node: Node): number {
|
||||||
|
if (node.type === 'decision') return 90;
|
||||||
|
if (node.style?.height && typeof node.style.height === 'number') return node.style.height;
|
||||||
|
return NODE_HEIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ mermaid.initialize({
|
||||||
interface ParsedNode {
|
interface ParsedNode {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
type: 'start' | 'end' | 'default' | 'decision' | 'process' | 'database' | 'group' | 'client' | 'server';
|
type: 'start' | 'end' | 'default' | 'decision' | 'process' | 'database' | 'group' | 'client' | 'server' | 'ai' | 'team' | 'platform' | 'data' | 'tech' | 'custom-shape';
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,11 +35,45 @@ function isDecisionLabel(label: string): boolean {
|
||||||
return decisionKeywords.some(keyword => lowerLabel.includes(keyword));
|
return decisionKeywords.some(keyword => lowerLabel.includes(keyword));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse metadata from Mermaid comments
|
||||||
|
* Format: %% { "id": "nodeId", "metadata": { ... } }
|
||||||
|
*/
|
||||||
|
function parseMetadataComments(code: string): Map<string, any> {
|
||||||
|
const metadataMap = new Map<string, any>();
|
||||||
|
|
||||||
|
// Regex matches lines starting with %% followed by JSON object
|
||||||
|
const lines = code.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed.startsWith('%%')) {
|
||||||
|
try {
|
||||||
|
// Extract potential JSON
|
||||||
|
const jsonStr = trimmed.substring(2).trim();
|
||||||
|
// Basic check if it looks like JSON object
|
||||||
|
if (jsonStr.startsWith('{') && jsonStr.endsWith('}')) {
|
||||||
|
const data = JSON.parse(jsonStr);
|
||||||
|
if (data.id && data.metadata) {
|
||||||
|
metadataMap.set(data.id, data.metadata);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore incomplete/invalid JSON in comments
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadataMap;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preprocess mermaid code to handle common issues
|
* Preprocess mermaid code to handle common issues
|
||||||
*/
|
*/
|
||||||
function preprocessMermaidCode(code: string): string {
|
function preprocessMermaidCode(code: string): string {
|
||||||
return code
|
let cleaned = code
|
||||||
|
// Strip markdown code fence wrappers (```mermaid ... ```)
|
||||||
|
.replace(/^```(?:mermaid)?\s*\n?/im, '')
|
||||||
|
.replace(/\n?```\s*$/im, '')
|
||||||
// Remove %%{init:...}%% directives that may cause issues
|
// Remove %%{init:...}%% directives that may cause issues
|
||||||
.replace(/%%\{init:[^}]*\}%%/g, '')
|
.replace(/%%\{init:[^}]*\}%%/g, '')
|
||||||
// Convert <br/>, <br>, <br /> to spaces in node labels
|
// Convert <br/>, <br>, <br /> to spaces in node labels
|
||||||
|
|
@ -48,6 +82,8 @@ function preprocessMermaidCode(code: string): string {
|
||||||
.replace(/\r\n/g, '\n')
|
.replace(/\r\n/g, '\n')
|
||||||
// Remove empty lines at start
|
// Remove empty lines at start
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
|
return cleaned;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function parseMermaid(mermaidCode: string): Promise<{ nodes: Node[]; edges: Edge[] }> {
|
export async function parseMermaid(mermaidCode: string): Promise<{ nodes: Node[]; edges: Edge[] }> {
|
||||||
|
|
@ -80,18 +116,33 @@ export async function parseMermaid(mermaidCode: string): Promise<{ nodes: Node[]
|
||||||
|
|
||||||
// Create Group Nodes first
|
// Create Group Nodes first
|
||||||
for (const sub of subgraphs) {
|
for (const sub of subgraphs) {
|
||||||
|
const groupTitle = (sub.title || sub.id).toLowerCase();
|
||||||
|
let category = 'filter-other';
|
||||||
|
|
||||||
|
if (groupTitle.includes('client') || groupTitle.includes('frontend') || groupTitle.includes('ui') || groupTitle.includes('mobile') || groupTitle.includes('web')) {
|
||||||
|
category = 'filter-client';
|
||||||
|
} else if (groupTitle.includes('server') || groupTitle.includes('backend') || groupTitle.includes('api') || groupTitle.includes('service') || groupTitle.includes('auth') || groupTitle.includes('handler') || groupTitle.includes('worker')) {
|
||||||
|
category = 'filter-server';
|
||||||
|
} else if (groupTitle.includes('database') || groupTitle.includes('db') || groupTitle.includes('store') || groupTitle.includes('cache') || groupTitle.includes('redis')) {
|
||||||
|
category = 'filter-db';
|
||||||
|
}
|
||||||
|
|
||||||
nodes.push({
|
nodes.push({
|
||||||
id: sub.id,
|
id: sub.id,
|
||||||
type: 'group',
|
type: 'group',
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
label: sub.title,
|
label: sub.title,
|
||||||
color: groupColors[groupIndex++ % groupColors.length]
|
color: groupColors[groupIndex++ % groupColors.length],
|
||||||
|
category
|
||||||
},
|
},
|
||||||
style: {},
|
style: {},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Match metadata to nodes
|
||||||
|
const metadataMap = parseMetadataComments(cleanedCode);
|
||||||
|
|
||||||
// Process Nodes and Groups logic combined
|
// Process Nodes and Groups logic combined
|
||||||
for (const [id, vertex] of Object.entries(vertices) as any[]) {
|
for (const [id, vertex] of Object.entries(vertices) as any[]) {
|
||||||
// vertex: { id, text, type, styles, classes, ... }
|
// vertex: { id, text, type, styles, classes, ... }
|
||||||
|
|
@ -124,16 +175,77 @@ export async function parseMermaid(mermaidCode: string): Promise<{ nodes: Node[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine category for filtering
|
// Determine category for filtering - Enhanced Logic
|
||||||
let category = 'filter-server';
|
let category = 'filter-other'; // Default to other/flow
|
||||||
if (type === 'database') category = 'filter-db';
|
|
||||||
else if (type === 'client') category = 'filter-client';
|
// 1. Try to infer from Parent Subgraph Title/ID first (High Priority)
|
||||||
|
if (parentId) {
|
||||||
|
const parentGroup = nodes.find(n => n.id === parentId);
|
||||||
|
if (parentGroup) {
|
||||||
|
// Check both label and ID for clues
|
||||||
|
const groupLabel = (parentGroup.data?.label as string || '').toLowerCase();
|
||||||
|
const groupTitle = groupLabel || parentGroup.id.toLowerCase();
|
||||||
|
|
||||||
|
if (groupTitle.includes('client') || groupTitle.includes('frontend') || groupTitle.includes('ui') || groupTitle.includes('mobile') || groupTitle.includes('web')) {
|
||||||
|
category = 'filter-client';
|
||||||
|
// Also upgrade node type if generic
|
||||||
|
if (type === 'default') type = 'client';
|
||||||
|
} else if (groupTitle.includes('server') || groupTitle.includes('backend') || groupTitle.includes('api') || groupTitle.includes('service') || groupTitle.includes('auth') || groupTitle.includes('handler') || groupTitle.includes('worker')) {
|
||||||
|
category = 'filter-server';
|
||||||
|
if (type === 'default') type = 'server';
|
||||||
|
} else if (groupTitle.includes('database') || groupTitle.includes('db') || groupTitle.includes('store') || groupTitle.includes('cache') || groupTitle.includes('redis')) {
|
||||||
|
category = 'filter-db';
|
||||||
|
if (type === 'default' || type === 'database') type = 'database';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. If no parent group match (or no parent), use Node Type/Label
|
||||||
|
if (category === 'filter-other') {
|
||||||
|
if (type === 'database') category = 'filter-db';
|
||||||
|
else if (type === 'client') category = 'filter-client';
|
||||||
|
else if (type === 'server') category = 'filter-server';
|
||||||
|
// Heuristics from label if still default
|
||||||
|
else if (label.toLowerCase().includes('gateway')) {
|
||||||
|
category = 'filter-server';
|
||||||
|
if (type === 'default') type = 'server';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Final Fallback: If type is still default but category is specific, sync them
|
||||||
|
if (type === 'default') {
|
||||||
|
if (category === 'filter-server') type = 'server';
|
||||||
|
else if (category === 'filter-client') type = 'client';
|
||||||
|
else if (category === 'filter-db') type = 'database';
|
||||||
|
|
||||||
|
// Check for specialized types based on label keywords (matching CustomNodes.tsx logic)
|
||||||
|
const l = label.toLowerCase();
|
||||||
|
if (l.includes('ai') || l.includes('director') || l.includes('agent') || l.includes('generate')) type = 'ai';
|
||||||
|
else if (l.includes('team') || l.includes('human') || l.includes('review')) type = 'team';
|
||||||
|
else if (l.includes('platform') || l.includes('youtube') || l.includes('tiktok') || l.includes('shop')) type = 'platform';
|
||||||
|
else if (l.includes('data') || l.includes('analytics') || l.includes('metric')) type = 'data';
|
||||||
|
else if (l.includes('tech') || l.includes('infra')) type = 'tech';
|
||||||
|
|
||||||
|
// Check if it's a specific shape supported by ShapeNode
|
||||||
|
const supportedShapes = ['rect', 'rounded', 'stadium', 'subroutine', 'cyl', 'cylinder', 'diamond', 'rhombus', 'hexagon', 'parallelogram', 'trapezoid', 'doc', 'document', 'cloud', 'circle', 'doublecircle'];
|
||||||
|
if (supportedShapes.includes(vertex.type || '')) {
|
||||||
|
type = 'custom-shape';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge metadata if available
|
||||||
|
const nodeMetadata = metadataMap.get(id);
|
||||||
|
|
||||||
nodes.push({
|
nodes.push({
|
||||||
id: id,
|
id: id,
|
||||||
type: type,
|
type: type, // We might want to use a generic 'custom-shape' type for explicit shapes later, but for now we keep the semantic type inference or fallback
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
data: { label, category }, // Use sanitized label
|
data: {
|
||||||
|
label,
|
||||||
|
category,
|
||||||
|
shape: vertex.type, // Pass the raw mermaid shape type
|
||||||
|
metadata: nodeMetadata // Attach parsed metadata
|
||||||
|
},
|
||||||
parentId: parentId,
|
parentId: parentId,
|
||||||
extent: parentId ? 'parent' : undefined
|
extent: parentId ? 'parent' : undefined
|
||||||
});
|
});
|
||||||
|
|
@ -149,6 +261,7 @@ export async function parseMermaid(mermaidCode: string): Promise<{ nodes: Node[]
|
||||||
animated: e.stroke === 'dotted', // Heuristic
|
animated: e.stroke === 'dotted', // Heuristic
|
||||||
style: {
|
style: {
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
|
strokeOpacity: 0.5,
|
||||||
strokeDasharray: e.stroke === 'dotted' ? '5,5' : undefined
|
strokeDasharray: e.stroke === 'dotted' ? '5,5' : undefined
|
||||||
},
|
},
|
||||||
labelStyle: { fill: '#374151', fontWeight: 600, fontSize: 11 },
|
labelStyle: { fill: '#374151', fontWeight: 600, fontSize: 11 },
|
||||||
|
|
@ -326,7 +439,10 @@ function parseMermaidRegex(mermaidCode: string): { nodes: Node[]; edges: Edge[]
|
||||||
target: e.target,
|
target: e.target,
|
||||||
label: e.label,
|
label: e.label,
|
||||||
animated: e.dotted,
|
animated: e.dotted,
|
||||||
style: e.dotted ? { strokeDasharray: '5,5' } : undefined
|
style: {
|
||||||
|
strokeDasharray: e.dotted ? '5,5' : undefined,
|
||||||
|
strokeOpacity: 0.5
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log(`[MermaidParser] Regex fallback: ${nodes.length} nodes, ${edges.length} edges`);
|
console.log(`[MermaidParser] Regex fallback: ${nodes.length} nodes, ${edges.length} edges`);
|
||||||
|
|
|
||||||
118
src/lib/prompts.ts
Normal file
118
src/lib/prompts.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
export const SYSTEM_PROMPT = `You are an expert System Architect AI. Your task is to transform technical descriptions into precise Mermaid.js diagrams and high-fidelity metadata.
|
||||||
|
|
||||||
|
Return ONLY a strictly valid JSON object. No Markdown headers, no preamble.
|
||||||
|
|
||||||
|
EXPECTED JSON FORMAT:
|
||||||
|
{
|
||||||
|
"mermaidCode": "flowchart TD\\n A[Client] --> B[API Server]\\n ...",
|
||||||
|
"metadata": {
|
||||||
|
"NodeLabel": {
|
||||||
|
"techStack": ["Technologies used"],
|
||||||
|
"role": "Specific role (e.g., Load Balancer, Cache, Database)",
|
||||||
|
"description": "Concise technical responsibility"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ARCHITECTURAL RULES:
|
||||||
|
1. Use 'flowchart TD' or 'flowchart LR'.
|
||||||
|
2. Use descriptive but concise ID/Labels (e.g., 'API', 'DB_PROD').
|
||||||
|
3. Labels must match the keys in the 'metadata' object EXACTLY.
|
||||||
|
4. If input is already Mermaid code, wrap it in the JSON 'mermaidCode' field and infer metadata.
|
||||||
|
5. Identify semantic roles: use keywords like 'Client', 'Server', 'Worker', 'Database', 'Queue' in labels.
|
||||||
|
6. Escape double quotes inside the mermaid string correctly.
|
||||||
|
|
||||||
|
DIAGRAM QUALITY RULES:
|
||||||
|
7. NEVER use HTML tags (like <br/>, <b>, etc.) in node labels. Use short, clean text only.
|
||||||
|
8. NEVER use pipe characters (|) inside node labels - they break parsing. Use commas or spaces instead.
|
||||||
|
9. Use DIAMOND shapes {Decision} for review, approval, or decision steps (e.g., A{Approve?}).
|
||||||
|
10. Use CYLINDER shapes [(Database)] for data stores.
|
||||||
|
11. Use ROUNDED shapes (Process) for human/manual tasks.
|
||||||
|
12. For complex workflows, add step numbers as edge labels: A -->|1| B -->|2| C.
|
||||||
|
13. Include feedback loops where logical (e.g., connect "Collect Feedback" back to analysis nodes).
|
||||||
|
14. Use subgraphs/swimlanes to group related components by team, role, or domain.
|
||||||
|
15. Ensure consistent node shapes within each swimlane/subgraph.`;
|
||||||
|
|
||||||
|
export const SYSTEM_PROMPT_SIMPLE = `You are a System Architect. Create a SIMPLE, high-level Mermaid diagram.
|
||||||
|
- Focus on the main data flow (max 5-7 nodes).
|
||||||
|
- Use simple labels (e.g., "User", "API", "DB"). NEVER use HTML tags like <br/>.
|
||||||
|
- Use diamond {Decision?} for approval/review steps.
|
||||||
|
- Minimal metadata (role only).`;
|
||||||
|
|
||||||
|
export const SYSTEM_PROMPT_COMPLEX = `You are an Expert Solution Architect. Create a DETAILED, comprehensive Mermaid diagram.
|
||||||
|
- Include all subsystems, queues, workers, and external services.
|
||||||
|
- Use swimlanes (subgraphs) to group components by role/domain.
|
||||||
|
- NEVER use HTML tags like <br/> in labels. Keep labels clean and concise.
|
||||||
|
- Use diamond {Decision?} for approval/review steps.
|
||||||
|
- Use cylinder [(DB)] for databases, rounded (Task) for human tasks.
|
||||||
|
- Add step numbers as edge labels for complex flows: A -->|1| B -->|2| C.
|
||||||
|
- Include feedback loops connecting outputs back to inputs where logical.
|
||||||
|
- Detailed metadata (techStack, role, description).`;
|
||||||
|
|
||||||
|
export const SUGGEST_PROMPT = `You are a Mermaid.js syntax and logic expert.
|
||||||
|
Your task is to analyze the provided Mermaid flowchart code and either:
|
||||||
|
1. Fix any syntax errors that prevent it from rendering.
|
||||||
|
2. Improve the logical flow or visual clarity if the syntax is already correct.
|
||||||
|
|
||||||
|
Return ONLY a strictly valid JSON object. No Markdown headers, no preamble.
|
||||||
|
|
||||||
|
EXPECTED JSON FORMAT:
|
||||||
|
{
|
||||||
|
"mermaidCode": "flowchart TD\\n A[Fixed/Improved] --> B[Nodes]",
|
||||||
|
"explanation": "Briefly explain what was fixed or improved."
|
||||||
|
}
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- Maintain the original intent of the diagram.
|
||||||
|
- Use best practices for Mermaid layout and labeling.
|
||||||
|
- If the code is already perfect, return it as is but provide a positive explanation.`;
|
||||||
|
|
||||||
|
export const VISUAL_ANALYSIS_PROMPT = `You are a Visualization and UX Expert specialized in node-graph diagrams.
|
||||||
|
Your task is to analyze the provided graph structure and metrics to suggest specific improvements for layout, grouping, and visual clarity.
|
||||||
|
|
||||||
|
Return ONLY a strictly valid JSON object. Do not include markdown formatting like \`\`\`json.
|
||||||
|
|
||||||
|
EXPECTED JSON FORMAT:
|
||||||
|
{
|
||||||
|
"analysis": {
|
||||||
|
"suggestions": [
|
||||||
|
{
|
||||||
|
"id": "unique-id",
|
||||||
|
"title": "Short title",
|
||||||
|
"description": "Detailed explanation",
|
||||||
|
"type": "spacing" | "grouping" | "routing" | "hierarchy" | "style",
|
||||||
|
"impact": "high" | "medium" | "low",
|
||||||
|
"fix_strategy": "algorithmic_spacing" | "algorithmic_routing" | "group_nodes" | "unknown"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": {
|
||||||
|
"critique": "Overall analysis",
|
||||||
|
"score": 0-100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EXAMPLE RESPONSE:
|
||||||
|
{
|
||||||
|
"analysis": {
|
||||||
|
"suggestions": [
|
||||||
|
{
|
||||||
|
"id": "sug-1",
|
||||||
|
"title": "Group Database Nodes",
|
||||||
|
"description": "Several database nodes are scattered. Group them for better logical separation.",
|
||||||
|
"type": "grouping",
|
||||||
|
"impact": "high",
|
||||||
|
"fix_strategy": "group_nodes"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": {
|
||||||
|
"critique": "The flow is generally good but lacks logical grouping for backend services.",
|
||||||
|
"score": 75
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- Focus on readability, flow, and logical grouping.
|
||||||
|
- Identify if nodes are too cluttered or if the flow is confusing.
|
||||||
|
- Suggest grouping for nodes that appear related based on their labels.`;
|
||||||
61
src/lib/shapes.ts
Normal file
61
src/lib/shapes.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
// SVG Paths for Mermaid Shapes
|
||||||
|
// Based on Mermaid v11.3.0+ syntax
|
||||||
|
// Syntax: node@{ shape: shapeName }
|
||||||
|
|
||||||
|
export interface ShapeDefinition {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
category: 'basic' | 'logic' | 'data' | 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SHAPE_PATHS: Record<string, string> = {
|
||||||
|
// Basic
|
||||||
|
rect: 'M0 4 h100 v60 h-100 z',
|
||||||
|
rounded: 'M10 4 h80 a10 10 0 0 1 10 10 v40 a10 10 0 0 1 -10 10 h-80 a10 10 0 0 1 -10 -10 v-40 a10 10 0 0 1 10 -10 z',
|
||||||
|
stadium: 'M20 4 h60 a20 20 0 0 1 20 20 v20 a20 20 0 0 1 -20 20 h-60 a20 20 0 0 1 -20 -20 v-20 a20 20 0 0 1 20 -20 z',
|
||||||
|
subroutine: 'M10 4 h80 v60 h-80 z M20 4 v60 M80 4 v60',
|
||||||
|
cyl: 'M0 14 a50 10 0 0 1 100 0 v40 a50 10 0 0 1 -100 0 z M0 14 a50 10 0 0 1 100 0',
|
||||||
|
cylinder: 'M0 14 a50 10 0 0 1 100 0 v40 a50 10 0 0 1 -100 0 z M0 14 a50 10 0 0 1 100 0',
|
||||||
|
|
||||||
|
// Logic
|
||||||
|
diamond: 'M50 0 L100 50 L50 100 L0 50 Z',
|
||||||
|
rhombus: 'M50 0 L100 50 L50 100 L0 50 Z',
|
||||||
|
decision: 'M50 0 L100 50 L50 100 L0 50 Z',
|
||||||
|
hexagon: 'M15 4 L85 4 L100 34 L85 64 L15 64 L0 34 Z',
|
||||||
|
parallelogram: 'M15 4 h85 l-15 60 h-85 z',
|
||||||
|
trapezoid: 'M15 4 h70 l15 60 h-100 z',
|
||||||
|
|
||||||
|
// Documents/Data
|
||||||
|
doc: 'M0 0 h80 l20 20 v80 h-100 z M80 0 v20 h20',
|
||||||
|
document: 'M10 4 h80 v45 q0 15 -15 15 h-60 q-15 0 -15 -15 v-45 z M10 4 h80 v45 q-10 10 -40 10 q -30 0 -40 -10',
|
||||||
|
|
||||||
|
// Cloud (Approximation)
|
||||||
|
cloud: 'M20 50 a20 20 0 0 1 20 -30 a25 25 0 0 1 40 10 a20 20 0 0 1 15 35 h-70 z',
|
||||||
|
|
||||||
|
// Circles
|
||||||
|
circle: 'M50 4 a30 30 0 0 1 0 60 a30 30 0 0 1 0 -60',
|
||||||
|
doublecircle: 'M50 4 a30 30 0 0 1 0 60 a30 30 0 0 1 0 -60 M50 9 a25 25 0 0 1 0 50 a25 25 0 0 1 0 -50',
|
||||||
|
|
||||||
|
// New additions based on research
|
||||||
|
note: 'M10 4 h80 v60 h-80 z M70 4 v20 h20', // rough note
|
||||||
|
summary: 'M50 4 a30 30 0 0 1 0 60 a30 30 0 0 1 0 -60 M30 24 l40 40 M30 64 l40 -40', // Crossed circle
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SHAPE_DEFINITIONS: ShapeDefinition[] = [
|
||||||
|
{ id: 'rect', label: 'Rectangle', path: SHAPE_PATHS.rect, category: 'basic' },
|
||||||
|
{ id: 'rounded', label: 'Rounded', path: SHAPE_PATHS.rounded, category: 'basic' },
|
||||||
|
{ id: 'stadium', label: 'Terminal', path: SHAPE_PATHS.stadium, category: 'basic' },
|
||||||
|
{ id: 'subroutine', label: 'Subroutine', path: SHAPE_PATHS.subroutine, category: 'basic' },
|
||||||
|
{ id: 'circle', label: 'Circle', path: SHAPE_PATHS.circle, category: 'basic' },
|
||||||
|
|
||||||
|
{ id: 'diamond', label: 'Decision', path: SHAPE_PATHS.diamond, category: 'logic' },
|
||||||
|
{ id: 'hexagon', label: 'Prepare', path: SHAPE_PATHS.hexagon, category: 'logic' },
|
||||||
|
{ id: 'parallelogram', label: 'Input/Output', path: SHAPE_PATHS.parallelogram, category: 'logic' },
|
||||||
|
|
||||||
|
{ id: 'cyl', label: 'Database', path: SHAPE_PATHS.cyl, category: 'data' },
|
||||||
|
{ id: 'doc', label: 'Document', path: SHAPE_PATHS.doc, category: 'data' },
|
||||||
|
{ id: 'cloud', label: 'Cloud', path: SHAPE_PATHS.cloud, category: 'data' },
|
||||||
|
|
||||||
|
{ id: 'note', label: 'Note', path: SHAPE_PATHS.note, category: 'other' },
|
||||||
|
];
|
||||||
|
|
@ -16,11 +16,14 @@ export class WebLlmService {
|
||||||
private isReady = false;
|
private isReady = false;
|
||||||
|
|
||||||
// Track GPU Availability
|
// Track GPU Availability
|
||||||
public static async isSystemSupported(): Promise<boolean> {
|
public static async isSystemSupported(): Promise<boolean | null> {
|
||||||
|
// @ts-ignore
|
||||||
if (!navigator.gpu) {
|
if (!navigator.gpu) {
|
||||||
return false;
|
console.warn('WebGPU not supported in this environment');
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
// @ts-ignore
|
||||||
const adapter = await navigator.gpu.requestAdapter();
|
const adapter = await navigator.gpu.requestAdapter();
|
||||||
return !!adapter;
|
return !!adapter;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Settings, Zap, ChevronRight, Activity, ArrowRight, Sun, Moon, Eye, Upload
|
Settings, Zap, ChevronRight, Activity, ArrowRight, Sun, Moon, Eye, Upload, Trash2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useFlowStore } from '../store';
|
import { useFlowStore } from '../store';
|
||||||
import { SettingsModal } from '../components/Settings';
|
import { SettingsModal } from '../components/Settings';
|
||||||
|
|
@ -9,13 +9,15 @@ import { SettingsModal } from '../components/Settings';
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const {
|
const {
|
||||||
savedDiagrams, theme, toggleTheme
|
savedDiagrams, theme, toggleTheme, deleteDiagram, clearAllDiagrams
|
||||||
} = useFlowStore();
|
} = useFlowStore();
|
||||||
|
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
|
||||||
|
// ... (keep handleFileSelect) ...
|
||||||
|
|
||||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
|
|
@ -37,11 +39,7 @@ export function Dashboard() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen bg-void text-primary overflow-hidden font-sans relative">
|
<div className="h-screen bg-void text-primary overflow-hidden font-sans relative">
|
||||||
{/* Ambient Background */}
|
{/* ... (keep background and header) ... */}
|
||||||
<div className="absolute inset-0 z-0 pointer-events-none opacity-20">
|
|
||||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[80vw] h-[80vh] bg-blue-500/5 blur-[160px] rounded-full dark:bg-blue-500/10" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Top Navigation */}
|
{/* Top Navigation */}
|
||||||
<header className="absolute top-0 left-0 right-0 h-20 px-12 flex items-center justify-between z-50">
|
<header className="absolute top-0 left-0 right-0 h-20 px-12 flex items-center justify-between z-50">
|
||||||
<div className="flex items-center gap-4 group cursor-pointer" onClick={() => window.location.reload()}>
|
<div className="flex items-center gap-4 group cursor-pointer" onClick={() => window.location.reload()}>
|
||||||
|
|
@ -75,7 +73,7 @@ export function Dashboard() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<main className="h-full flex flex-col items-center justify-center relative z-10 px-12">
|
<main className="min-h-screen flex flex-col items-center justify-start relative z-10 px-6 sm:px-12 pt-28 pb-24 overflow-y-auto">
|
||||||
<div className="max-w-4xl w-full text-center mb-16 animate-slide-up">
|
<div className="max-w-4xl w-full text-center mb-16 animate-slide-up">
|
||||||
<h1 className="text-7xl font-display font-black tracking-tighter leading-[0.9] mb-6 text-primary">
|
<h1 className="text-7xl font-display font-black tracking-tighter leading-[0.9] mb-6 text-primary">
|
||||||
Design the <br /><span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-500 to-indigo-500">Unseen logic</span>.
|
Design the <br /><span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-500 to-indigo-500">Unseen logic</span>.
|
||||||
|
|
@ -86,6 +84,7 @@ export function Dashboard() {
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-8">
|
<div className="flex flex-col items-center gap-8">
|
||||||
<div className="flex flex-wrap items-center justify-center gap-4 w-full max-w-2xl">
|
<div className="flex flex-wrap items-center justify-center gap-4 w-full max-w-2xl">
|
||||||
|
{/* ... (keep Upload and Direct Access buttons) ... */}
|
||||||
<button
|
<button
|
||||||
className="btn-primary flex-1 min-w-[240px] group h-14"
|
className="btn-primary flex-1 min-w-[240px] group h-14"
|
||||||
onClick={() => !isUploading && document.getElementById('main-upload')?.click()}
|
onClick={() => !isUploading && document.getElementById('main-upload')?.click()}
|
||||||
|
|
@ -113,9 +112,7 @@ export function Dashboard() {
|
||||||
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform opacity-40" />
|
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform opacity-40" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input id="main-upload" type="file" className="hidden" onChange={handleFileSelect} />
|
<input id="main-upload" type="file" className="hidden" onChange={handleFileSelect} />
|
||||||
|
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.4em] text-tertiary">
|
<p className="text-[10px] font-black uppercase tracking-[0.4em] text-tertiary">
|
||||||
Drop files anywhere or click to start
|
Drop files anywhere or click to start
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -123,31 +120,60 @@ export function Dashboard() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Compact Recent Intelligence */}
|
{/* Compact Recent Intelligence */}
|
||||||
<div className="w-full max-w-5xl grid grid-cols-1 sm:grid-cols-3 gap-6 animate-slide-up" style={{ animationDelay: '0.2s' }}>
|
<div className="w-full max-w-5xl animate-slide-up" style={{ animationDelay: '0.2s' }}>
|
||||||
{savedDiagrams.length > 0 ? (
|
{savedDiagrams.length > 0 && (
|
||||||
[...savedDiagrams].reverse().slice(0, 3).map((diagram) => (
|
<div className="flex justify-end mb-4">
|
||||||
<div
|
<button
|
||||||
key={diagram.id}
|
onClick={() => {
|
||||||
className="glass-panel rounded-2xl p-6 flex items-center justify-between group cursor-pointer hover:border-blue-500/30 shadow-sm hover:shadow-xl transition-all"
|
if (confirm('Are you sure you want to delete ALL saved diagrams?')) {
|
||||||
onClick={() => navigate(`/diagram?id=${diagram.id}`)}
|
clearAllDiagrams();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-red-500/10 text-red-500 hover:bg-red-500/20 text-[10px] font-bold uppercase tracking-widest transition-all"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-4 overflow-hidden">
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center shrink-0">
|
Clear All
|
||||||
<Activity className="w-4 h-4 text-blue-500 group-hover:scale-110 transition-transform" />
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="truncate text-left">
|
|
||||||
<h4 className="font-bold text-sm truncate text-primary">{diagram.name}</h4>
|
|
||||||
<p className="text-[9px] font-black text-tertiary uppercase tracking-widest">{diagram.nodes.filter(n => n.type !== 'group').length} Entities</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ArrowRight className="w-4 h-4 text-tertiary group-hover:text-primary transition-all opacity-0 group-hover:opacity-100 scale-0 group-hover:scale-100" />
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
[1, 2, 3].map(i => (
|
|
||||||
<div key={i} className="glass-panel border-dashed rounded-2xl p-6 opacity-20" />
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||||
|
{savedDiagrams.length > 0 ? (
|
||||||
|
[...savedDiagrams].reverse().slice(0, 3).map((diagram) => (
|
||||||
|
<div
|
||||||
|
key={diagram.id}
|
||||||
|
className="glass-panel rounded-2xl p-6 flex items-center justify-between group cursor-pointer hover:border-blue-500/30 shadow-sm hover:shadow-xl transition-all relative overflow-hidden"
|
||||||
|
onClick={() => navigate(`/diagram?id=${diagram.id}`)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4 overflow-hidden z-10 w-full pr-8">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center shrink-0">
|
||||||
|
<Activity className="w-4 h-4 text-blue-500 group-hover:scale-110 transition-transform" />
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-left">
|
||||||
|
<h4 className="font-bold text-sm truncate text-primary">{diagram.name}</h4>
|
||||||
|
<p className="text-[9px] font-black text-tertiary uppercase tracking-widest">{diagram.nodes.filter(n => n.type !== 'group').length} Entities</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Button (visible on hover) */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteDiagram(diagram.id);
|
||||||
|
}}
|
||||||
|
className="absolute top-2 right-2 p-2 rounded-lg bg-red-500/10 text-red-500 opacity-0 group-hover:opacity-100 dark:hover:bg-red-500/20 transition-all z-20 hover:scale-110"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
[1, 2, 3].map(i => (
|
||||||
|
<div key={i} className="glass-panel border-dashed rounded-2xl p-6 opacity-20" />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{savedDiagrams.length > 3 && (
|
{savedDiagrams.length > 3 && (
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,34 @@ import { FlowCanvas } from '../components/FlowCanvas';
|
||||||
import { NodeDetailsPanel } from '../components/NodeDetailsPanel';
|
import { NodeDetailsPanel } from '../components/NodeDetailsPanel';
|
||||||
import InteractiveLegend from '../components/InteractiveLegend';
|
import InteractiveLegend from '../components/InteractiveLegend';
|
||||||
import OnboardingTour from '../components/OnboardingTour';
|
import OnboardingTour from '../components/OnboardingTour';
|
||||||
import { useFlowStore } from '../store';
|
import { useFlowStore, useSettingsStore } from '../store';
|
||||||
|
import { useMobileDetect } from '../hooks/useMobileDetect';
|
||||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import { PanelLeft, PanelRight, Zap, Sparkles, Minimize2 } from 'lucide-react';
|
import { PanelLeft, PanelRight, Zap, Sparkles, Minimize2, X, Cpu, Cloud, Server } from 'lucide-react';
|
||||||
import { OrchestratorLoader } from '../components/ui/OrchestratorLoader';
|
import { OrchestratorLoader } from '../components/ui/OrchestratorLoader';
|
||||||
import { EditorHeader } from '../components/editor/EditorHeader';
|
import { EditorHeader } from '../components/editor/EditorHeader';
|
||||||
|
import { VisualOrganizerFAB } from '../components/VisualOrganizerFAB';
|
||||||
|
import { useDiagramAPI } from '../hooks/useDiagramAPI';
|
||||||
|
|
||||||
export function Editor() {
|
export function Editor() {
|
||||||
const { nodes, isLoading, leftPanelOpen, setLeftPanelOpen, rightPanelOpen, setRightPanelOpen, focusMode, setFocusMode } = useFlowStore();
|
// Enable Programmatic API
|
||||||
|
useDiagramAPI();
|
||||||
|
|
||||||
|
const { nodes, isLoading, leftPanelOpen, setLeftPanelOpen, rightPanelOpen, setRightPanelOpen, focusMode, setFocusMode, mobileEditorOpen, setMobileEditorOpen } = useFlowStore();
|
||||||
|
const { aiMode, onlineProvider } = useSettingsStore();
|
||||||
|
const { isMobile } = useMobileDetect();
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(384); // Default w-96
|
const [sidebarWidth, setSidebarWidth] = useState(384); // Default w-96
|
||||||
const isResizing = useRef(false);
|
const isResizing = useRef(false);
|
||||||
|
const hasInitializedMobile = useRef(false);
|
||||||
|
|
||||||
|
// Mobile: Hide panels by default on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMobile && !hasInitializedMobile.current) {
|
||||||
|
setLeftPanelOpen(false);
|
||||||
|
setRightPanelOpen(false);
|
||||||
|
hasInitializedMobile.current = true;
|
||||||
|
}
|
||||||
|
}, [isMobile, setLeftPanelOpen, setRightPanelOpen]);
|
||||||
|
|
||||||
const startResizing = useCallback((e: React.MouseEvent) => {
|
const startResizing = useCallback((e: React.MouseEvent) => {
|
||||||
isResizing.current = true;
|
isResizing.current = true;
|
||||||
|
|
@ -101,7 +119,8 @@ export function Editor() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(!leftPanelOpen && !focusMode) && (
|
|
||||||
|
{(!leftPanelOpen && !focusMode && !isMobile) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setLeftPanelOpen(true)}
|
onClick={() => setLeftPanelOpen(true)}
|
||||||
className="absolute left-6 top-1/2 -translate-y-1/2 z-40 w-10 h-10 glass-panel rounded-xl flex items-center justify-center text-slate-500 dark:text-secondary hover:text-blue-500 hover:scale-110 transition-all shadow-xl bg-white/80 dark:bg-slate-900/50 border border-black/5 dark:border-white/10"
|
className="absolute left-6 top-1/2 -translate-y-1/2 z-40 w-10 h-10 glass-panel rounded-xl flex items-center justify-center text-slate-500 dark:text-secondary hover:text-blue-500 hover:scale-110 transition-all shadow-xl bg-white/80 dark:bg-slate-900/50 border border-black/5 dark:border-white/10"
|
||||||
|
|
@ -124,8 +143,8 @@ export function Editor() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty Workspace */}
|
{/* Empty Workspace - Hidden on mobile (FAB provides access) */}
|
||||||
{nodes.length === 0 && !isLoading && (
|
{nodes.length === 0 && !isLoading && !isMobile && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center z-10 pointer-events-none">
|
<div className="absolute inset-0 flex items-center justify-center z-10 pointer-events-none">
|
||||||
<div className="text-center p-12 floating-glass rounded-[2.5rem] max-w-sm pointer-events-auto shadow-2xl border border-black/5 dark:border-white/10 bg-white/50 dark:bg-black/20 backdrop-blur-xl">
|
<div className="text-center p-12 floating-glass rounded-[2.5rem] max-w-sm pointer-events-auto shadow-2xl border border-black/5 dark:border-white/10 bg-white/50 dark:bg-black/20 backdrop-blur-xl">
|
||||||
<div className="w-14 h-14 rounded-2xl bg-blue-600 flex items-center justify-center mx-auto mb-6 shadow-xl shadow-blue-600/20">
|
<div className="w-14 h-14 rounded-2xl bg-blue-600 flex items-center justify-center mx-auto mb-6 shadow-xl shadow-blue-600/20">
|
||||||
|
|
@ -149,22 +168,50 @@ export function Editor() {
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Right Inspector Panel - Flex layout instead of absolute */}
|
{/* Right Inspector Panel - Sidebar on desktop, Sheet on mobile */}
|
||||||
<div
|
{!isMobile ? (
|
||||||
className={`transition-all duration-500 ease-out border-l border-black/5 dark:border-white/10 bg-white/80 dark:bg-slate-900/95 backdrop-blur-xl flex flex-col ${(rightPanelOpen && !focusMode) ? 'w-80 opacity-100' : 'w-0 opacity-0 overflow-hidden border-none'}`}
|
<div
|
||||||
>
|
className={`transition-all duration-500 ease-out border-l border-black/5 dark:border-white/10 bg-white/80 dark:bg-slate-900/95 backdrop-blur-xl flex flex-col ${(rightPanelOpen && !focusMode) ? 'w-80 opacity-100' : 'w-0 opacity-0 overflow-hidden border-none'}`}
|
||||||
<div className="h-12 px-6 flex items-center justify-between border-b border-black/5 dark:border-white/10 bg-slate-50/50 dark:bg-slate-950/20 shrink-0">
|
>
|
||||||
<span className="text-[9px] font-black uppercase tracking-[0.3em] text-slate-500 dark:text-tertiary">Inspector</span>
|
<div className="h-12 px-6 flex items-center justify-between border-b border-black/5 dark:border-white/10 bg-slate-50/50 dark:bg-slate-950/20 shrink-0">
|
||||||
<button onClick={() => setRightPanelOpen(false)} className="text-slate-400 dark:text-tertiary hover:text-slate-600 dark:hover:text-primary transition-colors">
|
<span className="text-[9px] font-black uppercase tracking-[0.3em] text-slate-500 dark:text-tertiary">Inspector</span>
|
||||||
<PanelRight className="w-4 h-4" />
|
<button onClick={() => setRightPanelOpen(false)} className="text-slate-400 dark:text-tertiary hover:text-slate-600 dark:hover:text-primary transition-colors">
|
||||||
</button>
|
<PanelRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto hide-scrollbar">
|
||||||
|
<NodeDetailsPanel />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto hide-scrollbar">
|
) : (
|
||||||
<NodeDetailsPanel />
|
rightPanelOpen && !focusMode && (
|
||||||
</div>
|
<div className="fixed inset-0 z-[100] flex flex-col animate-fade-in">
|
||||||
</div>
|
<div
|
||||||
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={() => setRightPanelOpen(false)}
|
||||||
|
/>
|
||||||
|
<div className="relative mt-auto h-[70vh] bg-white dark:bg-slate-900 rounded-t-3xl shadow-2xl flex flex-col animate-slide-up overflow-hidden">
|
||||||
|
<div className="flex justify-center py-3">
|
||||||
|
<div className="w-12 h-1.5 rounded-full bg-slate-200 dark:bg-slate-700" />
|
||||||
|
</div>
|
||||||
|
<div className="h-12 px-6 flex items-center justify-between border-b border-black/5 dark:border-white/10 shrink-0">
|
||||||
|
<span className="text-sm font-bold text-slate-700 dark:text-slate-200">Inspector</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setRightPanelOpen(false)}
|
||||||
|
className="w-10 h-10 rounded-full flex items-center justify-center text-slate-400 hover:text-slate-600 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/10 transition-all"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<NodeDetailsPanel />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
{(nodes.length > 0 && !rightPanelOpen && !focusMode) && (
|
{(nodes.length > 0 && !rightPanelOpen && !focusMode && !isMobile) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setRightPanelOpen(true)}
|
onClick={() => setRightPanelOpen(true)}
|
||||||
className="absolute right-6 top-1/2 -translate-y-1/2 z-40 w-10 h-10 glass-panel rounded-xl flex items-center justify-center text-secondary hover:text-indigo-500 hover:scale-110 transition-all shadow-xl"
|
className="absolute right-6 top-1/2 -translate-y-1/2 z-40 w-10 h-10 glass-panel rounded-xl flex items-center justify-center text-secondary hover:text-indigo-500 hover:scale-110 transition-all shadow-xl"
|
||||||
|
|
@ -173,7 +220,69 @@ export function Editor() {
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile FAB - Opens Bottom Sheet */}
|
||||||
|
{isMobile && !mobileEditorOpen && !focusMode && (
|
||||||
|
<>
|
||||||
|
{/* Visual Organizer FAB (Above Main FAB) */}
|
||||||
|
<div className="fixed bottom-24 right-6 z-50 flex flex-col items-end pointer-events-none">
|
||||||
|
<div className="pointer-events-auto">
|
||||||
|
<VisualOrganizerFAB />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileEditorOpen(true)}
|
||||||
|
className="fixed bottom-6 right-6 z-50 w-14 h-14 rounded-full bg-gradient-to-br from-blue-600 to-indigo-600 shadow-xl shadow-blue-600/30 flex items-center justify-center hover:scale-110 active:scale-95 transition-all"
|
||||||
|
title="Open Editor"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-6 h-6 text-white" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile Bottom Sheet */}
|
||||||
|
{isMobile && mobileEditorOpen && (
|
||||||
|
<div className="fixed inset-0 z-[100] flex flex-col animate-fade-in">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={() => setMobileEditorOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Sheet Content */}
|
||||||
|
<div className="relative mt-auto h-[85vh] bg-white dark:bg-slate-900 rounded-t-3xl shadow-2xl flex flex-col animate-slide-up overflow-hidden">
|
||||||
|
{/* Handle */}
|
||||||
|
<div className="flex justify-center py-3">
|
||||||
|
<div className="w-12 h-1.5 rounded-full bg-slate-200 dark:bg-slate-700" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="h-14 px-6 flex items-center justify-between border-b border-black/5 dark:border-white/10 shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-1.5 rounded-lg bg-blue-500/10">
|
||||||
|
<Sparkles className="w-4 h-4 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-bold text-slate-700 dark:text-slate-200">System Architect</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileEditorOpen(false)}
|
||||||
|
className="w-10 h-10 rounded-full flex items-center justify-center text-slate-400 hover:text-slate-600 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/10 transition-all"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<InputPanel />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</ReactFlowProvider>
|
</ReactFlowProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
26
src/plugins/types.ts
Normal file
26
src/plugins/types.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
// Basic types needed for plugins
|
||||||
|
|
||||||
|
|
||||||
|
export interface ToolbarItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: any; // Lucide icon component
|
||||||
|
onClick: () => void;
|
||||||
|
position?: 'left' | 'center' | 'right'; // Default 'right' (actions)
|
||||||
|
tooltip?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginContext {
|
||||||
|
registerToolbarItem: (item: ToolbarItem) => void;
|
||||||
|
// Future extensions:
|
||||||
|
// registerSidebarTab: (tab: SidebarTab) => void;
|
||||||
|
// registerNodeAction: (action: NodeAction) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Plugin {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
init: (context: PluginContext) => void;
|
||||||
|
cleanup?: () => void;
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
|
import { temporal } from 'zundo';
|
||||||
import { addEdge, applyNodeChanges, applyEdgeChanges } from '@xyflow/react';
|
import { addEdge, applyNodeChanges, applyEdgeChanges } from '@xyflow/react';
|
||||||
import type { NodeChange, EdgeChange } from '@xyflow/react';
|
import type { NodeChange, EdgeChange } from '@xyflow/react';
|
||||||
import type { Node, Edge, Connection, SavedDiagram, EdgeStyle } from '../types';
|
import type { Node, Edge, Connection, SavedDiagram, EdgeStyle } from '../types';
|
||||||
|
|
@ -40,6 +41,7 @@ interface DiagramState {
|
||||||
saveDiagram: (name: string) => void;
|
saveDiagram: (name: string) => void;
|
||||||
loadDiagram: (id: string) => void;
|
loadDiagram: (id: string) => void;
|
||||||
deleteDiagram: (id: string) => void;
|
deleteDiagram: (id: string) => void;
|
||||||
|
clearAllDiagrams: () => void;
|
||||||
getSavedDiagrams: () => SavedDiagram[];
|
getSavedDiagrams: () => SavedDiagram[];
|
||||||
|
|
||||||
// Reset
|
// Reset
|
||||||
|
|
@ -58,130 +60,148 @@ const initialState = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useDiagramStore = create<DiagramState>()(
|
export const useDiagramStore = create<DiagramState>()(
|
||||||
persist(
|
temporal(
|
||||||
(set, get) => ({
|
persist(
|
||||||
...initialState,
|
(set, get) => ({
|
||||||
|
...initialState,
|
||||||
|
|
||||||
// Setters
|
// Setters
|
||||||
setNodes: (nodes) => set({ nodes }),
|
setNodes: (nodes) => set({ nodes }),
|
||||||
setEdges: (edges) => set({ edges }),
|
setEdges: (edges) => set({ edges }),
|
||||||
setSourceCode: (sourceCode) => set({ sourceCode }),
|
setSourceCode: (sourceCode) => set({ sourceCode }),
|
||||||
setEdgeStyle: (edgeStyle) => set({ edgeStyle }),
|
setEdgeStyle: (edgeStyle) => set({ edgeStyle }),
|
||||||
setGenerationComplexity: (generationComplexity) => set({ generationComplexity }),
|
setGenerationComplexity: (generationComplexity) => set({ generationComplexity }),
|
||||||
|
|
||||||
// React Flow handlers
|
// React Flow handlers
|
||||||
onNodesChange: (changes) => {
|
onNodesChange: (changes) => {
|
||||||
set({
|
|
||||||
nodes: applyNodeChanges(changes, get().nodes),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
onEdgesChange: (changes) => {
|
|
||||||
set({
|
|
||||||
edges: applyEdgeChanges(changes, get().edges),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
onConnect: (connection) => {
|
|
||||||
set({
|
|
||||||
edges: addEdge(connection, get().edges),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Node operations
|
|
||||||
updateNodeData: (nodeId, data) => {
|
|
||||||
set({
|
|
||||||
nodes: get().nodes.map((node) =>
|
|
||||||
node.id === nodeId
|
|
||||||
? { ...node, data: { ...node.data, ...data } }
|
|
||||||
: node
|
|
||||||
),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
updateNodeType: (nodeId, type) => {
|
|
||||||
set({
|
|
||||||
nodes: get().nodes.map((node) =>
|
|
||||||
node.id === nodeId ? { ...node, type } : node
|
|
||||||
),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteNode: (nodeId) => {
|
|
||||||
set({
|
|
||||||
nodes: get().nodes.filter((node) => node.id !== nodeId),
|
|
||||||
edges: get().edges.filter(
|
|
||||||
(edge) => edge.source !== nodeId && edge.target !== nodeId
|
|
||||||
),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Diagram persistence
|
|
||||||
saveDiagram: (name) => {
|
|
||||||
const { nodes, edges, sourceCode } = get();
|
|
||||||
const diagrams = get().getSavedDiagrams();
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
const newDiagram: SavedDiagram = {
|
|
||||||
id: `diagram_${Date.now()}`,
|
|
||||||
name,
|
|
||||||
nodes,
|
|
||||||
edges,
|
|
||||||
sourceCode,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
};
|
|
||||||
|
|
||||||
localStorage.setItem(
|
|
||||||
'flowgen_diagrams',
|
|
||||||
JSON.stringify([newDiagram, ...diagrams])
|
|
||||||
);
|
|
||||||
set({ savedDiagrams: [newDiagram, ...diagrams] });
|
|
||||||
},
|
|
||||||
|
|
||||||
loadDiagram: (id) => {
|
|
||||||
const diagrams = get().getSavedDiagrams();
|
|
||||||
const diagram = diagrams.find((d) => d.id === id);
|
|
||||||
|
|
||||||
if (diagram) {
|
|
||||||
set({
|
set({
|
||||||
nodes: diagram.nodes,
|
nodes: applyNodeChanges(changes, get().nodes),
|
||||||
edges: diagram.edges,
|
|
||||||
sourceCode: diagram.sourceCode,
|
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
},
|
|
||||||
|
|
||||||
deleteDiagram: (id) => {
|
onEdgesChange: (changes) => {
|
||||||
const diagrams = get().getSavedDiagrams();
|
set({
|
||||||
localStorage.setItem(
|
edges: applyEdgeChanges(changes, get().edges),
|
||||||
'flowgen_diagrams',
|
});
|
||||||
JSON.stringify(diagrams.filter((d) => d.id !== id))
|
},
|
||||||
);
|
|
||||||
set({ savedDiagrams: diagrams.filter((d) => d.id !== id) });
|
|
||||||
},
|
|
||||||
|
|
||||||
getSavedDiagrams: () => {
|
onConnect: (connection) => {
|
||||||
try {
|
set({
|
||||||
const stored = localStorage.getItem('flowgen_diagrams');
|
edges: addEdge(connection, get().edges),
|
||||||
return stored ? JSON.parse(stored) : [];
|
});
|
||||||
} catch {
|
},
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Reset
|
// Node operations
|
||||||
reset: () => set(initialState),
|
updateNodeData: (nodeId, data) => {
|
||||||
}),
|
set({
|
||||||
{
|
nodes: get().nodes.map((node) =>
|
||||||
name: 'flowgen-diagram-storage',
|
node.id === nodeId
|
||||||
// Only persist saved diagrams and settings, NOT the current diagram
|
? { ...node, data: { ...node.data, ...data } }
|
||||||
// This ensures canvas clears on refresh unless user saves a draft
|
: node
|
||||||
partialize: (state) => ({
|
),
|
||||||
savedDiagrams: state.savedDiagrams,
|
});
|
||||||
edgeStyle: state.edgeStyle,
|
},
|
||||||
generationComplexity: state.generationComplexity,
|
|
||||||
|
updateNodeType: (nodeId, type) => {
|
||||||
|
set({
|
||||||
|
nodes: get().nodes.map((node) =>
|
||||||
|
node.id === nodeId ? { ...node, type } : node
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteNode: (nodeId) => {
|
||||||
|
set({
|
||||||
|
nodes: get().nodes.filter((node) => node.id !== nodeId),
|
||||||
|
edges: get().edges.filter(
|
||||||
|
(edge) => edge.source !== nodeId && edge.target !== nodeId
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Diagram persistence
|
||||||
|
saveDiagram: (name) => {
|
||||||
|
const { nodes, edges, sourceCode } = get();
|
||||||
|
const diagrams = get().getSavedDiagrams();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const newDiagram: SavedDiagram = {
|
||||||
|
id: `diagram_${Date.now()}`,
|
||||||
|
name,
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
sourceCode,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem(
|
||||||
|
'flowgen_diagrams',
|
||||||
|
JSON.stringify([newDiagram, ...diagrams])
|
||||||
|
);
|
||||||
|
set({ savedDiagrams: [newDiagram, ...diagrams] });
|
||||||
|
},
|
||||||
|
|
||||||
|
loadDiagram: (id) => {
|
||||||
|
const diagrams = get().getSavedDiagrams();
|
||||||
|
const diagram = diagrams.find((d) => d.id === id);
|
||||||
|
|
||||||
|
if (diagram) {
|
||||||
|
set({
|
||||||
|
nodes: diagram.nodes,
|
||||||
|
edges: diagram.edges,
|
||||||
|
sourceCode: diagram.sourceCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteDiagram: (id) => {
|
||||||
|
const diagrams = get().getSavedDiagrams();
|
||||||
|
localStorage.setItem(
|
||||||
|
'flowgen_diagrams',
|
||||||
|
JSON.stringify(diagrams.filter((d) => d.id !== id))
|
||||||
|
);
|
||||||
|
set({ savedDiagrams: diagrams.filter((d) => d.id !== id) });
|
||||||
|
},
|
||||||
|
|
||||||
|
clearAllDiagrams: () => {
|
||||||
|
localStorage.removeItem('flowgen_diagrams');
|
||||||
|
set({ savedDiagrams: [] });
|
||||||
|
},
|
||||||
|
|
||||||
|
getSavedDiagrams: () => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('flowgen_diagrams');
|
||||||
|
return stored ? JSON.parse(stored) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
reset: () => set(initialState),
|
||||||
}),
|
}),
|
||||||
|
{
|
||||||
|
name: 'flowgen-diagram-storage',
|
||||||
|
// Only persist saved diagrams and settings, NOT the current diagram
|
||||||
|
// This ensures canvas clears on refresh unless user saves a draft
|
||||||
|
partialize: (state) => ({
|
||||||
|
savedDiagrams: state.savedDiagrams,
|
||||||
|
edgeStyle: state.edgeStyle,
|
||||||
|
generationComplexity: state.generationComplexity,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{
|
||||||
|
// Zundo configuration
|
||||||
|
limit: 100, // Limit history to 100 states
|
||||||
|
partialize: (state) => ({
|
||||||
|
nodes: state.nodes,
|
||||||
|
edges: state.edges,
|
||||||
|
sourceCode: state.sourceCode,
|
||||||
|
}),
|
||||||
|
// Don't track intermediate edits while dragging, only on release if possible
|
||||||
|
// But for now, tracking everything is safer.
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ export function useFlowStore() {
|
||||||
saveDiagram: diagram.saveDiagram,
|
saveDiagram: diagram.saveDiagram,
|
||||||
loadDiagram: diagram.loadDiagram,
|
loadDiagram: diagram.loadDiagram,
|
||||||
deleteDiagram: diagram.deleteDiagram,
|
deleteDiagram: diagram.deleteDiagram,
|
||||||
|
clearAllDiagrams: diagram.clearAllDiagrams,
|
||||||
|
|
||||||
// Settings state
|
// Settings state
|
||||||
apiKey: settings.apiKey,
|
apiKey: settings.apiKey,
|
||||||
|
|
@ -75,6 +76,11 @@ export function useFlowStore() {
|
||||||
leftPanelOpen: ui.leftPanelOpen,
|
leftPanelOpen: ui.leftPanelOpen,
|
||||||
rightPanelOpen: ui.rightPanelOpen,
|
rightPanelOpen: ui.rightPanelOpen,
|
||||||
focusMode: ui.focusMode,
|
focusMode: ui.focusMode,
|
||||||
|
mobileEditorOpen: ui.mobileEditorOpen,
|
||||||
|
inputDescription: ui.inputDescription,
|
||||||
|
inputActiveTab: ui.inputActiveTab,
|
||||||
|
inputImageUrl: ui.inputImageUrl,
|
||||||
|
mermaidCode: ui.mermaidCode,
|
||||||
activeFilters: ui.activeFilters,
|
activeFilters: ui.activeFilters,
|
||||||
isLoading: ui.isLoading,
|
isLoading: ui.isLoading,
|
||||||
error: ui.error,
|
error: ui.error,
|
||||||
|
|
@ -84,6 +90,12 @@ export function useFlowStore() {
|
||||||
setLeftPanelOpen: ui.setLeftPanelOpen,
|
setLeftPanelOpen: ui.setLeftPanelOpen,
|
||||||
setRightPanelOpen: ui.setRightPanelOpen,
|
setRightPanelOpen: ui.setRightPanelOpen,
|
||||||
setFocusMode: ui.setFocusMode,
|
setFocusMode: ui.setFocusMode,
|
||||||
|
setMobileEditorOpen: ui.setMobileEditorOpen,
|
||||||
|
setInputDescription: ui.setInputDescription,
|
||||||
|
setInputActiveTab: ui.setInputActiveTab,
|
||||||
|
setInputImageUrl: ui.setInputImageUrl,
|
||||||
|
setMermaidCode: ui.setMermaidCode,
|
||||||
|
clearInputs: ui.clearInputs,
|
||||||
toggleFilter: ui.toggleFilter,
|
toggleFilter: ui.toggleFilter,
|
||||||
setLoading: ui.setLoading,
|
setLoading: ui.setLoading,
|
||||||
setError: ui.setError,
|
setError: ui.setError,
|
||||||
|
|
|
||||||
63
src/store/pluginStore.ts
Normal file
63
src/store/pluginStore.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import type { Plugin, PluginContext, ToolbarItem } from '../plugins/types';
|
||||||
|
|
||||||
|
interface PluginState {
|
||||||
|
plugins: Record<string, Plugin>;
|
||||||
|
toolbarItems: ToolbarItem[];
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
registerPlugin: (plugin: Plugin) => void;
|
||||||
|
unregisterPlugin: (pluginId: string) => void;
|
||||||
|
|
||||||
|
// Internal API for Plugins
|
||||||
|
_registerToolbarItem: (item: ToolbarItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePluginStore = create<PluginState>((set, get) => ({
|
||||||
|
plugins: {},
|
||||||
|
toolbarItems: [],
|
||||||
|
|
||||||
|
registerPlugin: (plugin) => {
|
||||||
|
if (get().plugins[plugin.id]) {
|
||||||
|
console.warn(`Plugin ${plugin.id} is already registered.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Context
|
||||||
|
const context: PluginContext = {
|
||||||
|
registerToolbarItem: (item) => get()._registerToolbarItem(item),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Init Plugin
|
||||||
|
try {
|
||||||
|
plugin.init(context);
|
||||||
|
set((state) => ({
|
||||||
|
plugins: { ...state.plugins, [plugin.id]: plugin }
|
||||||
|
}));
|
||||||
|
console.log(`Plugin ${plugin.name} loaded.`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to initialize plugin ${plugin.id}:`, e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
unregisterPlugin: (pluginId) => {
|
||||||
|
const plugin = get().plugins[pluginId];
|
||||||
|
if (!plugin) return;
|
||||||
|
|
||||||
|
if (plugin.cleanup) plugin.cleanup();
|
||||||
|
|
||||||
|
set((state) => {
|
||||||
|
const { [pluginId]: removed, ...rest } = state.plugins;
|
||||||
|
// Note: complex cleanup of UI items registered by this plugin would ideally happen here.
|
||||||
|
// For MVP, we might just clear all or need items to be tagged with pluginId.
|
||||||
|
// Improvement: ToolbarItem should have pluginId.
|
||||||
|
return { plugins: rest };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_registerToolbarItem: (item) => {
|
||||||
|
set((state) => ({
|
||||||
|
toolbarItems: [...state.toolbarItems, item]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import type { AIMode, OnlineProvider, Theme } from '../types';
|
import type { AIMode, OnlineProvider, Theme } from '../types';
|
||||||
|
import { checkIsMobile } from '../hooks/useMobileDetect';
|
||||||
|
|
||||||
interface SettingsState {
|
interface SettingsState {
|
||||||
// AI Configuration
|
// AI Configuration
|
||||||
|
|
@ -39,8 +40,19 @@ const getInitialTheme = (): Theme => {
|
||||||
return 'dark';
|
return 'dark';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if we're on a mobile device for initial AI mode
|
||||||
|
const getInitialAiMode = (): AIMode => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const stored = localStorage.getItem('flowgen_ai_mode') as AIMode | null;
|
||||||
|
if (stored) return stored;
|
||||||
|
// Default to online (cloud) on mobile for better performance
|
||||||
|
if (checkIsMobile()) return 'online';
|
||||||
|
}
|
||||||
|
return 'offline';
|
||||||
|
};
|
||||||
|
|
||||||
const initialSettings = {
|
const initialSettings = {
|
||||||
aiMode: 'offline' as AIMode,
|
aiMode: getInitialAiMode(),
|
||||||
onlineProvider: 'openai' as OnlineProvider,
|
onlineProvider: 'openai' as OnlineProvider,
|
||||||
apiKey: '',
|
apiKey: '',
|
||||||
ollamaUrl: 'http://localhost:11434',
|
ollamaUrl: 'http://localhost:11434',
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,13 @@ interface UIState {
|
||||||
leftPanelOpen: boolean;
|
leftPanelOpen: boolean;
|
||||||
rightPanelOpen: boolean;
|
rightPanelOpen: boolean;
|
||||||
focusMode: boolean;
|
focusMode: boolean;
|
||||||
|
mobileEditorOpen: boolean; // Mobile bottom sheet state
|
||||||
|
|
||||||
|
// Input Panel persistence
|
||||||
|
inputDescription: string;
|
||||||
|
inputActiveTab: 'image' | 'code' | 'describe';
|
||||||
|
inputImageUrl: string | null;
|
||||||
|
mermaidCode: string;
|
||||||
|
|
||||||
// Selection
|
// Selection
|
||||||
selectedNode: Node | null;
|
selectedNode: Node | null;
|
||||||
|
|
@ -26,6 +33,14 @@ interface UIState {
|
||||||
setRightPanelOpen: (open: boolean) => void;
|
setRightPanelOpen: (open: boolean) => void;
|
||||||
setFocusMode: (focusMode: boolean) => void;
|
setFocusMode: (focusMode: boolean) => void;
|
||||||
toggleFocusMode: () => void;
|
toggleFocusMode: () => void;
|
||||||
|
setMobileEditorOpen: (open: boolean) => void;
|
||||||
|
|
||||||
|
// Input Panel actions
|
||||||
|
setInputDescription: (description: string) => void;
|
||||||
|
setInputActiveTab: (tab: 'image' | 'code' | 'describe') => void;
|
||||||
|
setInputImageUrl: (url: string | null) => void;
|
||||||
|
setMermaidCode: (code: string) => void;
|
||||||
|
clearInputs: () => void;
|
||||||
|
|
||||||
setSelectedNode: (node: Node | null) => void;
|
setSelectedNode: (node: Node | null) => void;
|
||||||
|
|
||||||
|
|
@ -38,13 +53,18 @@ interface UIState {
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_FILTERS = ['filter-client', 'filter-server', 'filter-db', 'filter-other'];
|
const DEFAULT_FILTERS = ['filter-client', 'filter-server', 'filter-db', 'filter-other', 'filter-group'];
|
||||||
|
|
||||||
export const useUIStore = create<UIState>()((set, get) => ({
|
export const useUIStore = create<UIState>()((set, get) => ({
|
||||||
// Initial state
|
// Initial state
|
||||||
leftPanelOpen: true,
|
leftPanelOpen: true,
|
||||||
rightPanelOpen: false,
|
rightPanelOpen: false,
|
||||||
focusMode: false,
|
focusMode: false,
|
||||||
|
mobileEditorOpen: false,
|
||||||
|
inputDescription: '',
|
||||||
|
inputActiveTab: 'image',
|
||||||
|
inputImageUrl: null,
|
||||||
|
mermaidCode: '',
|
||||||
selectedNode: null,
|
selectedNode: null,
|
||||||
activeFilters: DEFAULT_FILTERS,
|
activeFilters: DEFAULT_FILTERS,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
|
@ -55,6 +75,14 @@ export const useUIStore = create<UIState>()((set, get) => ({
|
||||||
setRightPanelOpen: (rightPanelOpen) => set({ rightPanelOpen }),
|
setRightPanelOpen: (rightPanelOpen) => set({ rightPanelOpen }),
|
||||||
setFocusMode: (focusMode) => set({ focusMode }),
|
setFocusMode: (focusMode) => set({ focusMode }),
|
||||||
toggleFocusMode: () => set({ focusMode: !get().focusMode }),
|
toggleFocusMode: () => set({ focusMode: !get().focusMode }),
|
||||||
|
setMobileEditorOpen: (mobileEditorOpen) => set({ mobileEditorOpen }),
|
||||||
|
|
||||||
|
// Input Panel actions
|
||||||
|
setInputDescription: (inputDescription) => set({ inputDescription }),
|
||||||
|
setInputActiveTab: (inputActiveTab) => set({ inputActiveTab }),
|
||||||
|
setInputImageUrl: (inputImageUrl) => set({ inputImageUrl }),
|
||||||
|
setMermaidCode: (mermaidCode) => set({ mermaidCode }),
|
||||||
|
clearInputs: () => set({ inputDescription: '', inputImageUrl: null, mermaidCode: '' }),
|
||||||
|
|
||||||
// Selection actions
|
// Selection actions
|
||||||
setSelectedNode: (selectedNode) => {
|
setSelectedNode: (selectedNode) => {
|
||||||
|
|
|
||||||
|
|
@ -339,3 +339,81 @@
|
||||||
background: rgba(139, 92, 246, 0.15);
|
background: rgba(139, 92, 246, 0.15);
|
||||||
color: #8b5cf6;
|
color: #8b5cf6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Mobile Touch Target Sizes
|
||||||
|
Ensure minimum 44x44px for accessibility
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
/* Increase button touch targets on mobile */
|
||||||
|
.btn-icon {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary,
|
||||||
|
.btn-secondary {
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar and panel buttons */
|
||||||
|
.glass-panel button {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form inputs */
|
||||||
|
.input-base {
|
||||||
|
min-height: 48px;
|
||||||
|
font-size: 1rem; /* Prevent zoom on iOS */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs and navigation items */
|
||||||
|
button[role="tab"],
|
||||||
|
nav button,
|
||||||
|
nav a {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile-specific animation for bottom sheet */
|
||||||
|
@keyframes slide-up-sheet {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.animate-slide-up {
|
||||||
|
animation: slide-up-sheet 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fade in animation */
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fade-in 0.2s ease-out forwards;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue