diff --git a/package-lock.json b/package-lock.json
index 5f8e4c0..90bbee7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,6 +14,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@fastify/cors": "^11.2.0",
"@fastify/static": "^9.0.0",
+ "@radix-ui/react-dialog": "^1.1.23",
"@visactor/react-vchart": "^2.0.22",
"better-sqlite3": "^12.10.0",
"dotenv": "^17.4.2",
@@ -3505,6 +3506,320 @@
"node": ">=14"
}
},
+ "node_modules/@radix-ui/primitive": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz",
+ "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/react-compose-refs": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
+ "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz",
+ "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog": {
+ "version": "1.1.23",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz",
+ "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.2.2",
+ "@radix-ui/react-dismissable-layer": "1.1.19",
+ "@radix-ui/react-focus-guards": "1.1.6",
+ "@radix-ui/react-focus-scope": "1.1.16",
+ "@radix-ui/react-id": "1.1.4",
+ "@radix-ui/react-portal": "1.1.17",
+ "@radix-ui/react-presence": "1.1.10",
+ "@radix-ui/react-primitive": "2.1.10",
+ "@radix-ui/react-slot": "1.3.3",
+ "@radix-ui/react-use-controllable-state": "1.2.6",
+ "@radix-ui/react-use-layout-effect": "1.1.4",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.7.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dismissable-layer": {
+ "version": "1.1.19",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz",
+ "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.10",
+ "@radix-ui/react-use-callback-ref": "1.1.4",
+ "@radix-ui/react-use-effect-event": "0.0.5"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-guards": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz",
+ "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-scope": {
+ "version": "1.1.16",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz",
+ "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.10",
+ "@radix-ui/react-use-callback-ref": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-id": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz",
+ "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-portal": {
+ "version": "1.1.17",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz",
+ "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.10",
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-presence": {
+ "version": "1.1.10",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz",
+ "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.10",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz",
+ "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.3.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-slot": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz",
+ "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.5"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-callback-ref": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz",
+ "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-controllable-state": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz",
+ "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.7",
+ "@radix-ui/react-use-effect-event": "0.0.5",
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-effect-event": {
+ "version": "0.0.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz",
+ "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-layout-effect": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
+ "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@resvg/resvg-js": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.4.1.tgz",
@@ -5131,14 +5446,14 @@
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.28",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -5149,7 +5464,7 @@
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
@@ -6205,6 +6520,18 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
+ "node_modules/aria-hidden": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
+ "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/array-source": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/array-source/-/array-source-0.0.4.tgz",
@@ -7277,7 +7604,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/cytoscape": {
@@ -8112,6 +8439,12 @@
"license": "MIT",
"optional": true
},
+ "node_modules/detect-node-es": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
+ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
+ "license": "MIT"
+ },
"node_modules/devlop": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
@@ -9653,6 +9986,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/get-nonce": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
+ "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/get-port": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz",
@@ -12623,6 +12965,53 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-remove-scroll": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
+ "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "react-remove-scroll-bar": "^2.3.7",
+ "react-style-singleton": "^2.2.3",
+ "tslib": "^2.1.0",
+ "use-callback-ref": "^1.3.3",
+ "use-sidecar": "^1.1.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-remove-scroll-bar": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
+ "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
+ "license": "MIT",
+ "dependencies": {
+ "react-style-singleton": "^2.2.2",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-router": {
"version": "7.15.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz",
@@ -12677,6 +13066,28 @@
"react": "^16.0.0 || ^17.0.0 || ^18.0.0"
}
},
+ "node_modules/react-style-singleton": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
+ "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
+ "license": "MIT",
+ "dependencies": {
+ "get-nonce": "^1.0.0",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-test-renderer": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-18.3.1.tgz",
@@ -14936,6 +15347,49 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/use-callback-ref": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
+ "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/use-sidecar": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
+ "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "detect-node-es": "^1.1.0",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/utf8-byte-length": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",
diff --git a/package.json b/package.json
index 8770073..72e327d 100644
--- a/package.json
+++ b/package.json
@@ -72,6 +72,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@fastify/cors": "^11.2.0",
"@fastify/static": "^9.0.0",
+ "@radix-ui/react-dialog": "^1.1.23",
"@visactor/react-vchart": "^2.0.22",
"better-sqlite3": "^12.10.0",
"dotenv": "^17.4.2",
diff --git a/src/web/components/CenteredModal.tsx b/src/web/components/CenteredModal.tsx
index e356472..49905dd 100644
--- a/src/web/components/CenteredModal.tsx
+++ b/src/web/components/CenteredModal.tsx
@@ -1,8 +1,9 @@
-import React, { useEffect } from 'react';
-import { createPortal } from 'react-dom';
-import { useAnimatedVisibility } from './useAnimatedVisibility.js';
+import type React from 'react';
+import { Dialog } from './ui/Dialog.js';
-type CenteredModalProps = {
+// Compatibility facade: the Dialog wrapper owns useAnimatedVisibility and
+// createPortal while preserving the modal-backdrop/modal-content class names.
+export type CenteredModalProps = {
open: boolean;
onClose: () => void;
title: React.ReactNode;
@@ -27,64 +28,20 @@ export default function CenteredModal({
closeOnEscape = false,
showCloseButton = true,
}: CenteredModalProps) {
- const presence = useAnimatedVisibility(open, 220);
- const canUsePortal = typeof document !== 'undefined'
- && !!document.body
- && typeof document.body.appendChild === 'function'
- && typeof document.body.removeChild === 'function';
-
- useEffect(() => {
- if (!open || !canUsePortal) return;
- const previousOverflow = document.body.style.overflow;
- document.body.style.overflow = 'hidden';
- return () => {
- document.body.style.overflow = previousOverflow;
- };
- }, [canUsePortal, open]);
-
- useEffect(() => {
- if (!open || !closeOnEscape || !canUsePortal) return;
- const handleKeydown = (event: KeyboardEvent) => {
- if (event.key === 'Escape') onClose();
- };
- document.addEventListener('keydown', handleKeydown);
- return () => {
- document.removeEventListener('keydown', handleKeydown);
- };
- }, [canUsePortal, closeOnEscape, open, onClose]);
-
- if (!presence.shouldRender) return null;
-
- const modal = (
-
-
event.stopPropagation()}
- >
-
-
{title}
- {showCloseButton ? (
-
- ) : null}
-
-
- {children}
-
- {footer ?
{footer}
: null}
-
-
+ {children}
+
);
-
- return canUsePortal ? createPortal(modal, document.body) : modal;
}
diff --git a/src/web/components/ChangeKeyModal.tsx b/src/web/components/ChangeKeyModal.tsx
index 3de4f30..db6cf3c 100644
--- a/src/web/components/ChangeKeyModal.tsx
+++ b/src/web/components/ChangeKeyModal.tsx
@@ -1,12 +1,11 @@
-import React, { useEffect, useState } from 'react';
-import { createPortal } from 'react-dom';
+import React, { useState } from 'react';
import { api } from '../api.js';
import { useToast } from './Toast.js';
import { persistAuthSession } from '../authSession.js';
-import { useAnimatedVisibility } from './useAnimatedVisibility.js';
+import CenteredModal from './CenteredModal.js';
+import { AsyncButton, Button } from './ui/Button.js';
export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClose: () => void }) {
- const presence = useAnimatedVisibility(open, 200);
const [oldToken, setOldToken] = useState('');
const [newToken, setNewToken] = useState('');
const [confirmToken, setConfirmToken] = useState('');
@@ -14,15 +13,6 @@ export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClo
const [error, setError] = useState('');
const toast = useToast();
- useEffect(() => {
- if (!open || typeof document === 'undefined') return;
- const previous = document.body.style.overflow;
- document.body.style.overflow = 'hidden';
- return () => {
- document.body.style.overflow = previous;
- };
- }, [open]);
-
const handleSubmit = async () => {
setError('');
if (!oldToken || !newToken || !confirmToken) {
@@ -51,27 +41,39 @@ export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClo
} else {
setError(res.message || '更新失败');
}
- } catch (e: any) {
- setError(e.message || '更新失败');
+ } catch (error: unknown) {
+ setError(error instanceof Error ? error.message : '更新失败');
} finally {
setSaving(false);
}
};
- if (!presence.shouldRender) return null;
-
const inputStyle: React.CSSProperties = {
width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)', fontSize: 13, outline: 'none',
background: 'var(--color-bg)', color: 'var(--color-text-primary)',
};
- const modal = (
-
-
e.stopPropagation()} style={{ maxWidth: 420 }}>
-
修改管理员 Token
-
-
+ return (
+
+
+ void handleSubmit()} variant="primary" loading={saving} loadingLabel="更新中...">
+ 确认修改
+
+ >
+ )}
+ >
+ <>
)}
-
-
-
-
-
-
-
-
+ >
+
);
-
- return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
}
diff --git a/src/web/components/DeleteConfirmModal.tsx b/src/web/components/DeleteConfirmModal.tsx
index 5c9f0d4..b67fb9a 100644
--- a/src/web/components/DeleteConfirmModal.tsx
+++ b/src/web/components/DeleteConfirmModal.tsx
@@ -1,5 +1,6 @@
import React from 'react';
import CenteredModal from './CenteredModal.js';
+import { AsyncButton, Button } from './ui/Button.js';
type DeleteConfirmModalProps = {
open: boolean;
@@ -31,12 +32,10 @@ export default function DeleteConfirmModal({
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
footer={(
<>
-
-
+
+
+ {confirmText}
+
>
)}
>
diff --git a/src/web/components/MobileDrawer.tsx b/src/web/components/MobileDrawer.tsx
index 6816ff0..b27a35e 100644
--- a/src/web/components/MobileDrawer.tsx
+++ b/src/web/components/MobileDrawer.tsx
@@ -1,13 +1,18 @@
-import React, { useEffect, useId, useMemo, useState, useCallback } from 'react';
-import { createPortal } from 'react-dom';
+import type React from 'react';
+import { Drawer } from './ui/Dialog.js';
-type MobileDrawerProps = {
+export type MobileDrawerProps = {
open: boolean;
onClose: () => void;
children: React.ReactNode;
title?: React.ReactNode;
closeLabel?: string;
side?: 'left' | 'right';
+ closeOnBackdrop?: boolean;
+ closeOnEscape?: boolean;
+ showCloseButton?: boolean;
+ ariaLabel?: string;
+ ariaDescribedBy?: string;
};
function MobileDrawer({
@@ -17,80 +22,28 @@ function MobileDrawer({
title,
closeLabel = '关闭导航',
side = 'left',
+ closeOnBackdrop = true,
+ closeOnEscape = true,
+ showCloseButton = true,
+ ariaLabel,
+ ariaDescribedBy,
}: MobileDrawerProps) {
- const [shouldRender, setShouldRender] = useState(open);
- const [isClosing, setIsClosing] = useState(false);
- const titleId = useId();
- const labelledBy = title ? titleId : undefined;
-
- useEffect(() => {
- if (open) {
- setShouldRender(true);
- setIsClosing(false);
- } else if (shouldRender) {
- setIsClosing(true);
- const timer = setTimeout(() => {
- setShouldRender(false);
- setIsClosing(false);
- }, 280);
- return () => clearTimeout(timer);
- }
- }, [open]);
-
- const handleClose = useCallback(() => {
- setIsClosing(true);
- onClose();
- }, [onClose]);
-
- useEffect(() => {
- if (!open || typeof document === 'undefined') return;
- const previousOverflow = document.body.style.overflow;
- document.body.style.overflow = 'hidden';
- return () => {
- document.body.style.overflow = previousOverflow;
- };
- }, [open]);
-
- useEffect(() => {
- if (!open || typeof document === 'undefined') return;
- const handleKeydown = (event: KeyboardEvent) => {
- if (event.key === 'Escape') {
- handleClose();
- }
- };
- document.addEventListener('keydown', handleKeydown);
- return () => {
- document.removeEventListener('keydown', handleKeydown);
- };
- }, [handleClose, open]);
-
- if (!shouldRender) return null;
-
- const drawer = (
-
-
-
-
- {title ? (
-
- {title}
-
- ) :
}
-
-
- {children}
-
-
+ return (
+
+ {children}
+
);
-
- const portalTarget = typeof document !== 'undefined' ? document.body : null;
- return portalTarget ? createPortal(drawer, portalTarget) : drawer;
}
export { MobileDrawer };
diff --git a/src/web/components/ModernSelect.test.tsx b/src/web/components/ModernSelect.test.tsx
index 0694433..074dd9e 100644
--- a/src/web/components/ModernSelect.test.tsx
+++ b/src/web/components/ModernSelect.test.tsx
@@ -1,4 +1,4 @@
-import { describe, expect, it } from 'vitest';
+import { describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import ModernSelect from './ModernSelect.js';
@@ -90,4 +90,47 @@ describe('ModernSelect', () => {
));
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual(['Gamma APIhttps://third.example.com']);
});
+
+ it('keeps keyboard navigation and selection inside the facade contract', async () => {
+ const onChange = vi.fn();
+ const root = create(
+
,
+ );
+ const trigger = root.root.find((node) => (
+ node.type === 'button'
+ && typeof node.props.className === 'string'
+ && node.props.className.includes('modern-select-trigger')
+ ));
+
+ await act(async () => {
+ trigger.props.onClick();
+ });
+ expect(trigger.props['aria-expanded']).toBe(true);
+
+ await act(async () => {
+ trigger.props.onKeyDown({ key: 'ArrowDown', preventDefault: vi.fn() });
+ });
+ let highlighted = root.root.findAll((node) => (
+ node.type === 'button'
+ && typeof node.props.className === 'string'
+ && node.props.className.includes('modern-select-option')
+ && node.props.className.includes('is-highlighted')
+ ));
+ expect(highlighted).toHaveLength(1);
+ expect(collectInstanceText(highlighted[0])).toContain('Beta');
+
+ await act(async () => {
+ trigger.props.onKeyDown({ key: 'Enter', preventDefault: vi.fn() });
+ });
+ expect(onChange).toHaveBeenCalledWith('beta');
+ expect(trigger.props['aria-expanded']).toBe(false);
+ });
});
diff --git a/src/web/components/ModernSelect.tsx b/src/web/components/ModernSelect.tsx
index 363410b..af0ec0a 100644
--- a/src/web/components/ModernSelect.tsx
+++ b/src/web/components/ModernSelect.tsx
@@ -1,202 +1,9 @@
-import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
-
-type ModernSelectOption = {
- value: string;
- label: string;
- description?: string;
- disabled?: boolean;
- iconNode?: ReactNode;
- iconUrl?: string;
- iconText?: string;
-};
-
-type ModernSelectProps = {
- value: string;
- onChange: (value: string) => void;
- options: ModernSelectOption[];
- 'data-testid'?: string;
- placeholder?: string;
- disabled?: boolean;
- emptyLabel?: string;
- menuMaxHeight?: number;
- className?: string;
- size?: 'md' | 'sm';
- searchable?: boolean;
- searchPlaceholder?: string;
-};
-
-export default function ModernSelect({
- value,
- onChange,
- options,
- 'data-testid': dataTestId,
- placeholder = 'Select',
- disabled = false,
- emptyLabel = 'No options',
- menuMaxHeight = 280,
- className = '',
- size = 'md',
- searchable = false,
- searchPlaceholder = 'Search...',
-}: ModernSelectProps) {
- const [open, setOpen] = useState(false);
- const [searchQuery, setSearchQuery] = useState('');
- const rootRef = useRef
(null);
-
- const selected = useMemo(
- () => options.find((item) => item.value === value),
- [options, value],
- );
-
- const visibleOptions = useMemo(() => {
- const query = searchQuery.trim().toLowerCase();
- if (!searchable || !query) return options;
-
- return options.filter((item) => {
- const haystack = [
- item.label,
- item.description,
- item.value,
- ]
- .filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
- .join(' ')
- .toLowerCase();
- return haystack.includes(query);
- });
- }, [options, searchQuery, searchable]);
-
- useEffect(() => {
- if (!open) return;
- if (typeof document === 'undefined') return;
-
- const handleOutsideClick = (event: MouseEvent) => {
- if (!rootRef.current) return;
- if (!rootRef.current.contains(event.target as Node)) {
- setOpen(false);
- }
- };
-
- const handleEscape = (event: KeyboardEvent) => {
- if (event.key === 'Escape') setOpen(false);
- };
-
- document.addEventListener('mousedown', handleOutsideClick);
- document.addEventListener('keydown', handleEscape);
- return () => {
- document.removeEventListener('mousedown', handleOutsideClick);
- document.removeEventListener('keydown', handleEscape);
- };
- }, [open]);
-
- useEffect(() => {
- if (disabled) setOpen(false);
- }, [disabled]);
-
- useEffect(() => {
- if (!open && searchQuery) {
- setSearchQuery('');
- }
- }, [open, searchQuery]);
-
- const renderOptionIcon = (item: ModernSelectOption) => {
- if (item.iconNode) {
- return item.iconNode;
- }
- if (item.iconUrl) {
- return
;
- }
- if (item.iconText) {
- return {item.iconText};
- }
- return null;
- };
-
- return (
-
-
-
-
- {searchable && (
-
- setSearchQuery(event.target.value)}
- placeholder={searchPlaceholder}
- className="modern-select-search-input"
- />
-
- )}
-
- {visibleOptions.length === 0 ? (
-
{emptyLabel}
- ) : (
- visibleOptions.map((item) => {
- const active = item.value === value;
- return (
-
- );
- })
- )}
-
-
- );
-}
+/**
+ * Backward-compatible import path. Keep this file deliberately tiny so the
+ * project-owned `components/ui` layer remains the single implementation.
+ */
+export {
+ default,
+ type ModernSelectOption,
+ type ModernSelectProps,
+} from './ui/Select.js';
diff --git a/src/web/components/ResponsiveBatchActionBar.architecture.test.ts b/src/web/components/ResponsiveBatchActionBar.architecture.test.ts
index bb120dd..aa5e2bc 100644
--- a/src/web/components/ResponsiveBatchActionBar.architecture.test.ts
+++ b/src/web/components/ResponsiveBatchActionBar.architecture.test.ts
@@ -10,7 +10,7 @@ describe('ResponsiveBatchActionBar page adoption', () => {
it('is used by the repeated admin list pages instead of open-coded mobile and desktop batch wrappers', () => {
expect(readPageSource('src/web/pages/Accounts.tsx')).toContain('ResponsiveBatchActionBar');
expect(readPageSource('src/web/pages/Sites.tsx')).toContain('ResponsiveBatchActionBar');
- expect(readPageSource('src/web/pages/Tokens.tsx')).toContain('ResponsiveBatchActionBar');
+ expect(readPageSource('src/web/pages/tokens/TokensPanel.tsx')).toContain('ResponsiveBatchActionBar');
expect(readPageSource('src/web/pages/DownstreamKeys.tsx')).toContain('ResponsiveBatchActionBar');
});
});
diff --git a/src/web/components/SiteCreatedModal.tsx b/src/web/components/SiteCreatedModal.tsx
index 0c300e8..c5a4ecd 100644
--- a/src/web/components/SiteCreatedModal.tsx
+++ b/src/web/components/SiteCreatedModal.tsx
@@ -1,4 +1,5 @@
import CenteredModal from './CenteredModal.js';
+import { Button } from './ui/Button.js';
import { getSiteInitializationPreset } from '../../shared/siteInitializationPresets.js';
type NextStepChoice = 'session' | 'apikey' | 'later';
@@ -56,22 +57,22 @@ export default function SiteCreatedModal({
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
footer={(
<>
-
-
- {savingSchedule ? <> 保存中...> : '保存定时任务'}
-
+
+ 保存定时任务
+
diff --git a/src/web/pages/Tokens.tsx b/src/web/pages/Tokens.tsx
index 6c7309f..50112e5 100644
--- a/src/web/pages/Tokens.tsx
+++ b/src/web/pages/Tokens.tsx
@@ -1,1460 +1,10 @@
-import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { Navigate, useLocation, useNavigate } from 'react-router-dom';
-import { api } from '../api.js';
-import CenteredModal from '../components/CenteredModal.js';
-import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.js';
-import ResponsiveFormGrid from '../components/ResponsiveFormGrid.js';
-import ResponsiveBatchActionBar from '../components/ResponsiveBatchActionBar.js';
-import { useToast } from '../components/Toast.js';
-import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
-import {
- isTruthyFlag,
- parsePositiveInt,
- resolveAccountCredentialMode,
-} from './helpers/accountConnection.js';
-import ModernSelect from '../components/ModernSelect.js';
-import { MobileCard, MobileField } from '../components/MobileCard.js';
-import { useIsMobile } from '../components/useIsMobile.js';
-import DeleteConfirmModal from '../components/DeleteConfirmModal.js';
-import { clearFocusParams, readFocusTokenId } from './helpers/navigationFocus.js';
-import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js';
-import { tr } from '../i18n.js';
+import { Navigate, useLocation } from 'react-router-dom';
-type SyncStatus = 'success' | 'skipped' | 'failed';
-type TokensPanelProps = {
- embedded?: boolean;
- onEmbeddedActionsChange?: (actions: React.ReactNode | null) => void;
-};
-
-type AccountTokenSyncResult = {
- status?: string;
- success?: boolean;
- synced?: boolean;
- message?: string;
- reason?: string;
- created?: number;
- updated?: number;
- maskedPending?: number;
- pendingTokenIds?: number[];
- accountId?: number;
- accountName?: string;
- account?: {
- id?: number;
- username?: string;
- };
-};
-
-type SyncableAccount = {
- id: number;
- username?: string | null;
- accessToken?: string | null;
- status?: string | null;
- credentialMode?: string | null;
- capabilities?: {
- proxyOnly?: boolean;
- } | null;
- site?: {
- status?: string | null;
- name?: string | null;
- } | null;
-};
-
-const ACCOUNT_SELECT_SEARCH_PLACEHOLDER = '筛选账号(名称 / 站点)';
-
-const isAccountSyncable = (account: any) =>
- resolveAccountCredentialMode(account) === 'session'
- && account?.status === 'active'
- && account?.site?.status !== 'disabled';
-
-const resolveSyncStatus = (result: AccountTokenSyncResult | null | undefined): SyncStatus => {
- const raw = String(result?.status || '').toLowerCase();
- if (raw === 'failed' || raw === 'error') return 'failed';
- if (raw === 'skipped' || raw === 'skip') return 'skipped';
- if (raw === 'success' || raw === 'ok' || raw === 'succeeded') return 'success';
- if (result?.success === false) return 'failed';
- if (result?.synced === false) return 'skipped';
- return 'success';
-};
-
-const resolveSyncMessage = (result: AccountTokenSyncResult | null | undefined, fallback: string) => {
- const message = typeof result?.message === 'string' ? result.message.trim() : '';
- return message || fallback;
-};
-
-const isMaskedPendingToken = (token: any): boolean => token?.valueStatus === 'masked_pending';
-
-const isMaskedPendingSyncResult = (result: AccountTokenSyncResult | null | undefined) =>
- String(result?.reason || '').trim().toLowerCase() === 'upstream_masked_tokens'
- && Number(result?.maskedPending || 0) > 0;
-
-const resolveAccountLabel = (result: AccountTokenSyncResult | null | undefined) => {
- const name = typeof result?.accountName === 'string' ? result.accountName.trim() : '';
- if (name) return name;
- const username = typeof result?.account?.username === 'string' ? result.account.username.trim() : '';
- if (username) return username;
- const accountId = result?.accountId ?? result?.account?.id;
- if (accountId) return `#${accountId}`;
- return '未知账号';
-};
-
-async function copyText(text: string) {
- if (navigator.clipboard?.writeText) {
- await navigator.clipboard.writeText(text);
- return;
- }
-
- const textarea = document.createElement('textarea');
- textarea.value = text;
- textarea.style.position = 'fixed';
- textarea.style.opacity = '0';
- textarea.style.left = '-9999px';
- document.body.appendChild(textarea);
- textarea.focus();
- textarea.select();
- document.execCommand('copy');
- document.body.removeChild(textarea);
-}
-
-export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: TokensPanelProps) {
- const location = useLocation();
- const navigate = useNavigate();
- const isMobile = useIsMobile();
- const initialCreateForm = {
- accountId: 0,
- name: '',
- group: 'default',
- unlimitedQuota: true,
- remainQuota: '',
- expiredTime: '',
- allowIps: '',
- };
-
- const [tokens, setTokens] = useState([]);
- const [accounts, setAccounts] = useState([]);
- const [loading, setLoading] = useState(true);
- const [showAdd, setShowAdd] = useState(false);
- const [syncingAccountId, setSyncingAccountId] = useState(0);
- const [syncing, setSyncing] = useState(false);
- const [syncingAll, setSyncingAll] = useState(false);
- const [saving, setSaving] = useState(false);
- const [savingEdit, setSavingEdit] = useState(false);
- const [editingToken, setEditingToken] = useState(null);
- const [editingTokenValueLoading, setEditingTokenValueLoading] = useState(false);
- const [editingTokenPendingMessage, setEditingTokenPendingMessage] = useState('');
- const [createHintModelName, setCreateHintModelName] = useState('');
- const [highlightTokenId, setHighlightTokenId] = useState(null);
- const [pendingAutoOpenTokenId, setPendingAutoOpenTokenId] = useState(null);
- const [rowLoading, setRowLoading] = useState>({});
- const [selectedTokenIds, setSelectedTokenIds] = useState([]);
- const [expandedTokenIds, setExpandedTokenIds] = useState([]);
- const [showMobileTools, setShowMobileTools] = useState(false);
- const [batchActionLoading, setBatchActionLoading] = useState(false);
- const [deleteConfirm, setDeleteConfirm] = useState(null);
- const [form, setForm] = useState(initialCreateForm);
- const [editForm, setEditForm] = useState({
- name: '',
- token: '',
- group: 'default',
- enabled: true,
- isDefault: false,
- });
- const [groupOptions, setGroupOptions] = useState(['default']);
- const [groupLoading, setGroupLoading] = useState(false);
- const [editGroupOptions, setEditGroupOptions] = useState(['default']);
- const [editGroupLoading, setEditGroupLoading] = useState(false);
- const rowRefs = useRef