diff --git a/bun.lock b/bun.lock
index dbf9901..1d75457 100644
--- a/bun.lock
+++ b/bun.lock
@@ -43,7 +43,8 @@
"date-fns": "^4.1.0",
"drizzle-orm": "^0.41.0",
"lucide-react": "^0.536.0",
- "next": "^16.0.3",
+ "minio": "^8.0.6",
+ "next": "^16.0.10",
"next-auth": "^5.0.0-beta.29",
"postgres": "^3.4.4",
"react": "^19.0.0",
@@ -339,25 +340,25 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
- "@next/env": ["@next/env@16.0.3", "", {}, "sha512-IqgtY5Vwsm14mm/nmQaRMmywCU+yyMIYfk3/MHZ2ZTJvwVbBn3usZnjMi1GacrMVzVcAxJShTCpZlPs26EdEjQ=="],
+ "@next/env": ["@next/env@16.0.10", "", {}, "sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang=="],
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.4.5", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-YhbrlbEt0m4jJnXHMY/cCUDBAWgd5SaTa5mJjzOt82QwflAFfW/h3+COp2TfVSzhmscIZ5sg2WXt3MLziqCSCw=="],
- "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MOnbd92+OByu0p6QBAzq1ahVWzF6nyfiH07dQDez4/Nku7G249NjxDVyEfVhz8WkLiOEU+KFVnqtgcsfP2nLXg=="],
+ "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.0.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4XgdKtdVsaflErz+B5XeG0T5PeXKDdruDf3CRpnhN+8UebNa5N2H58+3GDgpn/9GBurrQ1uWW768FfscwYkJRg=="],
- "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-i70C4O1VmbTivYdRlk+5lj9xRc2BlK3oUikt3yJeHT1unL4LsNtN7UiOhVanFdc7vDAgZn1tV/9mQwMkWOJvHg=="],
+ "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.0.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-spbEObMvRKkQ3CkYVOME+ocPDFo5UqHb8EMTS78/0mQ+O1nqE8toHJVioZo4TvebATxgA8XMTHHrScPrn68OGw=="],
- "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-O88gCZ95sScwD00mn/AtalyCoykhhlokxH/wi1huFK+rmiP5LAYVs/i2ruk7xST6SuXN4NI5y4Xf5vepb2jf6A=="],
+ "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.0.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-uQtWE3X0iGB8apTIskOMi2w/MKONrPOUCi5yLO+v3O8Mb5c7K4Q5KD1jvTpTF5gJKa3VH/ijKjKUq9O9UhwOYw=="],
- "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-CEErFt78S/zYXzFIiv18iQCbRbLgBluS8z1TNDQoyPi8/Jr5qhR3e8XHAIxVxPBjDbEMITprqELVc5KTfFj0gg=="],
+ "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.0.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw=="],
- "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Tc3i+nwt6mQ+Dwzcri/WNDj56iWdycGVh5YwwklleClzPzz7UpfaMw1ci7bLl6GRYMXhWDBfe707EXNjKtiswQ=="],
+ "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.0.10", "", { "os": "linux", "cpu": "x64" }, "sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA=="],
- "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-zTh03Z/5PBBPdTurgEtr6nY0vI9KR9Ifp/jZCcHlODzwVOEKcKRBtQIGrkc7izFgOMuXDEJBmirwpGqdM/ZixA=="],
+ "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.0.10", "", { "os": "linux", "cpu": "x64" }, "sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g=="],
- "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.0.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-Jc1EHxtZovcJcg5zU43X3tuqzl/sS+CmLgjRP28ZT4vk869Ncm2NoF8qSTaL99gh6uOzgM99Shct06pSO6kA6g=="],
+ "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.0.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg=="],
- "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-N7EJ6zbxgIYpI/sWNzpVKRMbfEGgsWuOIvzkML7wxAAZhPk1Msxuo/JDu1PKjWGrAoOLaZcIX5s+/pF5LIbBBg=="],
+ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.0.10", "", { "os": "win32", "cpu": "x64" }, "sha512-E+njfCoFLb01RAFEnGZn6ERoOqhK1Gl3Lfz1Kjnj0Ulfu7oJbuMyvBKNj/bw8XZnenHDASlygTjZICQW+rYW1Q=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
@@ -697,6 +698,8 @@
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
+ "@zxing/text-encoding": ["@zxing/text-encoding@0.9.0", "", {}, "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA=="],
+
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@@ -731,6 +734,8 @@
"ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="],
+ "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
+
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
@@ -747,14 +752,20 @@
"bl": ["bl@5.1.0", "", { "dependencies": { "buffer": "^6.0.3", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ=="],
+ "block-stream2": ["block-stream2@2.1.0", "", { "dependencies": { "readable-stream": "^3.4.0" } }, "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg=="],
+
"bowser": ["bowser@2.11.0", "", {}, "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
+ "browser-or-node": ["browser-or-node@2.1.1", "", {}, "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg=="],
+
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
+ "buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="],
+
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
@@ -813,6 +824,8 @@
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
+ "decode-uri-component": ["decode-uri-component@0.2.2", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="],
+
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="],
@@ -895,6 +908,8 @@
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
+ "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
+
"execa": ["execa@7.2.0", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.1", "human-signals": "^4.3.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^3.0.7", "strip-final-newline": "^3.0.0" } }, "sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
@@ -905,7 +920,7 @@
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
- "fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="],
+ "fast-xml-parser": ["fast-xml-parser@4.5.3", "", { "dependencies": { "strnum": "^1.1.1" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig=="],
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
@@ -917,6 +932,8 @@
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
+ "filter-obj": ["filter-obj@1.1.0", "", {}, "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ=="],
+
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
@@ -989,6 +1006,10 @@
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
+ "ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
+
+ "is-arguments": ["is-arguments@1.2.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="],
+
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
"is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="],
@@ -1109,6 +1130,8 @@
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
+ "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
+
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"log-symbols": ["log-symbols@5.1.0", "", { "dependencies": { "chalk": "^5.0.0", "is-unicode-supported": "^1.1.0" } }, "sha512-l0x2DvrW294C9uDCoQe1VSU4gf529FkSZ6leBl4TiqZH/e+0R7hSfHQBNut2mNygDgHwvYHfFLn6Oxb3VWj2rA=="],
@@ -1127,12 +1150,18 @@
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
+ "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
+
+ "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
+
"mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="],
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
+ "minio": ["minio@8.0.6", "", { "dependencies": { "async": "^3.2.4", "block-stream2": "^2.1.0", "browser-or-node": "^2.1.1", "buffer-crc32": "^1.0.0", "eventemitter3": "^5.0.1", "fast-xml-parser": "^4.4.1", "ipaddr.js": "^2.0.1", "lodash": "^4.17.21", "mime-types": "^2.1.35", "query-string": "^7.1.3", "stream-json": "^1.8.0", "through2": "^4.0.2", "web-encoding": "^1.1.5", "xml2js": "^0.5.0 || ^0.6.2" } }, "sha512-sOeh2/b/XprRmEtYsnNRFtOqNRTPDvYtMWh+spWlfsuCV/+IdxNeKVUMKLqI7b5Dr07ZqCPuaRGU/rB9pZYVdQ=="],
+
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="],
@@ -1147,7 +1176,7 @@
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
- "next": ["next@16.0.3", "", { "dependencies": { "@next/env": "16.0.3", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.0.3", "@next/swc-darwin-x64": "16.0.3", "@next/swc-linux-arm64-gnu": "16.0.3", "@next/swc-linux-arm64-musl": "16.0.3", "@next/swc-linux-x64-gnu": "16.0.3", "@next/swc-linux-x64-musl": "16.0.3", "@next/swc-win32-arm64-msvc": "16.0.3", "@next/swc-win32-x64-msvc": "16.0.3", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-Ka0/iNBblPFcIubTA1Jjh6gvwqfjrGq1Y2MTI5lbjeLIAfmC+p5bQmojpRZqgHHVu5cG4+qdIiwXiBSm/8lZ3w=="],
+ "next": ["next@16.0.10", "", { "dependencies": { "@next/env": "16.0.10", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.0.10", "@next/swc-darwin-x64": "16.0.10", "@next/swc-linux-arm64-gnu": "16.0.10", "@next/swc-linux-arm64-musl": "16.0.10", "@next/swc-linux-x64-gnu": "16.0.10", "@next/swc-linux-x64-musl": "16.0.10", "@next/swc-win32-arm64-msvc": "16.0.10", "@next/swc-win32-x64-msvc": "16.0.10", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-RtWh5PUgI+vxlV3HdR+IfWA1UUHu0+Ram/JBO4vWB54cVPentCD0e+lxyAYEsDTqGGMg7qpjhKh6dc6aW7W/sA=="],
"next-auth": ["next-auth@5.0.0-beta.29", "", { "dependencies": { "@auth/core": "0.40.0" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "next": "^14.0.0-0 || ^15.0.0-0", "nodemailer": "^6.6.5", "react": "^18.2.0 || ^19.0.0-0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-Ukpnuk3NMc/LiOl32njZPySk7pABEzbjhMUFd5/n10I0ZNC7NCuVv8IY2JgbDek2t/PUOifQEoUiOOTLy4os5A=="],
@@ -1221,6 +1250,8 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
+ "query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="],
+
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
@@ -1265,6 +1296,8 @@
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
+ "sax": ["sax@1.4.3", "", {}, "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ=="],
+
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
"semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
@@ -1305,12 +1338,20 @@
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
+ "split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="],
+
"stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
"stdin-discarder": ["stdin-discarder@0.1.0", "", { "dependencies": { "bl": "^5.0.0" } }, "sha512-xhV7w8S+bUwlPTb4bAOUQhv8/cSS5offJuX8GQGq32ONF0ZtDWKfkdomM3HMRA+LhX6um/FZ0COqlwsjD53LeQ=="],
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
+ "stream-chain": ["stream-chain@2.2.5", "", {}, "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA=="],
+
+ "stream-json": ["stream-json@1.9.1", "", { "dependencies": { "stream-chain": "^2.2.5" } }, "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw=="],
+
+ "strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="],
+
"string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="],
"string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="],
@@ -1333,7 +1374,7 @@
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
- "strnum": ["strnum@2.1.1", "", {}, "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw=="],
+ "strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="],
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
@@ -1351,6 +1392,8 @@
"tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="],
+ "through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="],
+
"tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
@@ -1395,12 +1438,16 @@
"use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="],
+ "util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="],
+
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
"wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
+ "web-encoding": ["web-encoding@1.1.5", "", { "dependencies": { "util": "^0.12.3" }, "optionalDependencies": { "@zxing/text-encoding": "0.9.0" } }, "sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA=="],
+
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
@@ -1417,6 +1464,10 @@
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
+ "xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
+
+ "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
+
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
@@ -1431,6 +1482,8 @@
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
+ "@aws-sdk/core/fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="],
+
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
@@ -1507,6 +1560,8 @@
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
+ "@aws-sdk/core/fast-xml-parser/strnum": ["strnum@2.1.1", "", {}, "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw=="],
+
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
diff --git a/docker-compose.yml b/docker-compose.yml
index e063a86..c57b270 100755
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -17,18 +17,18 @@ services:
timeout: 5s
retries: 5
- # minio:
- # image: minio/minio
- # ports:
- # - "9000:9000" # API
- # - "9001:9001" # Console
- # environment:
- # MINIO_ROOT_USER: minioadmin
- # MINIO_ROOT_PASSWORD: minioadmin
- # volumes:
- # - minio_data:/data
- # command: server --console-address ":9001" /data
+ minio:
+ image: minio/minio
+ ports:
+ - "9000:9000" # API
+ - "9001:9001" # Console
+ environment:
+ MINIO_ROOT_USER: minioadmin
+ MINIO_ROOT_PASSWORD: minioadmin
+ volumes:
+ - minio_data:/data
+ command: server --console-address ":9001" /data
volumes:
postgres_data:
- # minio_data:
+ minio_data:
diff --git a/package.json b/package.json
index 97edc06..bc01768 100755
--- a/package.json
+++ b/package.json
@@ -62,7 +62,8 @@
"date-fns": "^4.1.0",
"drizzle-orm": "^0.41.0",
"lucide-react": "^0.536.0",
- "next": "^16.0.3",
+ "minio": "^8.0.6",
+ "next": "^16.0.10",
"next-auth": "^5.0.0-beta.29",
"postgres": "^3.4.4",
"react": "^19.0.0",
diff --git a/plugin_dump.json b/plugin_dump.json
new file mode 100644
index 0000000..09249bb
--- /dev/null
+++ b/plugin_dump.json
@@ -0,0 +1,927 @@
+ jsonb_pretty
+---------------------------------------------------------------------------------------
+ [ +
+ { +
+ "id": "walk_velocity", +
+ "icon": "navigation", +
+ "name": "Walk with Velocity", +
+ "ros2": { +
+ "qos": { +
+ "depth": 1, +
+ "history": "keep_last", +
+ "durability": "volatile", +
+ "reliability": "reliable" +
+ }, +
+ "topic": "/cmd_vel", +
+ "messageType": "geometry_msgs/msg/Twist", +
+ "payloadMapping": { +
+ "type": "transform", +
+ "transformFn": "transformToTwist" +
+ } +
+ }, +
+ "timeout": 5000, +
+ "category": "movement", +
+ "retryable": true, +
+ "description": "Control robot walking with linear and angular velocities", +
+ "parameterSchema": { +
+ "type": "object", +
+ "required": [ +
+ "linear", +
+ "angular" +
+ ], +
+ "properties": { +
+ "linear": { +
+ "type": "number", +
+ "default": 0, +
+ "maximum": 0.55, +
+ "minimum": -0.55, +
+ "description": "Forward velocity in m/s" +
+ }, +
+ "angular": { +
+ "type": "number", +
+ "default": 0, +
+ "maximum": 2, +
+ "minimum": -2, +
+ "description": "Angular velocity in rad/s" +
+ } +
+ } +
+ } +
+ }, +
+ { +
+ "id": "walk_forward", +
+ "icon": "arrow-up", +
+ "name": "Walk Forward", +
+ "ros2": { +
+ "topic": "/cmd_vel", +
+ "messageType": "geometry_msgs/msg/Twist", +
+ "payloadMapping": { +
+ "type": "static", +
+ "payload": { +
+ "linear": { +
+ "x": "{{speed}}", +
+ "y": 0, +
+ "z": 0 +
+ }, +
+ "angular": { +
+ "x": 0, +
+ "y": 0, +
+ "z": 0 +
+ } +
+ } +
+ } +
+ }, +
+ "timeout": 30000, +
+ "category": "movement", +
+ "retryable": true, +
+ "description": "Make the robot walk forward at specified speed", +
+ "parameterSchema": { +
+ "type": "object", +
+ "required": [ +
+ "speed" +
+ ], +
+ "properties": { +
+ "speed": { +
+ "type": "number", +
+ "default": 0.1, +
+ "maximum": 0.3, +
+ "minimum": 0.01, +
+ "description": "Walking speed in m/s" +
+ }, +
+ "duration": { +
+ "type": "number", +
+ "default": 0, +
+ "maximum": 30, +
+ "minimum": 0, +
+ "description": "Duration to walk in seconds (0 = indefinite)" +
+ } +
+ } +
+ } +
+ }, +
+ { +
+ "id": "walk_backward", +
+ "icon": "arrow-down", +
+ "name": "Walk Backward", +
+ "ros2": { +
+ "topic": "/cmd_vel", +
+ "messageType": "geometry_msgs/msg/Twist", +
+ "payloadMapping": { +
+ "type": "static", +
+ "payload": { +
+ "linear": { +
+ "x": "-{{speed}}", +
+ "y": 0, +
+ "z": 0 +
+ }, +
+ "angular": { +
+ "x": 0, +
+ "y": 0, +
+ "z": 0 +
+ } +
+ } +
+ } +
+ }, +
+ "timeout": 30000, +
+ "category": "movement", +
+ "retryable": true, +
+ "description": "Make the robot walk backward at specified speed", +
+ "parameterSchema": { +
+ "type": "object", +
+ "required": [ +
+ "speed" +
+ ], +
+ "properties": { +
+ "speed": { +
+ "type": "number", +
+ "default": 0.1, +
+ "maximum": 0.3, +
+ "minimum": 0.01, +
+ "description": "Walking speed in m/s" +
+ }, +
+ "duration": { +
+ "type": "number", +
+ "default": 0, +
+ "maximum": 30, +
+ "minimum": 0, +
+ "description": "Duration to walk in seconds (0 = indefinite)" +
+ } +
+ } +
+ } +
+ }, +
+ { +
+ "id": "turn_left", +
+ "icon": "rotate-ccw", +
+ "name": "Turn Left", +
+ "ros2": { +
+ "topic": "/cmd_vel", +
+ "messageType": "geometry_msgs/msg/Twist", +
+ "payloadMapping": { +
+ "type": "static", +
+ "payload": { +
+ "linear": { +
+ "x": 0, +
+ "y": 0, +
+ "z": 0 +
+ }, +
+ "angular": { +
+ "x": 0, +
+ "y": 0, +
+ "z": "{{speed}}" +
+ } +
+ } +
+ } +
+ }, +
+ "timeout": 30000, +
+ "category": "movement", +
+ "retryable": true, +
+ "description": "Make the robot turn left at specified angular speed", +
+ "parameterSchema": { +
+ "type": "object", +
+ "required": [ +
+ "speed" +
+ ], +
+ "properties": { +
+ "speed": { +
+ "type": "number", +
+ "default": 0.3, +
+ "maximum": 1, +
+ "minimum": 0.1, +
+ "description": "Angular speed in rad/s" +
+ }, +
+ "duration": { +
+ "type": "number", +
+ "default": 0, +
+ "maximum": 30, +
+ "minimum": 0, +
+ "description": "Duration to turn in seconds (0 = indefinite)" +
+ } +
+ } +
+ } +
+ }, +
+ { +
+ "id": "turn_right", +
+ "icon": "rotate-cw", +
+ "name": "Turn Right", +
+ "ros2": { +
+ "topic": "/cmd_vel", +
+ "messageType": "geometry_msgs/msg/Twist", +
+ "payloadMapping": { +
+ "type": "static", +
+ "payload": { +
+ "linear": { +
+ "x": 0, +
+ "y": 0, +
+ "z": 0 +
+ }, +
+ "angular": { +
+ "x": 0, +
+ "y": 0, +
+ "z": "-{{speed}}" +
+ } +
+ } +
+ } +
+ }, +
+ "timeout": 30000, +
+ "category": "movement", +
+ "retryable": true, +
+ "description": "Make the robot turn right at specified angular speed", +
+ "parameterSchema": { +
+ "type": "object", +
+ "required": [ +
+ "speed" +
+ ], +
+ "properties": { +
+ "speed": { +
+ "type": "number", +
+ "default": 0.3, +
+ "maximum": 1, +
+ "minimum": 0.1, +
+ "description": "Angular speed in rad/s" +
+ }, +
+ "duration": { +
+ "type": "number", +
+ "default": 0, +
+ "maximum": 30, +
+ "minimum": 0, +
+ "description": "Duration to turn in seconds (0 = indefinite)" +
+ } +
+ } +
+ } +
+ }, +
+ { +
+ "id": "stop_walking", +
+ "icon": "square", +
+ "name": "Stop Walking", +
+ "ros2": { +
+ "qos": { +
+ "depth": 1, +
+ "history": "keep_last", +
+ "durability": "volatile", +
+ "reliability": "reliable" +
+ }, +
+ "topic": "/cmd_vel", +
+ "messageType": "geometry_msgs/msg/Twist", +
+ "payloadMapping": { +
+ "type": "static", +
+ "payload": { +
+ "linear": { +
+ "x": 0, +
+ "y": 0, +
+ "z": 0 +
+ }, +
+ "angular": { +
+ "x": 0, +
+ "y": 0, +
+ "z": 0 +
+ } +
+ } +
+ } +
+ }, +
+ "timeout": 3000, +
+ "category": "movement", +
+ "retryable": false, +
+ "description": "Immediately stop robot movement", +
+ "parameterSchema": { +
+ "type": "object", +
+ "required": [ +
+ ], +
+ "properties": { +
+ } +
+ } +
+ }, +
+ { +
+ "id": "say_text", +
+ "icon": "volume-2", +
+ "name": "Say Text", +
+ "ros2": { +
+ "qos": { +
+ "durability": "volatile", +
+ "reliability": "reliable" +
+ }, +
+ "topic": "/speech", +
+ "messageType": "std_msgs/msg/String", +
+ "payloadMapping": { +
+ "type": "transform", +
+ "transformFn": "transformToStringMessage" +
+ } +
+ }, +
+ "timeout": 15000, +
+ "category": "interaction", +
+ "retryable": true, +
+ "description": "Make the robot speak using text-to-speech", +
+ "parameterSchema": { +
+ "type": "object", +
+ "required": [ +
+ "text" +
+ ], +
+ "properties": { +
+ "text": { +
+ "type": "string", +
+ "default": "Hello from NAO!", +
+ "description": "Text to speak" +
+ } +
+ } +
+ } +
+ }, +
+ { +
+ "id": "say_with_emotion", +
+ "icon": "heart", +
+ "name": "Say Text with Emotion", +
+ "ros2": { +
+ "topic": "/speech", +
+ "messageType": "std_msgs/msg/String", +
+ "payloadMapping": { +
+ "type": "static", +
+ "payload": { +
+ "data": "\\rspd={{speed}}\\\\rst={{emotion}}\\{{text}}" +
+ } +
+ } +
+ }, +
+ "timeout": 15000, +
+ "category": "interaction", +
+ "retryable": true, +
+ "description": "Speak text with emotional expression using SSML-like markup",+
+ "parameterSchema": { +
+ "type": "object", +
+ "required": [ +
+ "text" +
+ ], +
+ "properties": { +
+ "text": { +
+ "type": "string", +
+ "default": "Hello! I'm feeling great today!", +
+ "description": "Text for the robot to speak" +
+ }, +
+ "speed": { +
+ "type": "number", +
+ "default": 1, +
+ "maximum": 2, +
+ "minimum": 0.5, +
+ "description": "Speech speed multiplier" +
+ }, +
+ "emotion": { +
+ "enum": [ +
+ "neutral", +
+ "happy", +
+ "sad", +
+ "excited", +
+ "calm" +
+ ], +
+ "type": "string", +
+ "default": "neutral", +
+ "description": "Emotional tone for speech" +
+ } +
+ } +
+ } +
+ }, +
+ { +
+ "id": "set_volume", +
+ "icon": "volume-x", +
+ "name": "Set Volume", +
+ "ros2": { +
+ "topic": "/audio_volume", +
+ "messageType": "std_msgs/msg/Float32", +
+ "payloadMapping": { +
+ "type": "static", +
+ "payload": { +
+ "data": "{{volume}}" +
+ } +
+ } +
+ }, +
+ "timeout": 5000, +
+ "category": "interaction", +
+ "retryable": true, +
+ "description": "Adjust the robot's audio volume level", +
+ "parameterSchema": { +
+ "type": "object", +
+ "required": [ +
+ "volume" +
+ ], +
+ "properties": { +
+ "volume": { +
+ "type": "number", +
+ "default": 0.5, +
+ "maximum": 1, +
+ "minimum": 0, +
+ "description": "Volume level (0.0 = silent, 1.0 = maximum)" +
+ } +
+ } +
+ } +
+ }, +
+ { +
+ "id": "set_language", +
+ "icon": "globe", +
+ "name": "Set Language", +
+ "ros2": { +
+ "topic": "/set_language", +
+ "messageType": "std_msgs/msg/String", +
+ "payloadMapping": { +
+ "type": "static", +
+ "payload": { +
+ "data": "{{language}}" +
+ } +
+ } +
+ }, +
+ "timeout": 5000, +
+ "category": "interaction", +
+ "retryable": true, +
+ "description": "Change the robot's speech language", +
+ "parameterSchema": { +
+ "type": "object", +
+ "required": [ +
+ "language" +
+ ], +
+ "properties": { +
+ "language": { +
+ "enum": [ +
+ "en-US", +
+ "en-GB", +
+ "fr-FR", +
+ "de-DE", +
+ "es-ES", +
+ "it-IT", +
+ "ja-JP", +
+ "ko-KR", +
+ "zh-CN" +
+ ], +
+ "type": "string", +
+ "default": "en-US", +
+ "description": "Speech language" +
+ } +
+ } +
+ } +
+ }, +
+ { +
+ "id": "move_head", +
+ "icon": "eye", +
+ "name": "Move Head", +
+ "ros2": { +
+ "topic": "/joint_angles", +
+ "messageType": "naoqi_bridge_msgs/msg/JointAnglesWithSpeed", +
+ "payloadMapping": { +
+ "type": "static", +
+ "payload": { +
+ "speed": "{{speed}}", +
+ "joint_names": [ +
+ "HeadYaw", +
+ "HeadPitch" +
+ ], +
+ "joint_angles": [ +
+ "{{yaw}}", +
+ "{{pitch}}" +
+ ] +
+ } +
+ } +
+ }, +
+ "timeout": 10000, +
+ "category": "movement", +
+ "retryable": true, +
+ "description": "Control head orientation (yaw and pitch)", +
+ "parameterSchema": { +
+ "type": "object", +
+ "required": [ +
+ "yaw", +
+ "pitch" +
+ ], +
+ "properties": { +
+ "yaw": { +
+ "type": "number", +
+ "default": 0, +
+ "maximum": 2.09, +
+ "minimum": -2.09, +
+ "description": "Head yaw angle in radians" +
+ }, +
+ "pitch": { +
+ "type": "number", +
+ "default": 0, +
+ "maximum": 0.51, +
+ "minimum": -0.67, +
+ "description": "Head pitch angle in radians" +
+ }, +
+ "speed": { +
+ "type": "number", +
+ "default": 0.3, +
+ "maximum": 1, +
+ "minimum": 0.1, +
+ "description": "Movement speed (0.1 = slow, 1.0 = fast)" +
+ } +
+ } +
+ } +
+ }, +
+ { +
+ "id": "move_arm", +
+ "icon": "hand", +
+ "name": "Move Arm", +
+ "ros2": { +
+ "topic": "/joint_angles", +
+ "messageType": "naoqi_bridge_msgs/msg/JointAnglesWithSpeed", +
+ "payloadMapping": { +
+ "type": "static", +
+ "payload": { +
+ "speed": "{{speed}}", +
+ "joint_names": [ +
+ "{{arm === 'left' ? 'L' : 'R'}}ShoulderPitch", +
+ "{{arm === 'left' ? 'L' : 'R'}}ShoulderRoll", +
+ "{{arm === 'left' ? 'L' : 'R'}}ElbowYaw", +
+ "{{arm === 'left' ? 'L' : 'R'}}ElbowRoll" +
+ ], +
+ "joint_angles": [ +
+ "{{shoulder_pitch}}", +
+ "{{shoulder_roll}}", +
+ "{{elbow_yaw}}", +
+ "{{elbow_roll}}" +
+ ] +
+ } +
+ } +
+ }, +
+ "timeout": 10000, +
+ "category": "movement", +
+ "retryable": true, +
+ "description": "Control arm joint positions", +
+ "parameterSchema": { +
+ "type": "object", +
+ "required": [ +
+ "arm", +
+ "shoulder_pitch", +
+ "shoulder_roll", +
+ "elbow_yaw", +
+ "elbow_roll" +
+ ], +
+ "properties": { +
+ "arm": { +
+ "enum": [ +
+ "left", +
+ "right" +
+ ], +
+ "type": "string", +
+ "default": "right", +
+ "description": "Which arm to control" +
+ }, +
+ "speed": { +
+ "type": "number", +
+ "default": 0.3, +
+ "maximum": 1, +
+ "minimum": 0.1, +
+ "description": "Movement speed (0.1 = slow, 1.0 = fast)" +
+ }, +
+ "elbow_yaw": { +
+ "type": "number", +
+ "default": 0, +
+ "maximum": 2.09, +
+ "minimum": -2.09, +
+ "description": "Elbow yaw angle in radians" +
+ }, +
+ "elbow_roll": { +
+ "type": "number", +
+ "default": -0.5, +
+ "maximum": -0.03, +
+ "minimum": -1.54, +
+ "description": "Elbow roll angle in radians" +
+ }, +
+ "shoulder_roll": { +
+ "type": "number", +
+ "default": 0.2, +
+ "maximum": 1.33, +
+ "minimum": -0.31, +
+ "description": "Shoulder roll angle in radians" +
+ }, +
+ "shoulder_pitch": { +
+ "type": "number", +
+ "default": 1.4, +
+ "maximum": 2.09, +
+ "minimum": -2.09, +
+ "description": "Shoulder pitch angle in radians" +
+ } +
+ } +
+ } +
+ }, +
+ { +
+ "id": "set_joint_angle", +
+ "icon": "settings", +
+ "name": "Set Joint Angle", +
+ "ros2": { +
+ "topic": "/joint_angles", +
+ "messageType": "naoqi_bridge_msgs/msg/JointAnglesWithSpeed", +
+ "payloadMapping": { +
+ "type": "transform", +
+ "transformFn": "transformToJointAngles" +
+ } +
+ }, +
+ "timeout": 10000, +
+ "category": "movement", +
+ "retryable": true, +
+ "description": "Control individual joint angles", +
+ "parameterSchema": { +
+ "type": "object", +
+ "required": [ +
+ "joint_name", +
+ "angle" +
+ ], +
+ "properties": { +
+ "angle": { +
+ "type": "number", +
+ "default": 0, +
+ "maximum": 3.14159, +
+ "minimum": -3.14159, +
+ "description": "Target angle in radians" +
+ }, +
+ "speed": { +
+ "type": "number", +
+ "default": 0.2, +
+ "maximum": 1, +
+ "minimum": 0.01, +
+ "description": "Movement speed (fraction of max)" +
+ }, +
+ "joint_name": { +
+ "enum": [ +
+ "HeadYaw", +
+ "HeadPitch", +
+ "LShoulderPitch", +
+ "LShoulderRoll", +
+ "LElbowYaw", +
+ "LElbowRoll", +
+ "LWristYaw", +
+ "RShoulderPitch", +
+ "RShoulderRoll", +
+ "RElbowYaw", +
+ "RElbowRoll", +
+ "RWristYaw", +
+ "LHipYawPitch", +
+ "LHipRoll", +
+ "LHipPitch", +
+ "LKneePitch", +
+ "LAnklePitch", +
+ "LAnkleRoll", +
+ "RHipRoll", +
+ "RHipPitch", +
+ "RKneePitch", +
+ "RAnklePitch", +
+ "RAnkleRoll" +
+ ], +
+ "type": "string", +
+ "default": "HeadYaw", +
+ "description": "Joint to control" +
+ } +
+ } +
+ } +
+ }, +
+ { +
+ "id": "turn_head", +
+ "icon": "rotate-ccw", +
+ "name": "Turn Head", +
+ "ros2": { +
+ "topic": "/joint_angles", +
+ "messageType": "naoqi_bridge_msgs/msg/JointAnglesWithSpeed", +
+ "payloadMapping": { +
+ "type": "transform", +
+ "transformFn": "transformToHeadMovement" +
+ } +
+ }, +
+ "timeout": 8000, +
+ "category": "movement", +
+ "retryable": true, +
+ "description": "Control head orientation", +
+ "parameterSchema": { +
+ "type": "object", +
+ "required": [ +
+ "yaw", +
+ "pitch" +
+ ], +
+ "properties": { +
+ "yaw": { +
+ "type": "number", +
+ "default": 0, +
+ "maximum": 2.0857, +
+ "minimum": -2.0857, +
+ "description": "Head yaw angle in radians (left-right)" +
+ }, +
+ "pitch": { +
+ "type": "number", +
+ "default": 0, +
+ "maximum": 0.5149, +
+ "minimum": -0.672, +
+ "description": "Head pitch angle in radians (up-down)" +
+ }, +
+ "speed": { +
+ "type": "number", +
+ "default": 0.3, +
+ "maximum": 1, +
+ "minimum": 0.1, +
+ "description": "Movement speed fraction" +
+ } +
+ } +
+ } +
+ }, +
+ { +
+ "id": "get_camera_image", +
+ "icon": "camera", +
+ "name": "Get Camera Image", +
+ "ros2": { +
+ "qos": { +
+ "durability": "volatile", +
+ "reliability": "reliable" +
+ }, +
+ "topic": "/camera/{camera}/image_raw", +
+ "messageType": "sensor_msgs/msg/Image", +
+ "payloadMapping": { +
+ "type": "transform", +
+ "transformFn": "getCameraImage" +
+ } +
+ }, +
+ "timeout": 5000, +
+ "category": "sensors", +
+ "retryable": true, +
+ "description": "Capture image from front or bottom camera", +
+ "parameterSchema": { +
+ "type": "object", +
+ "required": [ +
+ "camera" +
+ ], +
+ "properties": { +
+ "camera": { +
+ "enum": [ +
+ "front", +
+ "bottom" +
+ ], +
+ "type": "string", +
+ "default": "front", +
+ "description": "Camera to use" +
+ } +
+ } +
+ } +
+ }, +
+ { +
+ "id": "get_joint_states", +
+ "icon": "activity", +
+ "name": "Get Joint States", +
+ "ros2": { +
+ "qos": { +
+ "durability": "volatile", +
+ "reliability": "reliable" +
+ }, +
+ "topic": "/joint_states", +
+ "messageType": "sensor_msgs/msg/JointState", +
+ "payloadMapping": { +
+ "type": "transform", +
+ "transformFn": "getJointStates" +
+ } +
+ }, +
+ "timeout": 3000, +
+ "category": "sensors", +
+ "retryable": true, +
+ "description": "Read current joint positions and velocities", +
+ "parameterSchema": { +
+ "type": "object", +
+ "required": [ +
+ ], +
+ "properties": { +
+ } +
+ } +
+ }, +
+ { +
+ "id": "get_imu_data", +
+ "icon": "compass", +
+ "name": "Get IMU Data", +
+ "ros2": { +
+ "qos": { +
+ "durability": "volatile", +
+ "reliability": "reliable" +
+ }, +
+ "topic": "/imu/torso", +
+ "messageType": "sensor_msgs/msg/Imu", +
+ "payloadMapping": { +
+ "type": "transform", +
+ "transformFn": "getImuData" +
+ } +
+ }, +
+ "timeout": 3000, +
+ "category": "sensors", +
+ "retryable": true, +
+ "description": "Read inertial measurement unit data from torso", +
+ "parameterSchema": { +
+ "type": "object", +
+ "required": [ +
+ ], +
+ "properties": { +
+ } +
+ } +
+ }, +
+ { +
+ "id": "get_bumper_status", +
+ "icon": "zap", +
+ "name": "Get Bumper Status", +
+ "ros2": { +
+ "topic": "/bumper", +
+ "messageType": "naoqi_bridge_msgs/msg/Bumper", +
+ "payloadMapping": { +
+ "type": "transform", +
+ "transformFn": "getBumperStatus" +
+ } +
+ }, +
+ "timeout": 3000, +
+ "category": "sensors", +
+ "retryable": true, +
+ "description": "Read foot bumper contact sensors", +
+ "parameterSchema": { +
+ "type": "object", +
+ "required": [ +
+ ], +
+ "properties": { +
+ } +
+ } +
+ }, +
+ { +
+ "id": "get_touch_sensors", +
+ "icon": "hand", +
+ "name": "Get Touch Sensors", +
+ "ros2": { +
+ "topic": "/{sensor_type}_touch", +
+ "messageType": "naoqi_bridge_msgs/msg/HandTouch", +
+ "payloadMapping": { +
+ "type": "transform", +
+ "transformFn": "getTouchSensors" +
+ } +
+ }, +
+ "timeout": 3000, +
+ "category": "sensors", +
+ "retryable": true, +
+ "description": "Read hand and head touch sensor states", +
+ "parameterSchema": { +
+ "type": "object", +
+ "required": [ +
+ "sensor_type" +
+ ], +
+ "properties": { +
+ "sensor_type": { +
+ "enum": [ +
+ "hand", +
+ "head" +
+ ], +
+ "type": "string", +
+ "default": "hand", +
+ "description": "Touch sensor type to read" +
+ } +
+ } +
+ } +
+ }, +
+ { +
+ "id": "get_sonar_range", +
+ "icon": "radio", +
+ "name": "Get Sonar Range", +
+ "ros2": { +
+ "topic": "/sonar/{sensor}", +
+ "messageType": "sensor_msgs/msg/Range", +
+ "payloadMapping": { +
+ "type": "transform", +
+ "transformFn": "getSonarRange" +
+ } +
+ }, +
+ "timeout": 3000, +
+ "category": "sensors", +
+ "retryable": true, +
+ "description": "Read ultrasonic range sensor data", +
+ "parameterSchema": { +
+ "type": "object", +
+ "required": [ +
+ "sensor" +
+ ], +
+ "properties": { +
+ "sensor": { +
+ "enum": [ +
+ "left", +
+ "right", +
+ "both" +
+ ], +
+ "type": "string", +
+ "default": "both", +
+ "description": "Sonar sensor to read" +
+ } +
+ } +
+ } +
+ }, +
+ { +
+ "id": "get_robot_info", +
+ "icon": "info", +
+ "name": "Get Robot Info", +
+ "ros2": { +
+ "topic": "/info", +
+ "messageType": "naoqi_bridge_msgs/msg/RobotInfo", +
+ "payloadMapping": { +
+ "type": "transform", +
+ "transformFn": "getRobotInfo" +
+ } +
+ }, +
+ "timeout": 3000, +
+ "category": "sensors", +
+ "retryable": true, +
+ "description": "Read general robot information and status", +
+ "parameterSchema": { +
+ "type": "object", +
+ "required": [ +
+ ], +
+ "properties": { +
+ } +
+ } +
+ } +
+ ]
+(1 row)
+
diff --git a/src/app/(dashboard)/analytics/page.tsx b/src/app/(dashboard)/analytics/page.tsx
deleted file mode 100755
index b964747..0000000
--- a/src/app/(dashboard)/analytics/page.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-"use client";
-
-import { useEffect } from "react";
-import { useRouter } from "next/navigation";
-import Link from "next/link";
-import { AlertCircle, ArrowRight } from "lucide-react";
-import { Button } from "~/components/ui/button";
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "~/components/ui/card";
-import { useStudyContext } from "~/lib/study-context";
-
-export default function AnalyticsRedirect() {
- const router = useRouter();
- const { selectedStudyId } = useStudyContext();
-
- useEffect(() => {
- // If user has a selected study, redirect to study analytics
- if (selectedStudyId) {
- router.replace(`/studies/${selectedStudyId}/analytics`);
- }
- }, [selectedStudyId, router]);
-
- return (
-
-
-
-
- Analytics Moved
-
- Analytics are now organized by study for better data insights.
-
-
-
-
-
To view analytics, please:
-
- • Select a study from your studies list
- • Navigate to that study's analytics page
- • Get study-specific insights and data
-
-
-
-
-
-
- Browse Studies
-
-
-
- Go to Dashboard
-
-
-
-
-
- );
-}
diff --git a/src/app/(dashboard)/experiments/[id]/edit/page.tsx b/src/app/(dashboard)/experiments/[id]/edit/page.tsx
deleted file mode 100755
index 3087a0c..0000000
--- a/src/app/(dashboard)/experiments/[id]/edit/page.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import { ExperimentForm } from "~/components/experiments/ExperimentForm";
-
-interface EditExperimentPageProps {
- params: Promise<{
- id: string;
- }>;
-}
-
-export default async function EditExperimentPage({
- params,
-}: EditExperimentPageProps) {
- const { id } = await params;
-
- return ;
-}
diff --git a/src/app/(dashboard)/experiments/[id]/page.tsx b/src/app/(dashboard)/experiments/[id]/page.tsx
deleted file mode 100755
index ce68a09..0000000
--- a/src/app/(dashboard)/experiments/[id]/page.tsx
+++ /dev/null
@@ -1,459 +0,0 @@
-"use client";
-
-import { formatDistanceToNow } from "date-fns";
-import { Calendar, Clock, Edit, Play, Settings, Users } from "lucide-react";
-import Link from "next/link";
-import { notFound } from "next/navigation";
-import { useEffect, useState } from "react";
-import { Badge } from "~/components/ui/badge";
-import { Button } from "~/components/ui/button";
-import {
- EntityView,
- EntityViewHeader,
- EntityViewSection,
- EmptyState,
- InfoGrid,
- QuickActions,
- StatsGrid,
-} from "~/components/ui/entity-view";
-import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
-import { api } from "~/trpc/react";
-import { useSession } from "next-auth/react";
-
-interface ExperimentDetailPageProps {
- params: Promise<{ id: string }>;
-}
-
-const statusConfig = {
- draft: {
- label: "Draft",
- variant: "secondary" as const,
- icon: "FileText" as const,
- },
- testing: {
- label: "Testing",
- variant: "outline" as const,
- icon: "TestTube" as const,
- },
- ready: {
- label: "Ready",
- variant: "default" as const,
- icon: "CheckCircle" as const,
- },
- deprecated: {
- label: "Deprecated",
- variant: "destructive" as const,
- icon: "AlertTriangle" as const,
- },
-};
-
-type Experiment = {
- id: string;
- name: string;
- description: string | null;
- status: string;
- createdAt: Date;
- updatedAt: Date;
- study: { id: string; name: string };
- robot: { id: string; name: string; description: string | null } | null;
- protocol?: { blocks: unknown[] } | null;
- visualDesign?: unknown;
- studyId: string;
- createdBy: string;
- robotId: string | null;
- version: number;
-};
-
-type Trial = {
- id: string;
- status: string;
- createdAt: Date;
- duration: number | null;
- participant: {
- id: string;
- participantCode: string;
- name?: string | null;
- } | null;
- experiment: { name: string } | null;
- participantId: string | null;
- experimentId: string;
- startedAt: Date | null;
- completedAt: Date | null;
- notes: string | null;
- updatedAt: Date;
- canAccess: boolean;
- userRole: string;
-};
-
-export default function ExperimentDetailPage({
- params,
-}: ExperimentDetailPageProps) {
- const { data: session } = useSession();
- const [experiment, setExperiment] = useState(null);
- const [trials, setTrials] = useState([]);
- const [loading, setLoading] = useState(true);
- const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(
- null,
- );
-
- useEffect(() => {
- const resolveParams = async () => {
- const resolved = await params;
- setResolvedParams(resolved);
- };
- void resolveParams();
- }, [params]);
-
- const experimentQuery = api.experiments.get.useQuery(
- { id: resolvedParams?.id ?? "" },
- { enabled: !!resolvedParams?.id },
- );
-
- const trialsQuery = api.trials.list.useQuery(
- { experimentId: resolvedParams?.id ?? "" },
- { enabled: !!resolvedParams?.id },
- );
-
- useEffect(() => {
- if (experimentQuery.data) {
- setExperiment(experimentQuery.data);
- }
- }, [experimentQuery.data]);
-
- useEffect(() => {
- if (trialsQuery.data) {
- setTrials(trialsQuery.data);
- }
- }, [trialsQuery.data]);
-
- useEffect(() => {
- if (experimentQuery.isLoading || trialsQuery.isLoading) {
- setLoading(true);
- } else {
- setLoading(false);
- }
- }, [experimentQuery.isLoading, trialsQuery.isLoading]);
-
- // Set breadcrumbs
- useBreadcrumbsEffect([
- {
- label: "Dashboard",
- href: "/",
- },
- {
- label: "Studies",
- href: "/studies",
- },
- {
- label: experiment?.study?.name ?? "Unknown Study",
- href: `/studies/${experiment?.study?.id}`,
- },
- {
- label: "Experiments",
- href: `/studies/${experiment?.study?.id}/experiments`,
- },
- {
- label: experiment?.name ?? "Experiment",
- },
- ]);
-
- if (loading) return Loading...
;
- if (experimentQuery.error) return notFound();
- if (!experiment) return notFound();
-
- const displayName = experiment.name ?? "Untitled Experiment";
- const description = experiment.description;
-
- // Check if user can edit this experiment
- const userRoles = session?.user?.roles?.map((r) => r.role) ?? [];
- const canEdit =
- userRoles.includes("administrator") || userRoles.includes("researcher");
-
- const statusInfo =
- statusConfig[experiment.status as keyof typeof statusConfig];
-
- return (
-
-
-
-
-
- Edit
-
-
-
-
-
- Designer
-
-
-
-
-
- Start Trial
-
-
- >
- ) : undefined
- }
- />
-
-
-
- {/* Basic Information */}
-
-
- {experiment.study.name}
-
- ) : (
- "No study assigned"
- ),
- },
- {
- label: "Status",
- value: statusInfo?.label ?? "Unknown",
- },
- {
- label: "Created",
- value: formatDistanceToNow(experiment.createdAt, {
- addSuffix: true,
- }),
- },
- {
- label: "Last Updated",
- value: formatDistanceToNow(experiment.updatedAt, {
- addSuffix: true,
- }),
- },
- ]}
- />
-
-
- {/* Protocol Section */}
-
-
-
- Edit Protocol
-
-
- )
- }
- >
- {experiment.protocol &&
- typeof experiment.protocol === "object" &&
- experiment.protocol !== null ? (
-
-
- Protocol contains{" "}
- {Array.isArray(
- (experiment.protocol as { blocks: unknown[] }).blocks,
- )
- ? (experiment.protocol as { blocks: unknown[] }).blocks
- .length
- : 0}{" "}
- blocks
-
-
- ) : (
-
-
- Open Designer
-
-
- )
- }
- />
- )}
-
-
- {/* Recent Trials */}
-
-
- View All
-
-
- }
- >
- {trials.length > 0 ? (
-
- {trials.slice(0, 5).map((trial) => (
-
-
-
- Trial #{trial.id.slice(-6)}
-
-
- {trial.status.charAt(0).toUpperCase() +
- trial.status.slice(1).replace("_", " ")}
-
-
-
-
-
- {formatDistanceToNow(trial.createdAt, {
- addSuffix: true,
- })}
-
- {trial.duration && (
-
-
- {Math.round(trial.duration / 60)} min
-
- )}
- {trial.participant && (
-
-
- {trial.participant.name ??
- trial.participant.participantCode}
-
- )}
-
-
- ))}
-
- ) : (
-
-
- Start Trial
-
-
- )
- }
- />
- )}
-
-
-
-
- {/* Statistics */}
-
- t.status === "completed").length,
- },
- {
- label: "In Progress",
- value: trials.filter((t) => t.status === "in_progress")
- .length,
- },
- ]}
- />
-
-
- {/* Robot Information */}
- {experiment.robot && (
-
-
-
- )}
-
- {/* Quick Actions */}
-
-
-
-
-
-
- );
-}
diff --git a/src/app/(dashboard)/experiments/page.tsx b/src/app/(dashboard)/experiments/page.tsx
deleted file mode 100755
index 1427e40..0000000
--- a/src/app/(dashboard)/experiments/page.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-"use client";
-
-import { useEffect } from "react";
-import { useRouter } from "next/navigation";
-import Link from "next/link";
-import { FlaskConical, ArrowRight } from "lucide-react";
-import { Button } from "~/components/ui/button";
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "~/components/ui/card";
-import { useStudyContext } from "~/lib/study-context";
-
-export default function ExperimentsRedirect() {
- const router = useRouter();
- const { selectedStudyId } = useStudyContext();
-
- useEffect(() => {
- // If user has a selected study, redirect to study experiments
- if (selectedStudyId) {
- router.replace(`/studies/${selectedStudyId}/experiments`);
- }
- }, [selectedStudyId, router]);
-
- return (
-
-
-
-
-
-
- Experiments Moved
-
- Experiment management is now organized by study for better
- workflow organization.
-
-
-
-
-
To manage experiments:
-
- • Select a study from your studies list
- • Navigate to that study's experiments page
- • Create and manage experiment protocols for that specific study
-
-
-
-
-
-
- Browse Studies
-
-
-
- Go to Dashboard
-
-
-
-
-
- );
-}
diff --git a/src/app/(dashboard)/participants/page.tsx b/src/app/(dashboard)/participants/page.tsx
deleted file mode 100755
index 3e081b9..0000000
--- a/src/app/(dashboard)/participants/page.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-"use client";
-
-import { useEffect } from "react";
-import { useRouter } from "next/navigation";
-import Link from "next/link";
-import { Users, ArrowRight } from "lucide-react";
-import { Button } from "~/components/ui/button";
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "~/components/ui/card";
-import { useStudyContext } from "~/lib/study-context";
-
-export default function ParticipantsRedirect() {
- const router = useRouter();
- const { selectedStudyId } = useStudyContext();
-
- useEffect(() => {
- // If user has a selected study, redirect to study participants
- if (selectedStudyId) {
- router.replace(`/studies/${selectedStudyId}/participants`);
- }
- }, [selectedStudyId, router]);
-
- return (
-
-
-
-
-
-
- Participants Moved
-
- Participant management is now organized by study for better
- organization.
-
-
-
-
-
To manage participants:
-
- • Select a study from your studies list
- • Navigate to that study's participants page
- • Add and manage participants for that specific study
-
-
-
-
-
-
- Browse Studies
-
-
-
- Go to Dashboard
-
-
-
-
-
- );
-}
diff --git a/src/app/(dashboard)/plugins/browse/page.tsx b/src/app/(dashboard)/plugins/browse/page.tsx
deleted file mode 100755
index ac75859..0000000
--- a/src/app/(dashboard)/plugins/browse/page.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-"use client";
-
-import { useEffect } from "react";
-import { useRouter } from "next/navigation";
-import Link from "next/link";
-import { ArrowRight, Store } from "lucide-react";
-import { Button } from "~/components/ui/button";
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "~/components/ui/card";
-import { useStudyContext } from "~/lib/study-context";
-
-export default function PluginBrowseRedirect() {
- const router = useRouter();
- const { selectedStudyId } = useStudyContext();
-
- useEffect(() => {
- // If user has a selected study, redirect to study plugin browse
- if (selectedStudyId) {
- router.replace(`/studies/${selectedStudyId}/plugins/browse`);
- }
- }, [selectedStudyId, router]);
-
- return (
-
-
-
-
-
-
- Plugin Store Moved
-
- Plugin browsing is now organized by study for better robot
- capability management.
-
-
-
-
-
To browse and install plugins:
-
- • Select a study from your studies list
- • Navigate to that study's plugin store
-
- • Browse and install robot capabilities for that specific study
-
-
-
-
-
-
-
- Browse Studies
-
-
-
- Go to Dashboard
-
-
-
-
-
- );
-}
diff --git a/src/app/(dashboard)/plugins/page.tsx b/src/app/(dashboard)/plugins/page.tsx
deleted file mode 100755
index 43cc54b..0000000
--- a/src/app/(dashboard)/plugins/page.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-"use client";
-
-import { useEffect } from "react";
-import { useRouter } from "next/navigation";
-import Link from "next/link";
-import { Puzzle, ArrowRight } from "lucide-react";
-import { Button } from "~/components/ui/button";
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "~/components/ui/card";
-import { useStudyContext } from "~/lib/study-context";
-
-export default function PluginsRedirect() {
- const router = useRouter();
- const { selectedStudyId } = useStudyContext();
-
- useEffect(() => {
- // If user has a selected study, redirect to study plugins
- if (selectedStudyId) {
- router.replace(`/studies/${selectedStudyId}/plugins`);
- }
- }, [selectedStudyId, router]);
-
- return (
-
-
-
-
- Plugins Moved
-
- Plugin management is now organized by study for better robot
- capability management.
-
-
-
-
-
To manage plugins:
-
- • Select a study from your studies list
- • Navigate to that study's plugins page
-
- • Install and configure robot capabilities for that specific
- study
-
-
-
-
-
-
-
- Browse Studies
-
-
-
- Go to Dashboard
-
-
-
-
-
- );
-}
diff --git a/src/app/(dashboard)/studies/[id]/experiments/[experimentId]/edit/experiment-form.tsx b/src/app/(dashboard)/studies/[id]/experiments/[experimentId]/edit/experiment-form.tsx
new file mode 100644
index 0000000..e242051
--- /dev/null
+++ b/src/app/(dashboard)/studies/[id]/experiments/[experimentId]/edit/experiment-form.tsx
@@ -0,0 +1,170 @@
+"use client";
+
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import * as z from "zod";
+import { Button } from "~/components/ui/button";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "~/components/ui/form";
+import { Input } from "~/components/ui/input";
+import { Textarea } from "~/components/ui/textarea";
+
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "~/components/ui/select";
+import { toast } from "sonner";
+import { api } from "~/trpc/react";
+import { useRouter } from "next/navigation";
+import { type Experiment } from "~/lib/experiments/types";
+
+const formSchema = z.object({
+ name: z.string().min(2, {
+ message: "Name must be at least 2 characters.",
+ }),
+ description: z.string().optional(),
+ status: z.enum([
+ "draft",
+ "ready",
+ "data_collection",
+ "analysis",
+ "completed",
+ "archived",
+ ]),
+});
+
+interface ExperimentFormProps {
+ experiment: Experiment;
+}
+
+export function ExperimentForm({ experiment }: ExperimentFormProps) {
+ const router = useRouter();
+ const updateExperiment = api.experiments.update.useMutation({
+ onSuccess: () => {
+ toast.success("Experiment updated successfully");
+ router.refresh();
+ router.push(`/studies/${experiment.studyId}/experiments/${experiment.id}`);
+ },
+ onError: (error) => {
+ toast.error(`Error updating experiment: ${error.message}`);
+ },
+ });
+
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ name: experiment.name,
+ description: experiment.description ?? "",
+ status: experiment.status,
+ },
+ });
+
+ function onSubmit(values: z.infer) {
+ updateExperiment.mutate({
+ id: experiment.id,
+ name: values.name,
+ description: values.description,
+ status: values.status,
+ });
+ }
+
+ return (
+
+
+ );
+}
diff --git a/src/app/(dashboard)/studies/[id]/experiments/[experimentId]/edit/page.tsx b/src/app/(dashboard)/studies/[id]/experiments/[experimentId]/edit/page.tsx
new file mode 100644
index 0000000..b784f08
--- /dev/null
+++ b/src/app/(dashboard)/studies/[id]/experiments/[experimentId]/edit/page.tsx
@@ -0,0 +1,63 @@
+import { notFound } from "next/navigation";
+import { type Experiment } from "~/lib/experiments/types";
+import { api } from "~/trpc/server";
+import { ExperimentForm } from "./experiment-form";
+import {
+ EntityView,
+ EntityViewHeader,
+ EntityViewSection,
+} from "~/components/ui/entity-view";
+import { Button } from "~/components/ui/button";
+import Link from "next/link";
+import { ArrowLeft } from "lucide-react";
+
+interface ExperimentEditPageProps {
+ params: Promise<{ id: string; experimentId: string }>;
+}
+
+export default async function ExperimentEditPage({
+ params,
+}: ExperimentEditPageProps) {
+ const { id: studyId, experimentId } = await params;
+
+ const experiment = await api.experiments.get({ id: experimentId });
+
+ if (!experiment) {
+ notFound();
+ }
+
+ // Ensure experiment belongs to study
+ if (experiment.studyId !== studyId) {
+ notFound();
+ }
+
+ // Convert to type expected by form
+ const experimentData: Experiment = {
+ ...experiment,
+ status: experiment.status as Experiment["status"],
+ };
+
+ return (
+
+
+
+
+ Back to Experiment
+
+
+ }
+ />
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/studies/[id]/experiments/[experimentId]/page.tsx b/src/app/(dashboard)/studies/[id]/experiments/[experimentId]/page.tsx
new file mode 100644
index 0000000..e2d9db6
--- /dev/null
+++ b/src/app/(dashboard)/studies/[id]/experiments/[experimentId]/page.tsx
@@ -0,0 +1,468 @@
+"use client";
+
+import { formatDistanceToNow } from "date-fns";
+import { Calendar, Clock, Edit, Play, Settings, Users } from "lucide-react";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { useEffect, useState } from "react";
+import { Badge } from "~/components/ui/badge";
+import { Button } from "~/components/ui/button";
+import {
+ EntityView,
+ EntityViewHeader,
+ EntityViewSection,
+ EmptyState,
+ InfoGrid,
+ QuickActions,
+ StatsGrid,
+} from "~/components/ui/entity-view";
+import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
+import { api } from "~/trpc/react";
+import { useSession } from "next-auth/react";
+import { useStudyManagement } from "~/hooks/useStudyManagement";
+
+interface ExperimentDetailPageProps {
+ params: Promise<{ id: string; experimentId: string }>;
+}
+
+const statusConfig = {
+ draft: {
+ label: "Draft",
+ variant: "secondary" as const,
+ icon: "FileText" as const,
+ },
+ testing: {
+ label: "Testing",
+ variant: "outline" as const,
+ icon: "TestTube" as const,
+ },
+ ready: {
+ label: "Ready",
+ variant: "default" as const,
+ icon: "CheckCircle" as const,
+ },
+ deprecated: {
+ label: "Deprecated",
+ variant: "destructive" as const,
+ icon: "AlertTriangle" as const,
+ },
+};
+
+type Experiment = {
+ id: string;
+ name: string;
+ description: string | null;
+ status: string;
+ createdAt: Date;
+ updatedAt: Date;
+ study: { id: string; name: string };
+ robot: { id: string; name: string; description: string | null } | null;
+ protocol?: { blocks: unknown[] } | null;
+ visualDesign?: unknown;
+ studyId: string;
+ createdBy: string;
+ robotId: string | null;
+ version: number;
+};
+
+type Trial = {
+ id: string;
+ status: string;
+ createdAt: Date;
+ duration: number | null;
+ participant: {
+ id: string;
+ participantCode: string;
+ name?: string | null;
+ } | null;
+ experiment: { name: string } | null;
+ participantId: string | null;
+ experimentId: string;
+ startedAt: Date | null;
+ completedAt: Date | null;
+ notes: string | null;
+ updatedAt: Date;
+ canAccess: boolean;
+ userRole: string;
+};
+
+export default function ExperimentDetailPage({
+ params,
+}: ExperimentDetailPageProps) {
+ const { data: session } = useSession();
+ const [experiment, setExperiment] = useState(null);
+ const [trials, setTrials] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [resolvedParams, setResolvedParams] = useState<{ id: string; experimentId: string } | null>(
+ null,
+ );
+ const { selectStudy } = useStudyManagement();
+
+ useEffect(() => {
+ const resolveParams = async () => {
+ const resolved = await params;
+ setResolvedParams(resolved);
+ // Ensure study context is synced
+ if (resolved.id) {
+ void selectStudy(resolved.id);
+ }
+ };
+ void resolveParams();
+ }, [params, selectStudy]);
+
+ const experimentQuery = api.experiments.get.useQuery(
+ { id: resolvedParams?.experimentId ?? "" },
+ { enabled: !!resolvedParams?.experimentId },
+ );
+
+ const trialsQuery = api.trials.list.useQuery(
+ { experimentId: resolvedParams?.experimentId ?? "" },
+ { enabled: !!resolvedParams?.experimentId },
+ );
+
+ useEffect(() => {
+ if (experimentQuery.data) {
+ setExperiment(experimentQuery.data);
+ }
+ }, [experimentQuery.data]);
+
+ useEffect(() => {
+ if (trialsQuery.data) {
+ setTrials(trialsQuery.data);
+ }
+ }, [trialsQuery.data]);
+
+ useEffect(() => {
+ if (experimentQuery.isLoading || trialsQuery.isLoading) {
+ setLoading(true);
+ } else {
+ setLoading(false);
+ }
+ }, [experimentQuery.isLoading, trialsQuery.isLoading]);
+
+ // Set breadcrumbs
+ useBreadcrumbsEffect([
+ {
+ label: "Dashboard",
+ href: "/",
+ },
+ {
+ label: "Studies",
+ href: "/studies",
+ },
+ {
+ label: experiment?.study?.name ?? "Study",
+ href: `/studies/${experiment?.study?.id}`,
+ },
+ {
+ label: "Experiments",
+ href: `/studies/${experiment?.study?.id}/experiments`,
+ },
+ {
+ label: experiment?.name ?? "Experiment",
+ },
+ ]);
+
+ if (loading) return Loading...
;
+ if (experimentQuery.error) return notFound();
+ if (!experiment) return notFound();
+
+ const displayName = experiment.name ?? "Untitled Experiment";
+ const description = experiment.description;
+
+ // Check if user can edit this experiment
+ const userRoles = session?.user?.roles?.map((r) => r.role) ?? [];
+ const canEdit =
+ userRoles.includes("administrator") || userRoles.includes("researcher");
+
+ const statusInfo =
+ statusConfig[experiment.status as keyof typeof statusConfig];
+
+ const studyId = experiment.study.id;
+ const experimentId = experiment.id;
+
+ return (
+
+
+
+
+
+ Edit
+
+
+
+
+
+ Designer
+
+
+
+
+
+ Start Trial
+
+
+ >
+ ) : undefined
+ }
+ />
+
+
+
+ {/* Basic Information */}
+
+
+ {experiment.study.name}
+
+ ) : (
+ "No study assigned"
+ ),
+ },
+ {
+ label: "Status",
+ value: statusInfo?.label ?? "Unknown",
+ },
+ {
+ label: "Created",
+ value: formatDistanceToNow(experiment.createdAt, {
+ addSuffix: true,
+ }),
+ },
+ {
+ label: "Last Updated",
+ value: formatDistanceToNow(experiment.updatedAt, {
+ addSuffix: true,
+ }),
+ },
+ ]}
+ />
+
+
+ {/* Protocol Section */}
+
+
+
+ Edit Protocol
+
+
+ )
+ }
+ >
+ {experiment.protocol &&
+ typeof experiment.protocol === "object" &&
+ experiment.protocol !== null ? (
+
+
+ Protocol contains{" "}
+ {Array.isArray(
+ (experiment.protocol as { blocks: unknown[] }).blocks,
+ )
+ ? (experiment.protocol as { blocks: unknown[] }).blocks
+ .length
+ : 0}{" "}
+ blocks
+
+
+ ) : (
+
+
+ Open Designer
+
+
+ )
+ }
+ />
+ )}
+
+
+ {/* Recent Trials */}
+
+
+ View All
+
+
+ }
+ >
+ {trials.length > 0 ? (
+
+ {trials.slice(0, 5).map((trial) => (
+
+
+
+ Trial #{trial.id.slice(-6)}
+
+
+ {trial.status.charAt(0).toUpperCase() +
+ trial.status.slice(1).replace("_", " ")}
+
+
+
+
+
+ {formatDistanceToNow(trial.createdAt, {
+ addSuffix: true,
+ })}
+
+ {trial.duration && (
+
+
+ {Math.round(trial.duration / 60)} min
+
+ )}
+ {trial.participant && (
+
+
+ {trial.participant.name ??
+ trial.participant.participantCode}
+
+ )}
+
+
+ ))}
+
+ ) : (
+
+
+ Start Trial
+
+
+ )
+ }
+ />
+ )}
+
+
+
+
+ {/* Statistics */}
+
+ t.status === "completed").length,
+ },
+ {
+ label: "In Progress",
+ value: trials.filter((t) => t.status === "in_progress")
+ .length,
+ },
+ ]}
+ />
+
+
+ {/* Robot Information */}
+ {experiment.robot && (
+
+
+
+ )}
+
+ {/* Quick Actions */}
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/studies/[id]/participants/[participantId]/edit/page.tsx b/src/app/(dashboard)/studies/[id]/participants/[participantId]/edit/page.tsx
new file mode 100644
index 0000000..cd8c3d7
--- /dev/null
+++ b/src/app/(dashboard)/studies/[id]/participants/[participantId]/edit/page.tsx
@@ -0,0 +1,31 @@
+import { ParticipantForm } from "~/components/participants/ParticipantForm";
+import { api } from "~/trpc/server";
+import { notFound } from "next/navigation";
+
+interface EditParticipantPageProps {
+ params: Promise<{
+ id: string;
+ participantId: string;
+ }>;
+}
+
+export default async function EditParticipantPage({
+ params,
+}: EditParticipantPageProps) {
+ const { id: studyId, participantId } = await params;
+
+ const participant = await api.participants.get({ id: participantId });
+
+ if (!participant || participant.studyId !== studyId) {
+ notFound();
+ }
+
+ // Transform data to match form expectations if needed, or pass directly
+ return (
+
+ );
+}
diff --git a/src/app/(dashboard)/studies/[id]/participants/[participantId]/page.tsx b/src/app/(dashboard)/studies/[id]/participants/[participantId]/page.tsx
new file mode 100644
index 0000000..4171f88
--- /dev/null
+++ b/src/app/(dashboard)/studies/[id]/participants/[participantId]/page.tsx
@@ -0,0 +1,109 @@
+import { notFound } from "next/navigation";
+import { api } from "~/trpc/server";
+import {
+ EntityView,
+ EntityViewHeader,
+ EntityViewSection,
+} from "~/components/ui/entity-view";
+import { ParticipantDocuments } from "./participant-documents";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "~/components/ui/card";
+import { Badge } from "~/components/ui/badge";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
+import { Button } from "~/components/ui/button";
+import { Edit } from "lucide-react";
+import Link from "next/link";
+
+interface ParticipantDetailPageProps {
+ params: Promise<{ id: string; participantId: string }>;
+}
+
+export default async function ParticipantDetailPage({
+ params,
+}: ParticipantDetailPageProps) {
+ const { id: studyId, participantId } = await params;
+
+ const participant = await api.participants.get({ id: participantId });
+
+ if (!participant) {
+ notFound();
+ }
+
+ // Ensure participant belongs to study
+ if (participant.studyId !== studyId) {
+ notFound();
+ }
+
+ return (
+
+
+ {participant.consentGiven ? "Consent Given" : "No Consent"}
+
+ }
+ actions={
+
+
+
+ Edit Participant
+
+
+ }
+ />
+
+
+
+ Overview
+ Files & Documents
+
+
+
+
+
+
+
+ Code
+ {participant.participantCode}
+
+
+
+ Name
+ {participant.name || "-"}
+
+
+
+ Email
+ {participant.email || "-"}
+
+
+
+ Added
+ {new Date(participant.createdAt).toLocaleDateString()}
+
+
+
+ Age
+ {(participant.demographics as any)?.age || "-"}
+
+
+
+ Gender
+ {(participant.demographics as any)?.gender?.replace("_", " ") || "-"}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/studies/[id]/participants/[participantId]/participant-documents.tsx b/src/app/(dashboard)/studies/[id]/participants/[participantId]/participant-documents.tsx
new file mode 100644
index 0000000..7372755
--- /dev/null
+++ b/src/app/(dashboard)/studies/[id]/participants/[participantId]/participant-documents.tsx
@@ -0,0 +1,187 @@
+"use client";
+
+import { useState } from "react";
+import { Upload, FileText, Trash2, Download, Loader2 } from "lucide-react";
+import { Button } from "~/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "~/components/ui/card";
+import { api } from "~/trpc/react";
+import { formatBytes } from "~/lib/utils";
+import { toast } from "sonner";
+
+interface ParticipantDocumentsProps {
+ participantId: string;
+}
+
+export function ParticipantDocuments({ participantId }: ParticipantDocumentsProps) {
+ const [isUploading, setIsUploading] = useState(false);
+ const utils = api.useUtils();
+
+ const { data: documents, isLoading } = api.files.listParticipantDocuments.useQuery({
+ participantId,
+ });
+
+ const getPresignedUrl = api.files.getPresignedUrl.useMutation();
+ const registerUpload = api.files.registerUpload.useMutation();
+ const deleteDocument = api.files.deleteDocument.useMutation({
+ onSuccess: () => {
+ toast.success("Document deleted");
+ utils.files.listParticipantDocuments.invalidate({ participantId });
+ },
+ onError: (err) => toast.error(`Failed to delete: ${err.message}`),
+ });
+
+ // Since presigned URLs are for PUT, we can use a direct fetch
+ const handleFileUpload = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ setIsUploading(true);
+ try {
+ // 1. Get presigned URL
+ const { url, storagePath } = await getPresignedUrl.mutateAsync({
+ filename: file.name,
+ contentType: file.type || "application/octet-stream",
+ participantId,
+ });
+
+ // 2. Upload to MinIO/S3
+ const uploadRes = await fetch(url, {
+ method: "PUT",
+ body: file,
+ headers: {
+ "Content-Type": file.type || "application/octet-stream",
+ },
+ });
+
+ if (!uploadRes.ok) {
+ throw new Error("Upload to storage failed");
+ }
+
+ // 3. Register in DB
+ await registerUpload.mutateAsync({
+ participantId,
+ name: file.name,
+ type: file.type,
+ storagePath,
+ fileSize: file.size,
+ });
+
+ toast.success("File uploaded successfully");
+ utils.files.listParticipantDocuments.invalidate({ participantId });
+ } catch (error) {
+ console.error(error);
+ toast.error("Failed to upload file");
+ } finally {
+ setIsUploading(false);
+ // Reset input
+ e.target.value = "";
+ }
+ };
+
+ const handleDownload = async (storagePath: string, filename: string) => {
+ // We would typically get a temporary download URL here
+ // For now assuming public bucket or implementing a separate download procedure
+ // Let's implement a quick procedure call right here via client or assume the server router has it.
+ // I added getDownloadUrl to the router in previous steps.
+ try {
+ const { url } = await utils.client.files.getDownloadUrl.query({ storagePath });
+ window.open(url, "_blank");
+ } catch (e) {
+ toast.error("Could not get download URL");
+ }
+ };
+
+ return (
+
+
+
+
+ Documents
+
+ Manage consent forms and other files for this participant.
+
+
+
+
+
+ {isUploading ? (
+
+ ) : (
+
+ )}
+ Upload PDF
+
+
+
+
+
+
+
+ {isLoading ? (
+
+
+
+ ) : documents?.length === 0 ? (
+
+
+
No documents uploaded yet.
+
+ ) : (
+
+ {documents?.map((doc) => (
+
+
+
+
+
+
+
{doc.name}
+
+ {formatBytes(doc.fileSize ?? 0)} • {new Date(doc.createdAt).toLocaleDateString()}
+
+
+
+
+ handleDownload(doc.storagePath, doc.name)}
+ >
+
+
+ {
+ if (confirm("Are you sure you want to delete this file?")) {
+ deleteDocument.mutate({ id: doc.id });
+ }
+ }}
+ >
+
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/app/(dashboard)/studies/[id]/trials/[trialId]/analysis/page.tsx b/src/app/(dashboard)/studies/[id]/trials/[trialId]/analysis/page.tsx
new file mode 100644
index 0000000..4ed1da9
--- /dev/null
+++ b/src/app/(dashboard)/studies/[id]/trials/[trialId]/analysis/page.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+import { useParams } from "next/navigation";
+import { Suspense, useEffect } from "react";
+import Link from "next/link";
+import { LineChart, ArrowLeft } from "lucide-react";
+import { PageHeader } from "~/components/ui/page-header";
+import { Button } from "~/components/ui/button";
+import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
+import { useStudyContext } from "~/lib/study-context";
+import { useSelectedStudyDetails } from "~/hooks/useSelectedStudyDetails";
+import { TrialAnalysisView } from "~/components/trials/views/TrialAnalysisView";
+import { api } from "~/trpc/react";
+
+function AnalysisPageContent() {
+ const params = useParams();
+ const studyId: string = typeof params.id === "string" ? params.id : "";
+ const trialId: string =
+ typeof params.trialId === "string" ? params.trialId : "";
+
+ const { setSelectedStudyId, selectedStudyId } = useStudyContext();
+ const { study } = useSelectedStudyDetails();
+
+ // Get trial data
+ const {
+ data: trial,
+ isLoading,
+ error,
+ } = api.trials.get.useQuery({ id: trialId }, { enabled: !!trialId });
+
+ // Set breadcrumbs
+ useBreadcrumbsEffect([
+ { label: "Dashboard", href: "/dashboard" },
+ { label: "Studies", href: "/studies" },
+ { label: study?.name ?? "Study", href: `/studies/${studyId}` },
+ { label: "Trials", href: `/studies/${studyId}/trials` },
+ {
+ label: trial?.experiment.name ?? "Trial",
+ href: `/studies/${studyId}/trials`,
+ },
+ { label: "Analysis" },
+ ]);
+
+ // Sync selected study (unified study-context)
+ useEffect(() => {
+ if (studyId && selectedStudyId !== studyId) {
+ setSelectedStudyId(studyId);
+ }
+ }, [studyId, selectedStudyId, setSelectedStudyId]);
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (error || !trial) {
+ return (
+
+
+
+
+ Back to Trials
+
+
+ }
+ />
+
+
+
+ {error ? "Error Loading Trial" : "Trial Not Found"}
+
+
+ {error?.message || "The requested trial could not be found."}
+
+
+
+
+ );
+ }
+
+ const trialData = {
+ ...trial,
+ startedAt: trial.startedAt ? new Date(trial.startedAt) : null,
+ completedAt: trial.completedAt ? new Date(trial.completedAt) : null,
+ eventCount: (trial as any).eventCount,
+ mediaCount: (trial as any).mediaCount,
+ };
+
+ return (
+
+
+
+
+ Back to Trial Details
+
+
+ }
+ />
+
+
+
+
+
+ );
+}
+
+export default function TrialAnalysisPage() {
+ return (
+
+ Loading...
+
+ }
+ >
+
+
+ );
+}
diff --git a/src/components/dashboard/app-sidebar.tsx b/src/components/dashboard/app-sidebar.tsx
index e4bf901..712295a 100755
--- a/src/components/dashboard/app-sidebar.tsx
+++ b/src/components/dashboard/app-sidebar.tsx
@@ -143,9 +143,9 @@ export function AppSidebar({
// Build study work items with proper URLs when study is selected
const studyWorkItemsWithUrls = selectedStudyId
? studyWorkItems.map((item) => ({
- ...item,
- url: `/studies/${selectedStudyId}${item.url}`,
- }))
+ ...item,
+ url: `/studies/${selectedStudyId}${item.url}`,
+ }))
: [];
const handleSignOut = async () => {
@@ -233,6 +233,22 @@ export function AppSidebar({
// Show debug info in development
const showDebug = process.env.NODE_ENV === "development";
+ const [mounted, setMounted] = React.useState(false);
+
+ React.useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ if (!mounted) {
+ return (
+
+
+
+
+
+ );
+ }
+
return (
diff --git a/src/components/experiments/ExperimentsTable.tsx b/src/components/experiments/ExperimentsTable.tsx
index ebc6449..60c02af 100755
--- a/src/components/experiments/ExperimentsTable.tsx
+++ b/src/components/experiments/ExperimentsTable.tsx
@@ -1,7 +1,7 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
-import { ArrowUpDown, MoreHorizontal } from "lucide-react";
+import { ArrowUpDown, MoreHorizontal, Copy, Eye, Edit, LayoutTemplate, PlayCircle, Archive } from "lucide-react";
import * as React from "react";
import { formatDistanceToNow } from "date-fns";
@@ -259,20 +259,26 @@ export const columns: ColumnDef[] = [
navigator.clipboard.writeText(experiment.id)}
>
- Copy experiment ID
+
+ Copy ID
- View details
+
+
+ Details
+
- Edit experiment
+
+ Edit
- Open designer
+
+ Designer
@@ -280,12 +286,14 @@ export const columns: ColumnDef[] = [
- Create trial
+
+ Start Trial
- Archive experiment
+
+ Archive
diff --git a/src/components/experiments/designer/DesignerRoot.tsx b/src/components/experiments/designer/DesignerRoot.tsx
index 2e1d3d1..c2bb523 100755
--- a/src/components/experiments/designer/DesignerRoot.tsx
+++ b/src/components/experiments/designer/DesignerRoot.tsx
@@ -736,6 +736,16 @@ export function DesignerRoot({
const targetStep = steps.find((s) => s.id === stepId);
if (!targetStep) return;
+ const fullDef = actionRegistry.getAction(actionDef.type);
+ const defaultParams: Record = {};
+ if (fullDef?.parameters) {
+ for (const param of fullDef.parameters) {
+ if (param.default !== undefined) {
+ defaultParams[param.id] = param.default;
+ }
+ }
+ }
+
const execution: ExperimentAction["execution"] =
actionDef.execution &&
(actionDef.execution.transport === "internal" ||
@@ -754,7 +764,7 @@ export function DesignerRoot({
type: actionDef.type,
name: actionDef.name,
category: actionDef.category as ExperimentAction["category"],
- parameters: {},
+ parameters: defaultParams,
source: actionDef.source as ExperimentAction["source"],
execution,
};
@@ -818,6 +828,7 @@ export function DesignerRoot({
description={designMeta.description || "No description"}
icon={Play}
actions={actions}
+ className="pb-6"
/>
diff --git a/src/components/experiments/designer/PropertiesPanel.tsx b/src/components/experiments/designer/PropertiesPanel.tsx
index 9736be8..97d8af9 100755
--- a/src/components/experiments/designer/PropertiesPanel.tsx
+++ b/src/components/experiments/designer/PropertiesPanel.tsx
@@ -70,7 +70,7 @@ export interface PropertiesPanelProps {
className?: string;
}
-export function PropertiesPanel({
+export function PropertiesPanelBase({
design,
selectedStep,
selectedAction,
@@ -198,8 +198,8 @@ export function PropertiesPanel({
const ResolvedIcon: React.ComponentType<{ className?: string }> =
def?.icon && iconComponents[def.icon]
? (iconComponents[def.icon] as React.ComponentType<{
- className?: string;
- }>)
+ className?: string;
+ }>)
: Zap;
return (
@@ -633,3 +633,5 @@ export function PropertiesPanel({
);
}
+
+export const PropertiesPanel = React.memo(PropertiesPanelBase);
diff --git a/src/components/experiments/designer/panels/InspectorPanel.tsx b/src/components/experiments/designer/panels/InspectorPanel.tsx
index e0cd509..850036d 100755
--- a/src/components/experiments/designer/panels/InspectorPanel.tsx
+++ b/src/components/experiments/designer/panels/InspectorPanel.tsx
@@ -284,14 +284,17 @@ export function InspectorPanel({
({
+ id: "design",
+ name: "Design",
+ description: "",
+ version: 1,
+ steps,
+ lastSaved: new Date(),
+ }),
+ [steps],
+ )}
selectedStep={selectedStep}
selectedAction={selectedAction}
onActionUpdate={handleActionUpdate}
diff --git a/src/components/experiments/designer/state/validators.ts b/src/components/experiments/designer/state/validators.ts
index 94dada3..26ae365 100755
--- a/src/components/experiments/designer/state/validators.ts
+++ b/src/components/experiments/designer/state/validators.ts
@@ -645,7 +645,7 @@ export function validateExecution(
issues.push({
severity: "warning",
message:
- "Multiple steps with trial_start trigger may cause execution conflicts",
+ "Multiple steps will start simultaneously. Ensure parallel execution is intended.",
category: "execution",
field: "trigger.type",
stepId: step.id,
diff --git a/src/components/participants/ParticipantForm.tsx b/src/components/participants/ParticipantForm.tsx
index 05f6280..06f30e5 100755
--- a/src/components/participants/ParticipantForm.tsx
+++ b/src/components/participants/ParticipantForm.tsx
@@ -114,39 +114,39 @@ export function ParticipantForm({
{ label: "Studies", href: "/studies" },
...(contextStudyId
? [
- {
- label: participant?.study?.name ?? "Study",
- href: `/studies/${contextStudyId}`,
- },
- {
- label: "Participants",
- href: `/studies/${contextStudyId}/participants`,
- },
- ...(mode === "edit" && participant
- ? [
- {
- label: participant.name ?? participant.participantCode,
- href: `/studies/${contextStudyId}/participants/${participant.id}`,
- },
- { label: "Edit" },
- ]
- : [{ label: "New Participant" }]),
- ]
+ {
+ label: participant?.study?.name ?? "Study",
+ href: `/studies/${contextStudyId}`,
+ },
+ {
+ label: "Participants",
+ href: `/studies/${contextStudyId}/participants`,
+ },
+ ...(mode === "edit" && participant
+ ? [
+ {
+ label: participant.name ?? participant.participantCode,
+ href: `/studies/${contextStudyId}/participants/${participant.id}`,
+ },
+ { label: "Edit" },
+ ]
+ : [{ label: "New Participant" }]),
+ ]
: [
- {
- label: "Participants",
- href: `/studies/${contextStudyId}/participants`,
- },
- ...(mode === "edit" && participant
- ? [
- {
- label: participant.name ?? participant.participantCode,
- href: `/studies/${contextStudyId}/participants/${participant.id}`,
- },
- { label: "Edit" },
- ]
- : [{ label: "New Participant" }]),
- ]),
+ {
+ label: "Participants",
+ href: `/studies/${contextStudyId}/participants`,
+ },
+ ...(mode === "edit" && participant
+ ? [
+ {
+ label: participant.name ?? participant.participantCode,
+ href: `/studies/${contextStudyId}/participants/${participant.id}`,
+ },
+ { label: "Edit" },
+ ]
+ : [{ label: "New Participant" }]),
+ ]),
];
useBreadcrumbsEffect(breadcrumbs);
@@ -203,7 +203,7 @@ export function ParticipantForm({
email: data.email ?? undefined,
demographics,
});
- router.push(`/participants/${newParticipant.id}`);
+ router.push(`/studies/${data.studyId}/participants/${newParticipant.id}`);
} else {
const updatedParticipant = await updateParticipantMutation.mutateAsync({
id: participantId!,
@@ -212,7 +212,7 @@ export function ParticipantForm({
email: data.email ?? undefined,
demographics,
});
- router.push(`/participants/${updatedParticipant.id}`);
+ router.push(`/studies/${contextStudyId}/participants/${updatedParticipant.id}`);
}
} catch (error) {
setError(
@@ -385,11 +385,11 @@ export function ParticipantForm({
form.setValue(
"gender",
value as
- | "male"
- | "female"
- | "non_binary"
- | "prefer_not_to_say"
- | "other",
+ | "male"
+ | "female"
+ | "non_binary"
+ | "prefer_not_to_say"
+ | "other",
)
}
>
@@ -505,7 +505,8 @@ export function ParticipantForm({
error={error}
onDelete={mode === "edit" ? onDelete : undefined}
isDeleting={isDeleting}
- sidebar={sidebar}
+ isDeleting={isDeleting}
+ // sidebar={sidebar} // Removed for cleaner UI per user request
submitText={mode === "create" ? "Register Participant" : "Save Changes"}
>
{formFields}
diff --git a/src/components/participants/ParticipantsTable.tsx b/src/components/participants/ParticipantsTable.tsx
index 53117e6..5d91954 100755
--- a/src/components/participants/ParticipantsTable.tsx
+++ b/src/components/participants/ParticipantsTable.tsx
@@ -1,7 +1,7 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
-import { ArrowUpDown, MoreHorizontal } from "lucide-react";
+import { ArrowUpDown, MoreHorizontal, Copy, Eye, Edit, Mail, Trash2 } from "lucide-react";
import * as React from "react";
import { formatDistanceToNow } from "date-fns";
@@ -27,6 +27,7 @@ import { api } from "~/trpc/react";
export type Participant = {
id: string;
+ studyId: string;
participantCode: string;
email: string | null;
name: string | null;
@@ -75,7 +76,7 @@ export const columns: ColumnDef[] = [
cell: ({ row }) => (
{row.getValue("participantCode")}
@@ -176,6 +177,13 @@ export const columns: ColumnDef
[] = [
enableHiding: false,
cell: ({ row }) => {
const participant = row.original;
+ // Use studyId from participant or fallback might be needed but for now presume row has it?
+ // Wait, the Participant type definition above doesn't have studyId!
+ // I need to add studyId to the type definition in this file or rely on context if I'm inside the component,
+ // but 'columns' is defined outside.
+ // Best practice: Add studyId to the Participant type.
+
+ const studyId = participant.studyId;
return (
@@ -190,26 +198,27 @@ export const columns: ColumnDef[] = [
navigator.clipboard.writeText(participant.id)}
>
- Copy participant ID
+
+ Copy ID
- View details
-
-
-
+
+
Edit participant
-
+
+
+
+
+ Send consent
- {!participant.consentGiven && (
- Send consent form
- )}
- Remove participant
+
+ Remove
-
-
+
+
);
},
},
@@ -250,6 +259,7 @@ export function ParticipantsTable({ studyId }: ParticipantsTableProps = {}) {
return participantsData.participants.map(
(p): Participant => ({
id: p.id,
+ studyId: p.studyId,
participantCode: p.participantCode,
email: p.email,
name: p.name,
diff --git a/src/components/trials/TrialForm.tsx b/src/components/trials/TrialForm.tsx
index 87f2e0e..7a2e938 100755
--- a/src/components/trials/TrialForm.tsx
+++ b/src/components/trials/TrialForm.tsx
@@ -96,33 +96,33 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
{ label: "Studies", href: "/studies" },
...(contextStudyId
? [
- {
- label: "Study",
- href: `/studies/${contextStudyId}`,
- },
- { label: "Trials", href: `/studies/${contextStudyId}/trials` },
- ...(mode === "edit" && trial
- ? [
- {
- label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
- href: `/studies/${contextStudyId}/trials/${trial.id}`,
- },
- { label: "Edit" },
- ]
- : [{ label: "New Trial" }]),
- ]
+ {
+ label: "Study",
+ href: `/studies/${contextStudyId}`,
+ },
+ { label: "Trials", href: `/studies/${contextStudyId}/trials` },
+ ...(mode === "edit" && trial
+ ? [
+ {
+ label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
+ href: `/studies/${contextStudyId}/trials/${trial.id}`,
+ },
+ { label: "Edit" },
+ ]
+ : [{ label: "New Trial" }]),
+ ]
: [
- { label: "Trials", href: `/studies/${contextStudyId}/trials` },
- ...(mode === "edit" && trial
- ? [
- {
- label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
- href: `/studies/${contextStudyId}/trials/${trial.id}`,
- },
- { label: "Edit" },
- ]
- : [{ label: "New Trial" }]),
- ]),
+ { label: "Trials", href: `/studies/${contextStudyId}/trials` },
+ ...(mode === "edit" && trial
+ ? [
+ {
+ label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
+ href: `/studies/${contextStudyId}/trials/${trial.id}`,
+ },
+ { label: "Edit" },
+ ]
+ : [{ label: "New Trial" }]),
+ ]),
];
useBreadcrumbsEffect(breadcrumbs);
@@ -161,7 +161,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
sessionNumber: data.sessionNumber ?? 1,
notes: data.notes ?? undefined,
});
- router.push(`/trials/${newTrial!.id}`);
+ router.push(`/studies/${contextStudyId}/trials/${newTrial!.id}`);
} else {
const updatedTrial = await updateTrialMutation.mutateAsync({
id: trialId!,
@@ -170,7 +170,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
sessionNumber: data.sessionNumber ?? 1,
notes: data.notes ?? undefined,
});
- router.push(`/trials/${updatedTrial!.id}`);
+ router.push(`/studies/${contextStudyId}/trials/${updatedTrial!.id}`);
}
} catch (error) {
setError(
diff --git a/src/components/trials/TrialsTable.tsx b/src/components/trials/TrialsTable.tsx
index 0f341b9..6f95caa 100755
--- a/src/components/trials/TrialsTable.tsx
+++ b/src/components/trials/TrialsTable.tsx
@@ -1,7 +1,7 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
-import { ArrowUpDown, ChevronDown, MoreHorizontal } from "lucide-react";
+import { ArrowUpDown, ChevronDown, MoreHorizontal, Copy, Eye, Play, Gamepad2, LineChart, Ban } from "lucide-react";
import * as React from "react";
import { format, formatDistanceToNow } from "date-fns";
@@ -51,27 +51,22 @@ const statusConfig = {
scheduled: {
label: "Scheduled",
className: "bg-blue-100 text-blue-800",
- icon: "📅",
},
in_progress: {
label: "In Progress",
className: "bg-yellow-100 text-yellow-800",
- icon: "▶️",
},
completed: {
label: "Completed",
className: "bg-green-100 text-green-800",
- icon: "✅",
},
aborted: {
label: "Aborted",
className: "bg-gray-100 text-gray-800",
- icon: "❌",
},
failed: {
label: "Failed",
className: "bg-red-100 text-red-800",
- icon: "⚠️",
},
};
@@ -145,7 +140,7 @@ export const columns: ColumnDef[] = [
{String(experimentName)}
@@ -175,7 +170,7 @@ export const columns: ColumnDef
[] = [
{participantId ? (
{(participantCode ?? "Unknown") as string}
@@ -232,10 +227,10 @@ export const columns: ColumnDef
[] = [
return (
- {statusInfo.icon}
{statusInfo.label}
);
+
},
},
{
@@ -366,79 +361,94 @@ export const columns: ColumnDef[] = [
{
id: "actions",
enableHiding: false,
- cell: ({ row }) => {
- const trial = row.original;
-
- if (!trial?.id) {
- return (
- No actions
- );
- }
-
- return (
-
-
-
- Open menu
-
-
-
-
- Actions
- navigator.clipboard.writeText(trial.id)}
- >
- Copy trial ID
-
-
-
-
- View details
-
-
- {trial.status === "scheduled" && (
-
-
- Start trial
-
-
- )}
- {trial.status === "in_progress" && (
-
-
- Control trial
-
-
- )}
- {trial.status === "completed" && (
-
-
- View analysis
-
-
- )}
-
-
-
- Edit trial
-
-
- {(trial.status === "scheduled" || trial.status === "failed") && (
-
- Cancel trial
-
- )}
-
-
- );
- },
+ cell: ({ row }) => ,
},
];
+function ActionsCell({ row }: { row: { original: Trial } }) {
+ const trial = row.original;
+ const router = React.useMemo(() => require("next/navigation").useRouter(), []); // Dynamic import to avoid hook rules in static context? No, this component is rendered in Table.
+ // Actually, hooks must be at top level. This ActionsCell will be a regular component.
+ // But useRouter might fail if columns is not in component tree?
+ // Table cells are rendered by flexRender in React, so they are components.
+ // importing useRouter is fine.
+
+ const utils = api.useUtils();
+ const duplicateMutation = api.trials.duplicate.useMutation({
+ onSuccess: () => {
+ utils.trials.list.invalidate();
+ // toast.success("Trial duplicated"); // We need toast
+ },
+ });
+
+ if (!trial?.id) {
+ return No actions ;
+ }
+
+ return (
+
+
+
+ Open menu
+
+
+
+
+ Actions
+ navigator.clipboard.writeText(trial.id)}
+ >
+
+ Copy ID
+
+
+
+
+
+ Details
+
+
+ {trial.status === "scheduled" && (
+
+
+
+ Start Trial
+
+
+ )}
+ {trial.status === "in_progress" && (
+
+
+
+ Control Trial
+
+
+ )}
+ {trial.status === "completed" && (
+
+
+
+ Analysis
+
+
+ )}
+
+ duplicateMutation.mutate({ id: trial.id })}>
+
+ Duplicate
+
+
+ {(trial.status === "scheduled" || trial.status === "failed") && (
+
+
+ Cancel
+
+ )}
+
+
+ );
+}
+
interface TrialsTableProps {
studyId?: string;
}
diff --git a/src/components/trials/timeline/HorizontalTimeline.tsx b/src/components/trials/timeline/HorizontalTimeline.tsx
new file mode 100644
index 0000000..2b53c43
--- /dev/null
+++ b/src/components/trials/timeline/HorizontalTimeline.tsx
@@ -0,0 +1,203 @@
+"use client";
+
+import React, { useState } from "react";
+import { Badge } from "~/components/ui/badge";
+import { Card, CardContent } from "~/components/ui/card";
+import { ScrollArea } from "~/components/ui/scroll-area";
+import { Flag, CheckCircle, Bot, User, MessageSquare, AlertTriangle, Activity } from "lucide-react";
+
+interface TimelineEvent {
+ type: string;
+ timestamp: Date;
+ message?: string;
+ data?: unknown;
+}
+
+interface HorizontalTimelineProps {
+ events: TimelineEvent[];
+ startTime?: Date;
+ endTime?: Date;
+}
+
+export function HorizontalTimeline({ events, startTime, endTime }: HorizontalTimelineProps) {
+ const [selectedEvent, setSelectedEvent] = useState(null);
+
+ if (events.length === 0) {
+ return (
+
+ No events recorded yet
+
+ );
+ }
+
+ // Calculate time range
+ const timestamps = events.map(e => e.timestamp.getTime());
+ const minTime = startTime?.getTime() ?? Math.min(...timestamps);
+ const maxTime = endTime?.getTime() ?? Math.max(...timestamps);
+ const duration = maxTime - minTime;
+
+ // Generate time markers (every 10 seconds or appropriate interval)
+ const getTimeMarkers = () => {
+ const markers: Date[] = [];
+ const interval = duration > 300000 ? 60000 : duration > 60000 ? 30000 : 10000; // 1min, 30s, or 10s intervals
+
+ for (let time = minTime; time <= maxTime; time += interval) {
+ markers.push(new Date(time));
+ }
+ if (markers[markers.length - 1]?.getTime() !== maxTime) {
+ markers.push(new Date(maxTime));
+ }
+ return markers;
+ };
+
+ const timeMarkers = getTimeMarkers();
+
+ // Get position percentage for a timestamp
+ const getPosition = (timestamp: Date) => {
+ if (duration === 0) return 50;
+ return ((timestamp.getTime() - minTime) / duration) * 100;
+ };
+
+ // Get color and icon for event type
+ const getEventStyle = (eventType: string) => {
+ if (eventType.includes("start") || eventType === "trial_started") {
+ return { color: "bg-blue-500", Icon: Flag };
+ } else if (eventType.includes("complete") || eventType === "trial_completed") {
+ return { color: "bg-green-500", Icon: CheckCircle };
+ } else if (eventType.includes("robot") || eventType.includes("action")) {
+ return { color: "bg-purple-500", Icon: Bot };
+ } else if (eventType.includes("wizard") || eventType.includes("intervention")) {
+ return { color: "bg-orange-500", Icon: User };
+ } else if (eventType.includes("note") || eventType.includes("annotation")) {
+ return { color: "bg-yellow-500", Icon: MessageSquare };
+ } else if (eventType.includes("error") || eventType.includes("issue")) {
+ return { color: "bg-red-500", Icon: AlertTriangle };
+ }
+ return { color: "bg-gray-500", Icon: Activity };
+ };
+
+ return (
+
+ {/* Timeline visualization */}
+
+
+
+ {/* Time markers */}
+
+ {/* Main horizontal line */}
+
+
+ {/* Time labels */}
+ {timeMarkers.map((marker, i) => {
+ const pos = getPosition(marker);
+ return (
+
+
+
+ {marker.toLocaleTimeString([], {
+ hour12: false,
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit'
+ })}
+
+
+
+
+ );
+ })}
+
+
+ {/* Event markers */}
+
+ {/* Timeline line for events */}
+
+
+ {events.map((event, i) => {
+ const pos = getPosition(event.timestamp);
+ const { color, Icon } = getEventStyle(event.type);
+ const isSelected = selectedEvent === event;
+
+ return (
+
+ {/* Clickable marker group */}
+
setSelectedEvent(isSelected ? null : event)}
+ className="flex flex-col items-center gap-1 cursor-pointer group"
+ title={event.message || event.type}
+ >
+ {/* Vertical dash */}
+
+
+ {/* Icon indicator */}
+
+
+
+
+
+ );
+ })}
+
+
+
+
+
+ {/* Selected event details */}
+ {selectedEvent && (
+
+
+
+
+
+ {selectedEvent.type.replace(/_/g, " ")}
+
+
+ {selectedEvent.timestamp.toLocaleTimeString([], {
+ hour12: false,
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ fractionalSecondDigits: 3
+ })}
+
+
+ {selectedEvent.message && (
+
{selectedEvent.message}
+ )}
+ {selectedEvent.data !== undefined && selectedEvent.data !== null && (
+
+
+ Event data
+
+
+ {JSON.stringify(selectedEvent.data, null, 2)}
+
+
+ )}
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/trials/views/TrialAnalysisView.tsx b/src/components/trials/views/TrialAnalysisView.tsx
new file mode 100644
index 0000000..b4d0209
--- /dev/null
+++ b/src/components/trials/views/TrialAnalysisView.tsx
@@ -0,0 +1,124 @@
+"use client";
+
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
+import { Badge } from "~/components/ui/badge";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
+import { LineChart, BarChart, Clock, Database, FileText } from "lucide-react";
+import { formatDistanceToNow } from "date-fns";
+
+interface TrialAnalysisViewProps {
+ trial: {
+ id: string;
+ status: string;
+ startedAt: Date | null;
+ completedAt: Date | null;
+ duration: number | null;
+ experiment: { name: string };
+ participant: { participantCode: string };
+ eventCount?: number;
+ mediaCount?: number;
+ };
+}
+
+export function TrialAnalysisView({ trial }: TrialAnalysisViewProps) {
+ return (
+
+
+
+
+ Status
+
+
+
+ {trial.status.replace("_", " ")}
+
+ {trial.completedAt
+ ? `Completed ${formatDistanceToNow(new Date(trial.completedAt), { addSuffix: true })}`
+ : "Not completed"}
+
+
+
+
+
+
+ Duration
+
+
+
+
+ {trial.duration ? `${Math.floor(trial.duration / 60)}m ${trial.duration % 60}s` : "N/A"}
+
+
+ Total execution time
+
+
+
+
+
+
+ Events Logged
+
+
+
+ {trial.eventCount ?? 0}
+
+ System & user events
+
+
+
+
+
+
+ Media Files
+
+
+
+ {trial.mediaCount ?? 0}
+
+ Recordings & snapshots
+
+
+
+
+
+
+
+ Overview
+ Event Log
+ Charts
+
+
+
+
+ Analysis Overview
+
+ Summary of trial execution for {trial.participant.participantCode} in experiment {trial.experiment.name}.
+
+
+
+
+
+
Detailed analysis visualizations coming soon.
+
+
+
+
+
+
+
+ Event Log
+
+ Chronological record of all trial events.
+
+
+
+
+
Event log view placeholder.
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/trials/wizard/WizardInterface.tsx b/src/components/trials/wizard/WizardInterface.tsx
index 42fd255..5b4714f 100755
--- a/src/components/trials/wizard/WizardInterface.tsx
+++ b/src/components/trials/wizard/WizardInterface.tsx
@@ -2,6 +2,7 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Play, CheckCircle, X, Clock, AlertCircle } from "lucide-react";
+import { useRouter } from "next/navigation";
import { Badge } from "~/components/ui/badge";
import { Progress } from "~/components/ui/progress";
import { Alert, AlertDescription } from "~/components/ui/alert";
@@ -9,6 +10,12 @@ import { PanelsContainer } from "~/components/experiments/designer/layout/Panels
import { WizardControlPanel } from "./panels/WizardControlPanel";
import { WizardExecutionPanel } from "./panels/WizardExecutionPanel";
import { WizardMonitoringPanel } from "./panels/WizardMonitoringPanel";
+import { WizardObservationPane } from "./panels/WizardObservationPane";
+import {
+ ResizableHandle,
+ ResizablePanel,
+ ResizablePanelGroup,
+} from "~/components/ui/resizable";
import { api } from "~/trpc/react";
import { useWizardRos } from "~/hooks/useWizardRos";
import { toast } from "sonner";
@@ -42,6 +49,16 @@ interface WizardInterfaceProps {
userRole: string;
}
+interface ActionData {
+ id: string;
+ name: string;
+ description: string | null;
+ type: string;
+ parameters: Record;
+ order: number;
+ pluginId: string | null;
+}
+
interface StepData {
id: string;
name: string;
@@ -53,6 +70,7 @@ interface StepData {
| "conditional_branch";
parameters: Record;
order: number;
+ actions: ActionData[];
}
export const WizardInterface = React.memo(function WizardInterface({
@@ -65,6 +83,7 @@ export const WizardInterface = React.memo(function WizardInterface({
initialTrial.startedAt ? new Date(initialTrial.startedAt) : null,
);
const [elapsedTime, setElapsedTime] = useState(0);
+ const router = useRouter();
// Persistent tab states to prevent resets from parent re-renders
const [controlPanelTab, setControlPanelTab] = useState<
@@ -73,9 +92,16 @@ export const WizardInterface = React.memo(function WizardInterface({
const [executionPanelTab, setExecutionPanelTab] = useState<
"current" | "timeline" | "events"
>(trial.status === "in_progress" ? "current" : "timeline");
+ const [isExecutingAction, setIsExecutingAction] = useState(false);
const [monitoringPanelTab, setMonitoringPanelTab] = useState<
"status" | "robot" | "events"
>("status");
+ const [completedActionsCount, setCompletedActionsCount] = useState(0);
+
+ // Reset completed actions when step changes
+ useEffect(() => {
+ setCompletedActionsCount(0);
+ }, [currentStepIndex]);
// Get experiment steps from API
const { data: experimentSteps } = api.experiments.getSteps.useQuery(
@@ -100,6 +126,13 @@ export const WizardInterface = React.memo(function WizardInterface({
},
});
+ // Log robot action mutation (for client-side execution)
+ const logRobotActionMutation = api.trials.logRobotAction.useMutation({
+ onError: (error) => {
+ console.error("Failed to log robot action:", error);
+ },
+ });
+
// Map database step types to component step types
const mapStepType = (dbType: string) => {
switch (dbType) {
@@ -136,6 +169,7 @@ export const WizardInterface = React.memo(function WizardInterface({
connect: connectRos,
disconnect: disconnectRos,
executeRobotAction: executeRosAction,
+ setAutonomousLife,
} = useWizardRos({
autoConnect: true,
onActionCompleted,
@@ -152,6 +186,15 @@ export const WizardInterface = React.memo(function WizardInterface({
},
);
+ // Poll for trial events
+ const { data: fetchedEvents } = api.trials.getEvents.useQuery(
+ { trialId: trial.id, limit: 100 },
+ {
+ refetchInterval: 3000,
+ staleTime: 1000,
+ }
+ );
+
// Update local trial state from polling
useEffect(() => {
if (pollingData) {
@@ -168,7 +211,15 @@ export const WizardInterface = React.memo(function WizardInterface({
}
}, [pollingData]);
+ // Auto-start trial on mount if scheduled
+ useEffect(() => {
+ if (trial.status === "scheduled") {
+ handleStartTrial();
+ }
+ }, []); // Run once on mount
+
// Trial events from robot actions
+
const trialEvents = useMemo<
Array<{
type: string;
@@ -176,7 +227,38 @@ export const WizardInterface = React.memo(function WizardInterface({
data?: unknown;
message?: string;
}>
- >(() => [], []);
+ >(() => {
+ return (fetchedEvents ?? []).map(event => {
+ let message: string | undefined;
+ const eventData = event.data as any;
+
+ // Extract or generate message based on event type
+ if (event.eventType.startsWith('annotation_')) {
+ message = eventData?.description || eventData?.label || 'Annotation added';
+ } else if (event.eventType.startsWith('robot_action_')) {
+ const actionName = event.eventType.replace('robot_action_', '').replace(/_/g, ' ');
+ message = `Robot action: ${actionName}`;
+ } else if (event.eventType === 'trial_started') {
+ message = 'Trial started';
+ } else if (event.eventType === 'trial_completed') {
+ message = 'Trial completed';
+ } else if (event.eventType === 'step_changed') {
+ message = `Step changed to: ${eventData?.stepName || 'next step'}`;
+ } else if (event.eventType.startsWith('wizard_')) {
+ message = eventData?.notes || eventData?.message || event.eventType.replace('wizard_', '').replace(/_/g, ' ');
+ } else {
+ // Generic fallback
+ message = eventData?.notes || eventData?.message || eventData?.description || event.eventType.replace(/_/g, ' ');
+ }
+
+ return {
+ type: event.eventType,
+ timestamp: new Date(event.timestamp),
+ data: event.data,
+ message,
+ };
+ }).sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); // Newest first
+ }, [fetchedEvents]);
// Transform experiment steps to component format
const steps: StepData[] =
@@ -187,6 +269,15 @@ export const WizardInterface = React.memo(function WizardInterface({
type: mapStepType(step.type),
parameters: step.parameters ?? {},
order: step.order ?? index,
+ actions: step.actions?.map((action) => ({
+ id: action.id,
+ name: action.name,
+ description: action.description,
+ type: action.type,
+ parameters: action.parameters ?? {},
+ order: action.order,
+ pluginId: action.pluginId,
+ })) ?? [],
})) ?? [];
const currentStep = steps[currentStepIndex] ?? null;
@@ -261,6 +352,8 @@ export const WizardInterface = React.memo(function WizardInterface({
status: data.status,
completedAt: data.completedAt,
});
+ toast.success("Trial completed! Redirecting to analysis...");
+ router.push(`/studies/${trial.experiment.studyId}/trials/${trial.id}/analysis`);
}
},
});
@@ -314,6 +407,7 @@ export const WizardInterface = React.memo(function WizardInterface({
const handleNextStep = () => {
if (currentStepIndex < steps.length - 1) {
+ setCompletedActionsCount(0); // Reset immediately to prevent flickering/double-click issues
setCurrentStepIndex(currentStepIndex + 1);
// Note: Step transitions can be enhanced later with database logging
}
@@ -335,15 +429,72 @@ export const WizardInterface = React.memo(function WizardInterface({
}
};
+ // Mutations for annotations
+ const addAnnotationMutation = api.trials.addAnnotation.useMutation({
+ onSuccess: () => {
+ toast.success("Note added");
+ },
+ onError: (error) => {
+ toast.error("Failed to add note", { description: error.message });
+ },
+ });
+
+ const handleAddAnnotation = async (
+ description: string,
+ category?: string,
+ tags?: string[],
+ ) => {
+ await addAnnotationMutation.mutateAsync({
+ trialId: trial.id,
+ description,
+ category,
+ tags,
+ });
+ };
+
+ // Mutation for events (Acknowledge)
+ const logEventMutation = api.trials.logEvent.useMutation({
+ onSuccess: () => toast.success("Event logged"),
+ });
+
+ // Mutation for interventions
+ const addInterventionMutation = api.trials.addIntervention.useMutation({
+ onSuccess: () => toast.success("Intervention logged"),
+ });
+
const handleExecuteAction = async (
actionId: string,
parameters?: Record,
) => {
try {
console.log("Executing action:", actionId, parameters);
+
+ if (actionId === "acknowledge") {
+ await logEventMutation.mutateAsync({
+ trialId: trial.id,
+ type: "wizard_acknowledge",
+ data: parameters,
+ });
+ handleNextStep();
+ } else if (actionId === "intervene") {
+ await addInterventionMutation.mutateAsync({
+ trialId: trial.id,
+ type: "manual_intervention",
+ description: "Wizard manual intervention triggered",
+ data: parameters,
+ });
+ } else if (actionId === "note") {
+ await addAnnotationMutation.mutateAsync({
+ trialId: trial.id,
+ description: String(parameters?.content || "Quick note"),
+ category: String(parameters?.category || "quick_note")
+ });
+ }
+
// Note: Action execution can be enhanced later with tRPC mutations
} catch (error) {
console.error("Failed to execute action:", error);
+ toast.error("Failed to execute action");
}
};
@@ -352,22 +503,34 @@ export const WizardInterface = React.memo(function WizardInterface({
pluginName: string,
actionId: string,
parameters: Record,
+ options?: { autoAdvance?: boolean },
) => {
try {
+ setIsExecutingAction(true);
// Try direct WebSocket execution first for better performance
if (rosConnected) {
try {
- await executeRosAction(pluginName, actionId, parameters);
+ const result = await executeRosAction(pluginName, actionId, parameters);
+
+ const duration =
+ result.endTime && result.startTime
+ ? result.endTime.getTime() - result.startTime.getTime()
+ : 0;
// Log to trial events for data capture
- await executeRobotActionMutation.mutateAsync({
+ await logRobotActionMutation.mutateAsync({
trialId: trial.id,
pluginName,
actionId,
parameters,
+ duration,
+ result: { status: result.status },
});
toast.success(`Robot action executed: ${actionId}`);
+ if (options?.autoAdvance) {
+ handleNextStep();
+ }
} catch (rosError) {
console.warn(
"WebSocket execution failed, falling back to tRPC:",
@@ -383,6 +546,9 @@ export const WizardInterface = React.memo(function WizardInterface({
});
toast.success(`Robot action executed via fallback: ${actionId}`);
+ if (options?.autoAdvance) {
+ handleNextStep();
+ }
}
} else {
// Use tRPC execution if WebSocket not connected
@@ -394,17 +560,51 @@ export const WizardInterface = React.memo(function WizardInterface({
});
toast.success(`Robot action executed: ${actionId}`);
+ if (options?.autoAdvance) {
+ handleNextStep();
+ }
}
} catch (error) {
console.error("Failed to execute robot action:", error);
toast.error(`Failed to execute robot action: ${actionId}`, {
description: error instanceof Error ? error.message : "Unknown error",
});
+ } finally {
+ setIsExecutingAction(false);
}
},
[rosConnected, executeRosAction, executeRobotActionMutation, trial.id],
);
+ const handleSkipAction = useCallback(
+ async (
+ pluginName: string,
+ actionId: string,
+ parameters: Record,
+ options?: { autoAdvance?: boolean },
+ ) => {
+ try {
+ await logRobotActionMutation.mutateAsync({
+ trialId: trial.id,
+ pluginName,
+ actionId,
+ parameters,
+ duration: 0,
+ result: { skipped: true },
+ });
+
+ toast.info(`Action skipped: ${actionId}`);
+ if (options?.autoAdvance) {
+ handleNextStep();
+ }
+ } catch (error) {
+ console.error("Failed to skip action:", error);
+ toast.error("Failed to skip action");
+ }
+ },
+ [logRobotActionMutation, trial.id],
+ );
+
return (
{/* Compact Status Bar */}
@@ -451,58 +651,78 @@ export const WizardInterface = React.memo(function WizardInterface({
- {/* No connection status alert - ROS connection shown in monitoring panel */}
-
- {/* Main Content - Three Panel Layout */}
+ {/* Main Content with Vertical Resizable Split */}
-
+
+
+ }
+ center={
+ setCurrentStepIndex(index)}
+ onExecuteAction={handleExecuteAction}
+ onExecuteRobotAction={handleExecuteRobotAction}
+ activeTab={executionPanelTab}
+ onTabChange={setExecutionPanelTab}
+ onSkipAction={handleSkipAction}
+ isExecuting={isExecutingAction}
+ onNextStep={handleNextStep}
+ completedActionsCount={completedActionsCount}
+ onActionCompleted={() => setCompletedActionsCount(c => c + 1)}
+ onCompleteTrial={handleCompleteTrial}
+ />
+ }
+ right={
+
+ }
+ showDividers={true}
+ className="h-full"
/>
- }
- center={
-
+
+
+
+
+ setCurrentStepIndex(index)}
- onExecuteAction={handleExecuteAction}
- activeTab={executionPanelTab}
- onTabChange={setExecutionPanelTab}
/>
- }
- right={
-
- }
- showDividers={true}
- className="h-full"
- />
+
+
);
diff --git a/src/components/trials/wizard/panels/WizardControlPanel.tsx b/src/components/trials/wizard/panels/WizardControlPanel.tsx
index c23b2cf..f5a2d9f 100755
--- a/src/components/trials/wizard/panels/WizardControlPanel.tsx
+++ b/src/components/trials/wizard/panels/WizardControlPanel.tsx
@@ -19,6 +19,8 @@ import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { Progress } from "~/components/ui/progress";
import { Separator } from "~/components/ui/separator";
+import { Switch } from "~/components/ui/switch";
+import { Label } from "~/components/ui/label";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
import { ScrollArea } from "~/components/ui/scroll-area";
@@ -35,6 +37,15 @@ interface StepData {
| "conditional_branch";
parameters: Record
;
order: number;
+ actions?: {
+ id: string;
+ name: string;
+ description: string | null;
+ type: string;
+ parameters: Record;
+ order: number;
+ pluginId: string | null;
+ }[];
}
interface TrialData {
@@ -86,6 +97,7 @@ interface WizardControlPanelProps {
activeTab: "control" | "step" | "actions" | "robot";
onTabChange: (tab: "control" | "step" | "actions" | "robot") => void;
isStarting?: boolean;
+ onSetAutonomousLife?: (enabled: boolean) => Promise;
}
export function WizardControlPanel({
@@ -105,65 +117,28 @@ export function WizardControlPanel({
activeTab,
onTabChange,
isStarting = false,
+ onSetAutonomousLife,
}: WizardControlPanelProps) {
- const progress =
- steps.length > 0 ? ((currentStepIndex + 1) / steps.length) * 100 : 0;
+ const [autonomousLife, setAutonomousLife] = React.useState(true);
- const getStatusConfig = (status: string) => {
- switch (status) {
- case "scheduled":
- return { variant: "outline" as const, icon: Clock };
- case "in_progress":
- return { variant: "default" as const, icon: Play };
- case "completed":
- return { variant: "secondary" as const, icon: CheckCircle };
- case "aborted":
- case "failed":
- return { variant: "destructive" as const, icon: X };
- default:
- return { variant: "outline" as const, icon: Clock };
+ const handleAutonomousLifeChange = async (checked: boolean) => {
+ setAutonomousLife(checked); // Optimistic update
+ if (onSetAutonomousLife) {
+ try {
+ const result = await onSetAutonomousLife(checked);
+ if (result === false) {
+ throw new Error("Service unavailable");
+ }
+ } catch (error) {
+ console.error("Failed to set autonomous life:", error);
+ setAutonomousLife(!checked); // Revert on failure
+ // Optional: Toast error?
+ }
}
};
- const statusConfig = getStatusConfig(trial.status);
- const StatusIcon = statusConfig.icon;
-
return (
- {/* Trial Info Header */}
-
-
-
-
-
- {trial.status.replace("_", " ")}
-
-
- Session #{trial.sessionNumber}
-
-
-
-
- {trial.participant.participantCode}
-
-
- {trial.status === "in_progress" && steps.length > 0 && (
-
-
- Progress
-
- {currentStepIndex + 1} of {steps.length}
-
-
-
-
- )}
-
-
-
{/* Tabbed Content */}
)}
- {/* Connection Status */}
-
-
Connection
+
+
Robot Status
+
- Status
+ Connection
-
- Polling
-
+ {_isConnected ? (
+
+ Connected
+
+ ) : (
+
+ Polling...
+
+ )}
+
+
+
diff --git a/src/components/trials/wizard/panels/WizardExecutionPanel.tsx b/src/components/trials/wizard/panels/WizardExecutionPanel.tsx
index d85e91a..9320649 100755
--- a/src/components/trials/wizard/panels/WizardExecutionPanel.tsx
+++ b/src/components/trials/wizard/panels/WizardExecutionPanel.tsx
@@ -12,6 +12,10 @@ import {
Zap,
Eye,
List,
+ Loader2,
+ ArrowRight,
+ AlertTriangle,
+ RotateCcw,
} from "lucide-react";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
@@ -24,12 +28,21 @@ interface StepData {
name: string;
description: string | null;
type:
- | "wizard_action"
- | "robot_action"
- | "parallel_steps"
- | "conditional_branch";
+ | "wizard_action"
+ | "robot_action"
+ | "parallel_steps"
+ | "conditional_branch";
parameters: Record;
order: number;
+ actions?: {
+ id: string;
+ name: string;
+ description: string | null;
+ type: string;
+ parameters: Record;
+ order: number;
+ pluginId: string | null;
+ }[];
}
interface TrialData {
@@ -75,8 +88,25 @@ interface WizardExecutionPanelProps {
actionId: string,
parameters?: Record,
) => void;
- activeTab: "current" | "timeline" | "events";
- onTabChange: (tab: "current" | "timeline" | "events") => void;
+ onExecuteRobotAction: (
+ pluginName: string,
+ actionId: string,
+ parameters: Record,
+ options?: { autoAdvance?: boolean },
+ ) => Promise;
+ activeTab: "current" | "timeline" | "events"; // Deprecated/Ignored
+ onTabChange: (tab: "current" | "timeline" | "events") => void; // Deprecated/Ignored
+ onSkipAction: (
+ pluginName: string,
+ actionId: string,
+ parameters: Record,
+ options?: { autoAdvance?: boolean },
+ ) => Promise;
+ isExecuting?: boolean;
+ onNextStep?: () => void;
+ onCompleteTrial?: () => void;
+ completedActionsCount: number;
+ onActionCompleted: () => void;
}
export function WizardExecutionPanel({
@@ -87,9 +117,21 @@ export function WizardExecutionPanel({
trialEvents,
onStepSelect,
onExecuteAction,
+ onExecuteRobotAction,
activeTab,
onTabChange,
+ onSkipAction,
+ isExecuting = false,
+ onNextStep,
+ onCompleteTrial,
+ completedActionsCount,
+ onActionCompleted,
}: WizardExecutionPanelProps) {
+ // Local state removed in favor of parent state to prevent reset on re-render
+ // const [completedCount, setCompletedCount] = React.useState(0);
+
+ const activeActionIndex = completedActionsCount;
+
const getStepIcon = (type: string) => {
switch (type) {
case "wizard_action":
@@ -169,7 +211,7 @@ export function WizardExecutionPanel({
{trial.completedAt &&
- `Ended at ${new Date(trial.completedAt).toLocaleTimeString()}`}
+ `Ended at ${new Date(trial.completedAt).toLocaleTimeString()} `}
@@ -209,281 +251,228 @@ export function WizardExecutionPanel({
)}
- {/* Tabbed Content */}
- {
- if (
- value === "current" ||
- value === "timeline" ||
- value === "events"
- ) {
- onTabChange(value);
- }
- }}
- className="flex min-h-0 flex-1 flex-col"
- >
-
-
-
-
- Current
-
-
-
- Timeline
-
-
-
- Events
- {trialEvents.length > 0 && (
-
- {trialEvents.length}
-
- )}
-
-
-
-
-
- {/* Current Step Tab */}
-
-
- {currentStep ? (
-
- {/* Current Step Display */}
-
-
-
- {React.createElement(getStepIcon(currentStep.type), {
- className: "h-5 w-5 text-primary",
- })}
-
-
-
- {currentStep.name}
-
-
- {currentStep.type.replace("_", " ")}
-
-
-
-
+ {/* Simplified Content - Sequential Focus */}
+
+
+ {currentStep ? (
+
+ {/* Header Info (Simplified) */}
+
+
+
+
{currentStep.name}
{currentStep.description && (
-
- {currentStep.description}
-
- )}
-
- {/* Step-specific content */}
- {currentStep.type === "wizard_action" && (
-
-
- Available Actions
-
-
- onExecuteAction("acknowledge")}
- >
-
- Acknowledge Step
-
- onExecuteAction("intervene")}
- >
-
- Manual Intervention
-
-
- onExecuteAction("note", {
- content: "Step observation",
- })
- }
- >
-
- Add Observation
-
-
-
- )}
-
- {currentStep.type === "robot_action" && (
-
-
-
-
- Robot Action in Progress
-
-
- The robot is executing this step. Monitor status in
- the monitoring panel.
-
-
-
- )}
-
- {currentStep.type === "parallel_steps" && (
-
-
-
- Parallel Execution
-
- Multiple actions are running simultaneously.
-
-
-
+
{currentStep.description}
)}
- ) : (
-
-
-
- No current step available
+
+
+ {/* Action Sequence */}
+ {currentStep.actions && currentStep.actions.length > 0 && (
+
+
+
+ Execution Sequence
+
+
+
+
+ {currentStep.actions.map((action, idx) => {
+ const isCompleted = idx < activeActionIndex;
+ const isActive = idx === activeActionIndex;
+ const isPending = idx > activeActionIndex;
+
+ return (
+
+
+ {isCompleted ? : idx + 1}
+
+
+
+
{action.name}
+ {action.description && (
+
+ {action.description}
+
+ )}
+
+
+ {action.pluginId && isActive && (
+
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ console.log("Skip clicked");
+ // Fire and forget
+ onSkipAction(
+ action.pluginId!,
+ action.type.includes(".")
+ ? action.type.split(".").pop()!
+ : action.type,
+ action.parameters || {},
+ { autoAdvance: false }
+ );
+ onActionCompleted();
+ }}
+ >
+ Skip
+
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ console.log("Execute clicked");
+ onExecuteRobotAction(
+ action.pluginId!,
+ action.type.includes(".")
+ ? action.type.split(".").pop()!
+ : action.type,
+ action.parameters || {},
+ { autoAdvance: false },
+ );
+ onActionCompleted();
+ }}
+ >
+
+ Execute
+
+
+ )}
+
+ {/* Fallback for actions with no plugin ID (e.g. manual steps) */}
+ {!action.pluginId && isActive && (
+
+ {
+ e.preventDefault();
+ onActionCompleted();
+ }}
+ >
+ Mark Done
+
+
+ )}
+
+ {/* Completed State Indicator */}
+ {isCompleted && (
+
+
+ Done
+
+ {action.pluginId && (
+ <>
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ // Execute again without advancing count
+ onExecuteRobotAction(
+ action.pluginId!,
+ action.type.includes(".") ? action.type.split(".").pop()! : action.type,
+ action.parameters || {},
+ { autoAdvance: false },
+ );
+ }}
+ >
+
+
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ onExecuteAction("note", {
+ content: `Reported issue with action: ${action.name}`,
+ category: "system_issue"
+ });
+ }}
+ >
+
+
+ >
+ )}
+
+ )}
+
+ )
+ })}
+
+
+ {/* Manual Advance Button */}
+ {activeActionIndex >= (currentStep.actions?.length || 0) && (
+
+
+ {currentStepIndex === steps.length - 1 ? "Complete Trial" : "Complete Step"}
+
+
+ )}
+
+ )}
+
+ {/* Manual Wizard Controls (If applicable) */}
+ {currentStep.type === "wizard_action" && (
+
+
Manual Controls
+
+ onExecuteAction("acknowledge")}
+ >
+
+ Acknowledge
+
+ onExecuteAction("intervene")}
+ >
+
+ Intervene
+
)}
-
-
- {/* Timeline Tab */}
-
-
-
- {steps.map((step, index) => {
- const status = getStepStatus(index);
- const StepIcon = getStepIcon(step.type);
- const isActive = index === currentStepIndex;
-
- return (
-
onStepSelect(index)}
- >
- {/* Step Number and Status */}
-
-
- {status === "completed" ? (
-
- ) : (
- index + 1
- )}
-
- {index < steps.length - 1 && (
-
- )}
-
-
- {/* Step Content */}
-
-
-
-
- {step.name}
-
-
- {step.type.replace("_", " ")}
-
-
-
- {step.description && (
-
- {step.description}
-
- )}
-
- {isActive && trial.status === "in_progress" && (
-
- )}
-
-
- );
- })}
-
-
-
-
- {/* Events Tab */}
-
-
-
- {trialEvents.length === 0 ? (
-
-
- No events recorded yet
-
-
- ) : (
-
- {trialEvents
- .slice()
- .reverse()
- .map((event, index) => (
-
-
-
-
- {event.type.replace(/_/g, " ")}
-
- {event.message && (
-
- {event.message}
-
- )}
-
- {event.timestamp.toLocaleTimeString()}
-
-
-
- ))}
-
- )}
-
-
-
-
-
-
+ ) : (
+
+ No active step
+
+ )}
+
+
+
);
}
diff --git a/src/components/trials/wizard/panels/WizardObservationPane.tsx b/src/components/trials/wizard/panels/WizardObservationPane.tsx
new file mode 100644
index 0000000..918ac77
--- /dev/null
+++ b/src/components/trials/wizard/panels/WizardObservationPane.tsx
@@ -0,0 +1,158 @@
+"use client";
+
+import React, { useState } from "react";
+import { Send, Hash, Tag, Clock, Flag, CheckCircle, Bot, User, MessageSquare, AlertTriangle, Activity } from "lucide-react";
+import { Button } from "~/components/ui/button";
+import { Textarea } from "~/components/ui/textarea";
+import { ScrollArea } from "~/components/ui/scroll-area";
+import { Badge } from "~/components/ui/badge";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "~/components/ui/select";
+import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
+import { HorizontalTimeline } from "~/components/trials/timeline/HorizontalTimeline";
+
+interface TrialEvent {
+ type: string;
+ timestamp: Date;
+ data?: unknown;
+ message?: string;
+}
+
+interface WizardObservationPaneProps {
+ onAddAnnotation: (
+ description: string,
+ category?: string,
+ tags?: string[],
+ ) => Promise
;
+ isSubmitting?: boolean;
+}
+
+export function WizardObservationPane({
+ onAddAnnotation,
+ isSubmitting = false,
+ trialEvents = [],
+}: WizardObservationPaneProps & { trialEvents?: TrialEvent[] }) {
+ const [note, setNote] = useState("");
+ const [category, setCategory] = useState("observation");
+ const [tags, setTags] = useState([]);
+ const [currentTag, setCurrentTag] = useState("");
+
+ const handleSubmit = async () => {
+ if (!note.trim()) return;
+
+ await onAddAnnotation(note, category, tags);
+ setNote("");
+ setTags([]);
+ setCurrentTag("");
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
+ handleSubmit();
+ }
+ };
+
+ const addTag = () => {
+ const trimmed = currentTag.trim();
+ if (trimmed && !tags.includes(trimmed)) {
+ setTags([...tags, trimmed]);
+ setCurrentTag("");
+ }
+ };
+
+ return (
+
+
+
+
+
+ Notes & Observations
+
+
+ Timeline
+
+
+
+
+
+
+
setNote(e.target.value)}
+ onKeyDown={handleKeyDown}
+ />
+
+
+
+
+
+
+
+ Observation
+ Behavior
+ System Issue
+ Success
+ Failure
+
+
+
+
+
+ setCurrentTag(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ addTag();
+ }
+ }}
+ onBlur={addTag}
+ />
+
+
+
+
+ Add Note
+
+
+
+ {tags.length > 0 && (
+
+ {tags.map((tag) => (
+ setTags(tags.filter((t) => t !== tag))}
+ >
+ #{tag}
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/ui/entity-form.tsx b/src/components/ui/entity-form.tsx
index 327db9e..ad8d16b 100755
--- a/src/components/ui/entity-form.tsx
+++ b/src/components/ui/entity-form.tsx
@@ -119,8 +119,8 @@ export function EntityForm({
{/* Form Layout */}
diff --git a/src/components/ui/entity-view.tsx b/src/components/ui/entity-view.tsx
index d785312..5de6df8 100755
--- a/src/components/ui/entity-view.tsx
+++ b/src/components/ui/entity-view.tsx
@@ -1,6 +1,7 @@
"use client";
import * as LucideIcons from "lucide-react";
+import { cn } from "~/lib/utils";
import { type ReactNode } from "react";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
@@ -45,10 +46,15 @@ interface EntityViewSidebarProps {
children: ReactNode;
}
+
interface EntityViewProps {
children: ReactNode;
+ layout?: "default" | "full-width";
}
+// ... existing code ...
+
+
export function EntityViewHeader({
title,
subtitle,
@@ -115,8 +121,15 @@ export function EntityViewSidebar({ children }: EntityViewSidebarProps) {
return
{children}
;
}
-export function EntityView({ children }: EntityViewProps) {
- return
{children}
;
+export function EntityView({ children, layout = "default" }: EntityViewProps) {
+ // Simplification: Always take full width of the parent container provided by DashboardLayout
+ // The DashboardLayout already provides padding (p-4).
+ // We remove 'container mx-auto max-w-5xl' to stop it from shrinking.
+ return (
+
+ {children}
+
+ );
}
// Utility component for empty states
@@ -158,13 +171,12 @@ interface InfoGridProps {
export function InfoGrid({ items, columns = 2 }: InfoGridProps) {
return (
{items.map((item, index) => (
Promise
;
+ callService: (service: string, args?: Record) => Promise;
+ setAutonomousLife: (enabled: boolean) => Promise;
}
+
export function useWizardRos(
options: UseWizardRosOptions = {},
): UseWizardRosReturn {
@@ -288,6 +291,24 @@ export function useWizardRos(
[isConnected],
);
+ const callService = useCallback(
+ async (service: string, args?: Record): Promise => {
+ const srv = serviceRef.current;
+ if (!srv || !isConnected) throw new Error("Not connected");
+ return srv.callService(service, args);
+ },
+ [isConnected],
+ );
+
+ const setAutonomousLife = useCallback(
+ async (enabled: boolean): Promise => {
+ const srv = serviceRef.current;
+ if (!srv || !isConnected) throw new Error("Not connected");
+ return srv.setAutonomousLife(enabled);
+ },
+ [isConnected],
+ );
+
return {
isConnected,
isConnecting,
@@ -297,5 +318,7 @@ export function useWizardRos(
connect,
disconnect,
executeRobotAction,
+ callService,
+ setAutonomousLife,
};
}
diff --git a/src/lib/ros/wizard-ros-service.ts b/src/lib/ros/wizard-ros-service.ts
index e353abe..a12f47a 100644
--- a/src/lib/ros/wizard-ros-service.ts
+++ b/src/lib/ros/wizard-ros-service.ts
@@ -14,6 +14,17 @@ export interface RosMessage {
values?: Record;
}
+export interface ServiceRequest {
+ service: string;
+ args?: Record;
+}
+
+export interface ServiceResponse {
+ result: boolean;
+ values?: Record;
+ error?: string;
+}
+
export interface RobotStatus {
connected: boolean;
battery: number;
@@ -405,7 +416,8 @@ export class WizardRosService extends EventEmitter {
let msg: Record;
if (
- config.payloadMapping.type === "template" &&
+ (config.payloadMapping.type === "template" ||
+ config.payloadMapping.type === "static") &&
config.payloadMapping.payload
) {
// Template-based payload construction
@@ -451,10 +463,15 @@ export class WizardRosService extends EventEmitter {
this.executeMovementAction(actionId, parameters);
break;
+ case "move_head":
case "turn_head":
this.executeTurnHead(parameters);
break;
+ case "move_arm":
+ this.executeMoveArm(parameters);
+ break;
+
case "emergency_stop":
this.publish("/cmd_vel", "geometry_msgs/Twist", {
linear: { x: 0, y: 0, z: 0 },
@@ -497,7 +514,7 @@ export class WizardRosService extends EventEmitter {
break;
}
- this.publish("/cmd_vel", "geometry_msgs/Twist", { linear, angular });
+ this.publish("/naoqi_driver/cmd_vel", "geometry_msgs/Twist", { linear, angular });
}
/**
@@ -515,6 +532,139 @@ export class WizardRosService extends EventEmitter {
});
}
+ /**
+ * Execute arm movement
+ */
+ private executeMoveArm(parameters: Record): void {
+ const arm = String(parameters.arm || "Right");
+ const roll = Number(parameters.roll) || 0;
+ const pitch = Number(parameters.pitch) || 0;
+ const speed = Number(parameters.speed) || 0.2;
+
+ const prefix = arm === "Left" ? "L" : "R";
+ const jointNames = [`${prefix}ShoulderPitch`, `${prefix}ShoulderRoll`];
+ const jointAngles = [pitch, roll];
+
+ this.publish("/naoqi_driver/joint_angles", "naoqi_bridge_msgs/JointAnglesWithSpeed", {
+ joint_names: jointNames,
+ joint_angles: jointAngles,
+ speed: speed,
+ });
+ }
+
+ /**
+ * Call a ROS service
+ */
+ async callService(
+ service: string,
+ args: Record = {},
+ ): Promise {
+ if (!this.isConnected) {
+ throw new Error("Not connected to ROS bridge");
+ }
+
+ const id = `call_${this.messageId++}`;
+
+ return new Promise((resolve, reject) => {
+ const handleResponse = (message: RosMessage) => {
+ if (message.op === "service_response" && message.id === id) {
+ this.off("message", handleResponse);
+ if (message.result === false) {
+ resolve({ result: false, error: String(message.values || "Service call failed") });
+ } else {
+ resolve({ result: true, values: message.values });
+ }
+ }
+ };
+
+ this.on("message", handleResponse);
+
+ this.send({
+ op: "call_service",
+ service,
+ args,
+ id,
+ });
+
+ setTimeout(() => {
+ this.off("message", handleResponse);
+ reject(new Error("Service call timed out"));
+ }, 5000);
+ });
+ }
+
+ /**
+ * Set Autonomous Life state with fallbacks
+ */
+ async setAutonomousLife(enabled: boolean): Promise {
+ const desiredState = enabled ? "interactive" : "disabled";
+
+ // List of services to try in order
+ const attempts = [
+ // Standard NaoQi Bridge pattern
+ {
+ service: "/naoqi_driver/ALAutonomousLife/setState",
+ args: { state: desiredState }
+ },
+ {
+ service: "/naoqi_driver/ALAutonomousLife/set_state",
+ args: { state: desiredState }
+ },
+ // Direct module mapping
+ {
+ service: "/ALAutonomousLife/setState",
+ args: { state: desiredState }
+ },
+ // Shortcuts/Aliases
+ {
+ service: "/naoqi_driver/set_autonomous_life",
+ args: { state: desiredState }
+ },
+ {
+ service: "/autonomous_life/set_state",
+ args: { state: desiredState }
+ },
+ // Fallback: Enable/Disable topics/services
+ {
+ service: enabled ? "/life/enable" : "/life/disable",
+ args: {}
+ },
+ // Last resort: Generic proxy call (if available)
+ {
+ service: "/naoqi_driver/function_call",
+ args: {
+ service: "ALAutonomousLife",
+ function: "setState",
+ args: [desiredState]
+ }
+ }
+ ];
+
+ console.log(`[WizardROS] Setting Autonomous Life to: ${desiredState}`);
+
+ for (const attempt of attempts) {
+ try {
+ console.log(`[WizardROS] Trying service: ${attempt.service}`);
+ const response = await this.callService(attempt.service, attempt.args);
+
+ // If the service call didn't timeout (it resolved), check result
+ if (response.result) {
+ console.log(`[WizardROS] Success via ${attempt.service}`);
+ return true;
+ } else {
+ // Resolved but failed? (e.g. internal error)
+ console.warn(`[WizardROS] Service ${attempt.service} returned false result:`, response.error);
+ }
+ } catch (error) {
+ // Service call failed or timed out
+ console.warn(`[WizardROS] Service ${attempt.service} failed/timeout:`, error);
+ }
+ }
+
+ console.error("[WizardROS] All Autonomous Life service attempts failed.");
+ return false;
+ }
+
/**
* Build template-based payload
*/
@@ -574,11 +724,13 @@ export class WizardRosService extends EventEmitter {
};
case "naoSpeechTransform":
+ case "transformToStringMessage":
return {
data: String(parameters.text || "Hello"),
};
case "naoHeadTransform":
+ case "transformToHeadMovement":
return {
joint_names: ["HeadYaw", "HeadPitch"],
joint_angles: [
@@ -588,6 +740,13 @@ export class WizardRosService extends EventEmitter {
speed: Number(parameters.speed) || 0.3,
};
+ case "transformToJointAngles":
+ return {
+ joint_names: [String(parameters.joint_name || "HeadYaw")],
+ joint_angles: [Number(parameters.angle) || 0],
+ speed: Number(parameters.speed) || 0.2,
+ };
+
default:
console.warn(`Unknown transform function: ${transformFn}`);
return parameters;
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index bd0c391..778697a 100755
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -4,3 +4,15 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+
+export function formatBytes(bytes: number, decimals = 2) {
+ if (!+bytes) return "0 Bytes";
+
+ const k = 1024;
+ const dm = decimals < 0 ? 0 : decimals;
+ const sizes = ["Bytes", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"];
+
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
+}
diff --git a/src/server/api/root.ts b/src/server/api/root.ts
index 5699e11..7bc97c0 100755
--- a/src/server/api/root.ts
+++ b/src/server/api/root.ts
@@ -4,6 +4,7 @@ import { authRouter } from "~/server/api/routers/auth";
import { collaborationRouter } from "~/server/api/routers/collaboration";
import { dashboardRouter } from "~/server/api/routers/dashboard";
import { experimentsRouter } from "~/server/api/routers/experiments";
+import { filesRouter } from "~/server/api/routers/files";
import { mediaRouter } from "~/server/api/routers/media";
import { participantsRouter } from "~/server/api/routers/participants";
import { robotsRouter } from "~/server/api/routers/robots";
@@ -25,6 +26,7 @@ export const appRouter = createTRPCRouter({
participants: participantsRouter,
trials: trialsRouter,
robots: robotsRouter,
+ files: filesRouter,
media: mediaRouter,
analytics: analyticsRouter,
collaboration: collaborationRouter,
diff --git a/src/server/api/routers/experiments.ts b/src/server/api/routers/experiments.ts
index 1a3ef0c..f79cda8 100755
--- a/src/server/api/routers/experiments.ts
+++ b/src/server/api/routers/experiments.ts
@@ -1542,6 +1542,15 @@ export const experimentsRouter = createTRPCRouter({
parameters: step.conditions as Record,
parentId: undefined, // Not supported in current schema
children: [], // TODO: implement hierarchical steps if needed
+ actions: step.actions.map((action) => ({
+ id: action.id,
+ name: action.name,
+ description: action.description,
+ type: action.type,
+ order: action.orderIndex,
+ parameters: action.parameters as Record,
+ pluginId: action.pluginId,
+ })),
}));
}),
diff --git a/src/server/api/routers/files.ts b/src/server/api/routers/files.ts
new file mode 100644
index 0000000..8d1dc68
--- /dev/null
+++ b/src/server/api/routers/files.ts
@@ -0,0 +1,146 @@
+import { z } from "zod";
+import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
+import { participantDocuments } from "~/server/db/schema";
+import { TRPCError } from "@trpc/server";
+import { env } from "~/env";
+import * as Minio from "minio";
+import { uuid } from "drizzle-orm/pg-core";
+import { eq, desc } from "drizzle-orm";
+
+// Initialize MinIO client
+// Note: In production, ensure these ENV vars are set.
+// For development with docker-compose, we use localhost:9000
+const minioClient = new Minio.Client({
+ endPoint: (env.MINIO_ENDPOINT ?? "localhost").split(":")[0] ?? "localhost",
+ port: parseInt((env.MINIO_ENDPOINT ?? "9000").split(":")[1] ?? "9000"),
+ useSSL: false, // Default to false for local dev; adjust for prod
+ accessKey: env.MINIO_ACCESS_KEY ?? "minioadmin",
+ secretKey: env.MINIO_SECRET_KEY ?? "minioadmin",
+});
+
+const BUCKET_NAME = env.MINIO_BUCKET_NAME ?? "hristudio-assets";
+
+// Ensure bucket exists on startup (best effort)
+const ensureBucket = async () => {
+ try {
+ const exists = await minioClient.bucketExists(BUCKET_NAME);
+ if (!exists) {
+ await minioClient.makeBucket(BUCKET_NAME, env.MINIO_REGION ?? "us-east-1");
+ // Set public policy if needed? For now, keep private and use presigned URLs.
+ }
+ } catch (e) {
+ console.error("Error ensuring MinIO bucket exists:", e);
+ }
+}
+void ensureBucket(); // Fire and forget on load
+
+export const filesRouter = createTRPCRouter({
+ // Get a presigned URL for uploading a file
+ getPresignedUrl: protectedProcedure
+ .input(z.object({
+ filename: z.string(),
+ contentType: z.string(),
+ participantId: z.string(),
+ }))
+ .mutation(async ({ input }) => {
+ const fileExtension = input.filename.split(".").pop();
+ const uniqueFilename = `${input.participantId}/${crypto.randomUUID()}.${fileExtension}`;
+
+ try {
+ const presignedUrl = await minioClient.presignedPutObject(
+ BUCKET_NAME,
+ uniqueFilename,
+ 60 * 5 // 5 minutes expiry
+ );
+
+ return {
+ url: presignedUrl,
+ storagePath: uniqueFilename, // Pass this back to client to save in DB after upload
+ };
+ } catch (error) {
+ console.error("Error generating presigned URL:", error);
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to generate upload URL",
+ });
+ }
+ }),
+
+ // Get a presigned URL for downloading/viewing a file
+ getDownloadUrl: protectedProcedure
+ .input(z.object({
+ storagePath: z.string(),
+ }))
+ .query(async ({ input }) => {
+ try {
+ const url = await minioClient.presignedGetObject(
+ BUCKET_NAME,
+ input.storagePath,
+ 60 * 60 // 1 hour
+ );
+ return { url };
+ } catch (error) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "File not found or storage error",
+ });
+ }
+ }),
+
+ // Record a successful upload in the database
+ registerUpload: protectedProcedure
+ .input(z.object({
+ participantId: z.string(),
+ name: z.string(),
+ type: z.string().optional(),
+ storagePath: z.string(),
+ fileSize: z.number().optional(),
+ }))
+ .mutation(async ({ ctx, input }) => {
+ await ctx.db.insert(participantDocuments).values({
+ participantId: input.participantId,
+ name: input.name,
+ type: input.type,
+ storagePath: input.storagePath,
+ fileSize: input.fileSize,
+ uploadedBy: ctx.session.user.id,
+ });
+ }),
+
+ // List documents for a participant
+ listParticipantDocuments: protectedProcedure
+ .input(z.object({ participantId: z.string() }))
+ .query(async ({ ctx, input }) => {
+ return await ctx.db.query.participantDocuments.findMany({
+ where: eq(participantDocuments.participantId, input.participantId),
+ orderBy: [desc(participantDocuments.createdAt)],
+ with: {
+ // Optional: join with uploader info if needed
+ }
+ });
+ }),
+
+ // Delete a document
+ deleteDocument: protectedProcedure
+ .input(z.object({ id: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ const doc = await ctx.db.query.participantDocuments.findFirst({
+ where: eq(participantDocuments.id, input.id),
+ });
+
+ if (!doc) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Document not found" });
+ }
+
+ // Delete from database
+ await ctx.db.delete(participantDocuments).where(eq(participantDocuments.id, input.id));
+
+ // Delete from MinIO (fire and forget or await)
+ try {
+ await minioClient.removeObject(BUCKET_NAME, doc.storagePath);
+ } catch (e) {
+ console.error("Failed to delete object from S3:", e);
+ // We still consider the operation successful for the user as the DB record is gone.
+ }
+ }),
+});
diff --git a/src/server/api/routers/trials.ts b/src/server/api/routers/trials.ts
index 4043853..59c5a90 100755
--- a/src/server/api/routers/trials.ts
+++ b/src/server/api/routers/trials.ts
@@ -24,6 +24,7 @@ import {
wizardInterventions,
mediaCaptures,
users,
+ annotations,
} from "~/server/db/schema";
import {
TrialExecutionEngine,
@@ -263,7 +264,22 @@ export const trialsRouter = createTRPCRouter({
});
}
- return trial[0];
+ // Fetch additional stats
+ const eventCount = await db
+ .select({ count: count() })
+ .from(trialEvents)
+ .where(eq(trialEvents.trialId, input.id));
+
+ const mediaCount = await db
+ .select({ count: count() })
+ .from(mediaCaptures)
+ .where(eq(mediaCaptures.trialId, input.id));
+
+ return {
+ ...trial[0],
+ eventCount: eventCount[0]?.count ?? 0,
+ mediaCount: mediaCount[0]?.count ?? 0,
+ };
}),
create: protectedProcedure
@@ -384,6 +400,58 @@ export const trialsRouter = createTRPCRouter({
return trial;
}),
+ duplicate: protectedProcedure
+ .input(
+ z.object({
+ id: z.string(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const { db } = ctx;
+ const userId = ctx.session.user.id;
+
+ await checkTrialAccess(db, userId, input.id, [
+ "owner",
+ "researcher",
+ "wizard",
+ ]);
+
+ // Get source trial
+ const sourceTrial = await db
+ .select()
+ .from(trials)
+ .where(eq(trials.id, input.id))
+ .limit(1);
+
+ if (!sourceTrial[0]) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Source trial not found",
+ });
+ }
+
+ // Create new trial based on source
+ const [newTrial] = await db
+ .insert(trials)
+ .values({
+ experimentId: sourceTrial[0].experimentId,
+ participantId: sourceTrial[0].participantId,
+ // Scheduled for now + 1 hour by default, or null? Let's use null or source time?
+ // New duplicate usually implies "planning to run soon".
+ // I'll leave scheduledAt null or same as source if future?
+ // Let's set it to tomorrow by default to avoid confusion
+ scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
+ wizardId: sourceTrial[0].wizardId,
+ sessionNumber: (sourceTrial[0].sessionNumber || 0) + 1, // Increment session
+ status: "scheduled",
+ notes: `Duplicate of trial ${sourceTrial[0].id}. ${sourceTrial[0].notes || ""}`,
+ metadata: sourceTrial[0].metadata,
+ })
+ .returning();
+
+ return newTrial;
+ }),
+
start: protectedProcedure
.input(
z.object({
@@ -414,10 +482,15 @@ export const trialsRouter = createTRPCRouter({
});
}
+ // Idempotency: If already in progress, return success
+ if (currentTrial[0].status === "in_progress") {
+ return currentTrial[0];
+ }
+
if (currentTrial[0].status !== "scheduled") {
throw new TRPCError({
code: "BAD_REQUEST",
- message: "Trial can only be started from scheduled status",
+ message: `Trial is in ${currentTrial[0].status} status and cannot be started`,
});
}
@@ -599,6 +672,61 @@ export const trialsRouter = createTRPCRouter({
return intervention;
}),
+ addAnnotation: protectedProcedure
+ .input(
+ z.object({
+ trialId: z.string(),
+ category: z.string().optional(),
+ label: z.string().optional(),
+ description: z.string().optional(),
+ timestampStart: z.date().optional(),
+ tags: z.array(z.string()).optional(),
+ metadata: z.any().optional(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const { db } = ctx;
+ const userId = ctx.session.user.id;
+
+ await checkTrialAccess(db, userId, input.trialId, [
+ "owner",
+ "researcher",
+ "wizard",
+ ]);
+
+ const [annotation] = await db
+ .insert(annotations)
+ .values({
+ trialId: input.trialId,
+ annotatorId: userId,
+ category: input.category,
+ label: input.label,
+ description: input.description,
+ timestampStart: input.timestampStart ?? new Date(),
+ tags: input.tags,
+ metadata: input.metadata,
+ })
+ .returning();
+
+ // Also create a trial event so it appears in the timeline
+ if (annotation) {
+ await db.insert(trialEvents).values({
+ trialId: input.trialId,
+ eventType: `annotation_${input.category || 'note'}`,
+ timestamp: input.timestampStart ?? new Date(),
+ data: {
+ annotationId: annotation.id,
+ description: input.description,
+ category: input.category,
+ label: input.label,
+ tags: input.tags,
+ },
+ });
+ }
+
+ return annotation;
+ }),
+
getEvents: protectedProcedure
.input(
z.object({
@@ -725,51 +853,51 @@ export const trialsRouter = createTRPCRouter({
const filteredTrials =
trialIds.length > 0
? await ctx.db.query.trials.findMany({
- where: inArray(trials.id, trialIds),
- with: {
- experiment: {
- with: {
- study: {
- columns: {
- id: true,
- name: true,
- },
+ where: inArray(trials.id, trialIds),
+ with: {
+ experiment: {
+ with: {
+ study: {
+ columns: {
+ id: true,
+ name: true,
},
},
- columns: {
- id: true,
- name: true,
- studyId: true,
- },
},
- participant: {
- columns: {
- id: true,
- participantCode: true,
- email: true,
- name: true,
- },
- },
- wizard: {
- columns: {
- id: true,
- name: true,
- email: true,
- },
- },
- events: {
- columns: {
- id: true,
- },
- },
- mediaCaptures: {
- columns: {
- id: true,
- },
+ columns: {
+ id: true,
+ name: true,
+ studyId: true,
},
},
- orderBy: [desc(trials.scheduledAt)],
- })
+ participant: {
+ columns: {
+ id: true,
+ participantCode: true,
+ email: true,
+ name: true,
+ },
+ },
+ wizard: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ },
+ },
+ events: {
+ columns: {
+ id: true,
+ },
+ },
+ mediaCaptures: {
+ columns: {
+ id: true,
+ },
+ },
+ },
+ orderBy: [desc(trials.scheduledAt)],
+ })
: [];
// Get total count
@@ -967,4 +1095,46 @@ export const trialsRouter = createTRPCRouter({
duration: result.duration,
};
}),
+
+ logRobotAction: protectedProcedure
+ .input(
+ z.object({
+ trialId: z.string(),
+ pluginName: z.string(),
+ actionId: z.string(),
+ parameters: z.record(z.string(), z.unknown()).optional().default({}),
+ duration: z.number().optional(),
+ result: z.any().optional(),
+ error: z.string().optional(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const { db } = ctx;
+ const userId = ctx.session.user.id;
+
+ await checkTrialAccess(db, userId, input.trialId, [
+ "owner",
+ "researcher",
+ "wizard",
+ ]);
+
+ await db.insert(trialEvents).values({
+ trialId: input.trialId,
+ eventType: "manual_robot_action",
+ data: {
+ userId,
+ pluginName: input.pluginName,
+ actionId: input.actionId,
+ parameters: input.parameters,
+ result: input.result,
+ duration: input.duration,
+ error: input.error,
+ executionMode: "websocket_client",
+ },
+ timestamp: new Date(),
+ createdBy: userId,
+ });
+
+ return { success: true };
+ }),
});
diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts
index 47c9fef..0d0f035 100755
--- a/src/server/db/schema.ts
+++ b/src/server/db/schema.ts
@@ -438,6 +438,29 @@ export const participants = createTable(
}),
);
+export const participantDocuments = createTable(
+ "participant_document",
+ {
+ id: uuid("id").notNull().primaryKey().defaultRandom(),
+ participantId: uuid("participant_id")
+ .notNull()
+ .references(() => participants.id, { onDelete: "cascade" }),
+ name: varchar("name", { length: 255 }).notNull(),
+ type: varchar("type", { length: 100 }), // MIME type or custom category
+ storagePath: text("storage_path").notNull(),
+ fileSize: integer("file_size"),
+ uploadedBy: uuid("uploaded_by").references(() => users.id),
+ createdAt: timestamp("created_at", { withTimezone: true })
+ .default(sql`CURRENT_TIMESTAMP`)
+ .notNull(),
+ },
+ (table) => ({
+ participantDocIdx: index("participant_document_participant_idx").on(
+ table.participantId,
+ ),
+ }),
+);
+
export const trials = createTable("trial", {
id: uuid("id").notNull().primaryKey().defaultRandom(),
experimentId: uuid("experiment_id")
diff --git a/src/server/services/trial-execution.ts b/src/server/services/trial-execution.ts
index 2a3d606..adf007b 100755
--- a/src/server/services/trial-execution.ts
+++ b/src/server/services/trial-execution.ts
@@ -507,6 +507,8 @@ export class TrialExecutionEngine {
// Parse plugin.action format
const [pluginName, actionId] = action.type.split(".");
+ console.log(`[TrialExecution] Parsed action: pluginName=${pluginName}, actionId=${actionId}`);
+
if (!pluginName || !actionId) {
throw new Error(
`Invalid robot action format: ${action.type}. Expected format: plugin.action`,
@@ -516,9 +518,12 @@ export class TrialExecutionEngine {
// Get plugin configuration from database
const plugin = await this.getPluginDefinition(pluginName);
if (!plugin) {
- throw new Error(`Plugin not found: ${pluginName}`);
+ throw new Error(`Plugin '${pluginName}' not found`);
}
+ console.log(`[TrialExecution] Plugin loaded: ${plugin.name} (ID: ${plugin.id})`);
+ console.log(`[TrialExecution] Available actions: ${plugin.actions?.map((a: any) => a.id).join(", ")}`);
+
// Find action definition in plugin
const actionDefinition = plugin.actions?.find(
(a: any) => a.id === actionId,
@@ -582,14 +587,27 @@ export class TrialExecutionEngine {
}
try {
+ // Check if pluginName is a UUID
+ const isUuid =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
+ pluginName,
+ );
+
+ const query = isUuid
+ ? eq(plugins.id, pluginName)
+ : eq(plugins.name, pluginName);
+
const [plugin] = await this.db
.select()
.from(plugins)
- .where(eq(plugins.name, pluginName))
+ .where(query)
.limit(1);
if (plugin) {
// Cache the plugin definition
+ // Use the actual name for cache key if we looked up by ID
+ const cacheKey = isUuid ? plugin.name : pluginName;
+
const pluginData = {
...plugin,
actions: plugin.actionDefinitions,
@@ -597,7 +615,12 @@ export class TrialExecutionEngine {
ros2Config: (plugin.metadata as any)?.ros2Config,
};
- this.pluginCache.set(pluginName, pluginData);
+ this.pluginCache.set(cacheKey, pluginData);
+ // Also cache by ID if accessible
+ if (plugin.id) {
+ this.pluginCache.set(plugin.id, pluginData);
+ }
+
return pluginData;
}