mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 22:54:45 -05:00
feat: Introduce dedicated participant, experiment, and trial detail/edit pages, enable MinIO, and refactor dashboard navigation.
This commit is contained in:
81
bun.lock
81
bun.lock
@@ -43,7 +43,8 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.41.0",
|
"drizzle-orm": "^0.41.0",
|
||||||
"lucide-react": "^0.536.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",
|
"next-auth": "^5.0.0-beta.29",
|
||||||
"postgres": "^3.4.4",
|
"postgres": "^3.4.4",
|
||||||
"react": "^19.0.0",
|
"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=="],
|
"@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/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=="],
|
"@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=="],
|
"@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": ["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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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": ["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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||||
|
|
||||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
"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=="],
|
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
|
||||||
|
|
||||||
"minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||||
|
|
||||||
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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.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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|
||||||
"uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||||
|
|
||||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
"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-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=="],
|
"@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=="],
|
"@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-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-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=="],
|
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
|
||||||
|
|||||||
@@ -17,18 +17,18 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
# minio:
|
minio:
|
||||||
# image: minio/minio
|
image: minio/minio
|
||||||
# ports:
|
ports:
|
||||||
# - "9000:9000" # API
|
- "9000:9000" # API
|
||||||
# - "9001:9001" # Console
|
- "9001:9001" # Console
|
||||||
# environment:
|
environment:
|
||||||
# MINIO_ROOT_USER: minioadmin
|
MINIO_ROOT_USER: minioadmin
|
||||||
# MINIO_ROOT_PASSWORD: minioadmin
|
MINIO_ROOT_PASSWORD: minioadmin
|
||||||
# volumes:
|
volumes:
|
||||||
# - minio_data:/data
|
- minio_data:/data
|
||||||
# command: server --console-address ":9001" /data
|
command: server --console-address ":9001" /data
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
# minio_data:
|
minio_data:
|
||||||
|
|||||||
@@ -62,7 +62,8 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.41.0",
|
"drizzle-orm": "^0.41.0",
|
||||||
"lucide-react": "^0.536.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",
|
"next-auth": "^5.0.0-beta.29",
|
||||||
"postgres": "^3.4.4",
|
"postgres": "^3.4.4",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
927
plugin_dump.json
Normal file
927
plugin_dump.json
Normal file
@@ -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)
|
||||||
|
|
||||||
@@ -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 (
|
|
||||||
<div className="flex min-h-[60vh] items-center justify-center p-4">
|
|
||||||
<Card className="w-full max-w-md">
|
|
||||||
<CardHeader className="text-center">
|
|
||||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-blue-50">
|
|
||||||
<AlertCircle className="h-8 w-8 text-blue-500" />
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-2xl">Analytics Moved</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Analytics are now organized by study for better data insights.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="text-muted-foreground space-y-2 text-center text-sm">
|
|
||||||
<p>To view analytics, please:</p>
|
|
||||||
<ul className="space-y-1 text-left">
|
|
||||||
<li>• Select a study from your studies list</li>
|
|
||||||
<li>• Navigate to that study's analytics page</li>
|
|
||||||
<li>• Get study-specific insights and data</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 pt-4">
|
|
||||||
<Button asChild className="w-full">
|
|
||||||
<Link href="/studies">
|
|
||||||
<ArrowRight className="mr-2 h-4 w-4" />
|
|
||||||
Browse Studies
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline" className="w-full">
|
|
||||||
<Link href="/dashboard">Go to Dashboard</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 <ExperimentForm mode="edit" experimentId={id} />;
|
|
||||||
}
|
|
||||||
@@ -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<Experiment | null>(null);
|
|
||||||
const [trials, setTrials] = useState<Trial[]>([]);
|
|
||||||
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 <div>Loading...</div>;
|
|
||||||
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 (
|
|
||||||
<EntityView>
|
|
||||||
<EntityViewHeader
|
|
||||||
title={displayName}
|
|
||||||
subtitle={description ?? undefined}
|
|
||||||
icon="TestTube"
|
|
||||||
status={{
|
|
||||||
label: statusInfo?.label ?? "Unknown",
|
|
||||||
variant: statusInfo?.variant ?? "secondary",
|
|
||||||
icon: statusInfo?.icon ?? "TestTube",
|
|
||||||
}}
|
|
||||||
actions={
|
|
||||||
canEdit ? (
|
|
||||||
<>
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href={`/experiments/${experiment.id}/edit`}>
|
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
|
||||||
Edit
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href={`/experiments/${experiment.id}/designer`}>
|
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
|
||||||
Designer
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild>
|
|
||||||
<Link
|
|
||||||
href={`/studies/${experiment.study.id}/trials/new?experimentId=${experiment.id}`}
|
|
||||||
>
|
|
||||||
<Play className="mr-2 h-4 w-4" />
|
|
||||||
Start Trial
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
|
||||||
<div className="space-y-6 lg:col-span-2">
|
|
||||||
{/* Basic Information */}
|
|
||||||
<EntityViewSection title="Information" icon="Info">
|
|
||||||
<InfoGrid
|
|
||||||
columns={2}
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
label: "Study",
|
|
||||||
value: experiment.study ? (
|
|
||||||
<Link
|
|
||||||
href={`/studies/${experiment.study.id}`}
|
|
||||||
className="text-primary hover:underline"
|
|
||||||
>
|
|
||||||
{experiment.study.name}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
"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,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</EntityViewSection>
|
|
||||||
|
|
||||||
{/* Protocol Section */}
|
|
||||||
<EntityViewSection
|
|
||||||
title="Experiment Protocol"
|
|
||||||
icon="FileText"
|
|
||||||
actions={
|
|
||||||
canEdit && (
|
|
||||||
<Button asChild variant="outline" size="sm">
|
|
||||||
<Link href={`/experiments/${experiment.id}/designer`}>
|
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
|
||||||
Edit Protocol
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{experiment.protocol &&
|
|
||||||
typeof experiment.protocol === "object" &&
|
|
||||||
experiment.protocol !== null ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="text-muted-foreground text-sm">
|
|
||||||
Protocol contains{" "}
|
|
||||||
{Array.isArray(
|
|
||||||
(experiment.protocol as { blocks: unknown[] }).blocks,
|
|
||||||
)
|
|
||||||
? (experiment.protocol as { blocks: unknown[] }).blocks
|
|
||||||
.length
|
|
||||||
: 0}{" "}
|
|
||||||
blocks
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<EmptyState
|
|
||||||
icon="FileText"
|
|
||||||
title="No protocol defined"
|
|
||||||
description="Create an experiment protocol using the visual designer"
|
|
||||||
action={
|
|
||||||
canEdit && (
|
|
||||||
<Button asChild>
|
|
||||||
<Link href={`/experiments/${experiment.id}/designer`}>
|
|
||||||
Open Designer
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</EntityViewSection>
|
|
||||||
|
|
||||||
{/* Recent Trials */}
|
|
||||||
<EntityViewSection
|
|
||||||
title="Recent Trials"
|
|
||||||
icon="Play"
|
|
||||||
actions={
|
|
||||||
<Button asChild variant="outline" size="sm">
|
|
||||||
<Link href={`/studies/${experiment.study?.id}/trials`}>
|
|
||||||
View All
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{trials.length > 0 ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{trials.slice(0, 5).map((trial) => (
|
|
||||||
<div
|
|
||||||
key={trial.id}
|
|
||||||
className="hover:bg-muted/50 rounded-lg border p-4 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<Link
|
|
||||||
href={`/studies/${experiment.study.id}/trials/${trial.id}`}
|
|
||||||
className="font-medium hover:underline"
|
|
||||||
>
|
|
||||||
Trial #{trial.id.slice(-6)}
|
|
||||||
</Link>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
trial.status === "completed"
|
|
||||||
? "default"
|
|
||||||
: trial.status === "in_progress"
|
|
||||||
? "secondary"
|
|
||||||
: trial.status === "failed"
|
|
||||||
? "destructive"
|
|
||||||
: "outline"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{trial.status.charAt(0).toUpperCase() +
|
|
||||||
trial.status.slice(1).replace("_", " ")}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground flex items-center gap-4 text-sm">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Calendar className="h-4 w-4" />
|
|
||||||
{formatDistanceToNow(trial.createdAt, {
|
|
||||||
addSuffix: true,
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
{trial.duration && (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Clock className="h-4 w-4" />
|
|
||||||
{Math.round(trial.duration / 60)} min
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{trial.participant && (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Users className="h-4 w-4" />
|
|
||||||
{trial.participant.name ??
|
|
||||||
trial.participant.participantCode}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<EmptyState
|
|
||||||
icon="Play"
|
|
||||||
title="No trials yet"
|
|
||||||
description="Start your first trial to collect data"
|
|
||||||
action={
|
|
||||||
canEdit && (
|
|
||||||
<Button asChild>
|
|
||||||
<Link
|
|
||||||
href={`/studies/${experiment.study.id}/trials/new?experimentId=${experiment.id}`}
|
|
||||||
>
|
|
||||||
Start Trial
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</EntityViewSection>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Statistics */}
|
|
||||||
<EntityViewSection title="Statistics" icon="BarChart">
|
|
||||||
<StatsGrid
|
|
||||||
stats={[
|
|
||||||
{
|
|
||||||
label: "Total Trials",
|
|
||||||
value: trials.length,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Completed",
|
|
||||||
value: trials.filter((t) => t.status === "completed").length,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "In Progress",
|
|
||||||
value: trials.filter((t) => t.status === "in_progress")
|
|
||||||
.length,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</EntityViewSection>
|
|
||||||
|
|
||||||
{/* Robot Information */}
|
|
||||||
{experiment.robot && (
|
|
||||||
<EntityViewSection title="Robot Platform" icon="Bot">
|
|
||||||
<InfoGrid
|
|
||||||
columns={1}
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
label: "Platform",
|
|
||||||
value: experiment.robot.name,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Type",
|
|
||||||
value: experiment.robot.description ?? "Not specified",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</EntityViewSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Quick Actions */}
|
|
||||||
<EntityViewSection title="Quick Actions" icon="Zap">
|
|
||||||
<QuickActions
|
|
||||||
actions={[
|
|
||||||
{
|
|
||||||
label: "Export Data",
|
|
||||||
icon: "Download" as const,
|
|
||||||
href: `/experiments/${experiment.id}/export`,
|
|
||||||
},
|
|
||||||
...(canEdit
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
label: "Edit Experiment",
|
|
||||||
icon: "Edit" as const,
|
|
||||||
href: `/experiments/${experiment.id}/edit`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Open Designer",
|
|
||||||
icon: "Palette" as const,
|
|
||||||
href: `/experiments/${experiment.id}/designer`,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</EntityViewSection>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</EntityView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<div className="flex min-h-[60vh] items-center justify-center p-4">
|
|
||||||
<Card className="w-full max-w-md">
|
|
||||||
<CardHeader className="text-center">
|
|
||||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-blue-50">
|
|
||||||
<FlaskConical className="h-8 w-8 text-blue-500" />
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-2xl">Experiments Moved</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Experiment management is now organized by study for better
|
|
||||||
workflow organization.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="text-muted-foreground space-y-2 text-center text-sm">
|
|
||||||
<p>To manage experiments:</p>
|
|
||||||
<ul className="space-y-1 text-left">
|
|
||||||
<li>• Select a study from your studies list</li>
|
|
||||||
<li>• Navigate to that study's experiments page</li>
|
|
||||||
<li>• Create and manage experiment protocols for that specific study</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 pt-4">
|
|
||||||
<Button asChild className="w-full">
|
|
||||||
<Link href="/studies">
|
|
||||||
<ArrowRight className="mr-2 h-4 w-4" />
|
|
||||||
Browse Studies
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline" className="w-full">
|
|
||||||
<Link href="/dashboard">Go to Dashboard</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<div className="flex min-h-[60vh] items-center justify-center p-4">
|
|
||||||
<Card className="w-full max-w-md">
|
|
||||||
<CardHeader className="text-center">
|
|
||||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-50">
|
|
||||||
<Users className="h-8 w-8 text-green-500" />
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-2xl">Participants Moved</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Participant management is now organized by study for better
|
|
||||||
organization.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="text-muted-foreground space-y-2 text-center text-sm">
|
|
||||||
<p>To manage participants:</p>
|
|
||||||
<ul className="space-y-1 text-left">
|
|
||||||
<li>• Select a study from your studies list</li>
|
|
||||||
<li>• Navigate to that study's participants page</li>
|
|
||||||
<li>• Add and manage participants for that specific study</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 pt-4">
|
|
||||||
<Button asChild className="w-full">
|
|
||||||
<Link href="/studies">
|
|
||||||
<ArrowRight className="mr-2 h-4 w-4" />
|
|
||||||
Browse Studies
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline" className="w-full">
|
|
||||||
<Link href="/dashboard">Go to Dashboard</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<div className="flex min-h-[60vh] items-center justify-center p-4">
|
|
||||||
<Card className="w-full max-w-md">
|
|
||||||
<CardHeader className="text-center">
|
|
||||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-purple-50">
|
|
||||||
<Store className="h-8 w-8 text-purple-500" />
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-2xl">Plugin Store Moved</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Plugin browsing is now organized by study for better robot
|
|
||||||
capability management.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="text-muted-foreground space-y-2 text-center text-sm">
|
|
||||||
<p>To browse and install plugins:</p>
|
|
||||||
<ul className="space-y-1 text-left">
|
|
||||||
<li>• Select a study from your studies list</li>
|
|
||||||
<li>• Navigate to that study's plugin store</li>
|
|
||||||
<li>
|
|
||||||
• Browse and install robot capabilities for that specific study
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 pt-4">
|
|
||||||
<Button asChild className="w-full">
|
|
||||||
<Link href="/studies">
|
|
||||||
<ArrowRight className="mr-2 h-4 w-4" />
|
|
||||||
Browse Studies
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline" className="w-full">
|
|
||||||
<Link href="/dashboard">Go to Dashboard</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<div className="flex min-h-[60vh] items-center justify-center p-4">
|
|
||||||
<Card className="w-full max-w-md">
|
|
||||||
<CardHeader className="text-center">
|
|
||||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-purple-50">
|
|
||||||
<Puzzle className="h-8 w-8 text-purple-500" />
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-2xl">Plugins Moved</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Plugin management is now organized by study for better robot
|
|
||||||
capability management.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="text-muted-foreground space-y-2 text-center text-sm">
|
|
||||||
<p>To manage plugins:</p>
|
|
||||||
<ul className="space-y-1 text-left">
|
|
||||||
<li>• Select a study from your studies list</li>
|
|
||||||
<li>• Navigate to that study's plugins page</li>
|
|
||||||
<li>
|
|
||||||
• Install and configure robot capabilities for that specific
|
|
||||||
study
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 pt-4">
|
|
||||||
<Button asChild className="w-full">
|
|
||||||
<Link href="/studies">
|
|
||||||
<ArrowRight className="mr-2 h-4 w-4" />
|
|
||||||
Browse Studies
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline" className="w-full">
|
|
||||||
<Link href="/dashboard">Go to Dashboard</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: experiment.name,
|
||||||
|
description: experiment.description ?? "",
|
||||||
|
status: experiment.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
|
updateExperiment.mutate({
|
||||||
|
id: experiment.id,
|
||||||
|
name: values.name,
|
||||||
|
description: values.description,
|
||||||
|
status: values.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Experiment name" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
The name of your experiment.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Describe your experiment..."
|
||||||
|
className="resize-none"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
A short description of the experiment goals.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="status"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Status</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="draft">Draft</SelectItem>
|
||||||
|
<SelectItem value="ready">Ready</SelectItem>
|
||||||
|
<SelectItem value="data_collection">Data Collection</SelectItem>
|
||||||
|
<SelectItem value="analysis">Analysis</SelectItem>
|
||||||
|
<SelectItem value="completed">Completed</SelectItem>
|
||||||
|
<SelectItem value="archived">Archived</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
The current status of the experiment.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button type="submit" disabled={updateExperiment.isPending}>
|
||||||
|
{updateExperiment.isPending ? "Saving..." : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
router.push(
|
||||||
|
`/studies/${experiment.studyId}/experiments/${experiment.id}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<EntityView>
|
||||||
|
<EntityViewHeader
|
||||||
|
title="Edit Experiment"
|
||||||
|
subtitle={`Update settings for ${experiment.name}`}
|
||||||
|
icon="Edit"
|
||||||
|
backButton={
|
||||||
|
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-2">
|
||||||
|
<Link href={`/studies/${studyId}/experiments/${experimentId}`}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Experiment
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<EntityViewSection title="Experiment Details" icon="Settings">
|
||||||
|
<ExperimentForm experiment={experimentData} />
|
||||||
|
</EntityViewSection>
|
||||||
|
</div>
|
||||||
|
</EntityView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Experiment | null>(null);
|
||||||
|
const [trials, setTrials] = useState<Trial[]>([]);
|
||||||
|
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 <div>Loading...</div>;
|
||||||
|
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 (
|
||||||
|
<EntityView>
|
||||||
|
<EntityViewHeader
|
||||||
|
title={displayName}
|
||||||
|
subtitle={description ?? undefined}
|
||||||
|
icon="TestTube"
|
||||||
|
status={{
|
||||||
|
label: statusInfo?.label ?? "Unknown",
|
||||||
|
variant: statusInfo?.variant ?? "secondary",
|
||||||
|
icon: statusInfo?.icon ?? "TestTube",
|
||||||
|
}}
|
||||||
|
actions={
|
||||||
|
canEdit ? (
|
||||||
|
<>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={`/studies/${studyId}/experiments/${experimentId}/edit`}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}>
|
||||||
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
|
Designer
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link
|
||||||
|
href={`/studies/${studyId}/trials/new?experimentId=${experimentId}`}
|
||||||
|
>
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
Start Trial
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
<div className="space-y-6 lg:col-span-2">
|
||||||
|
{/* Basic Information */}
|
||||||
|
<EntityViewSection title="Information" icon="Info">
|
||||||
|
<InfoGrid
|
||||||
|
columns={2}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: "Study",
|
||||||
|
value: experiment.study ? (
|
||||||
|
<Link
|
||||||
|
href={`/studies/${experiment.study.id}`}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{experiment.study.name}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
"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,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</EntityViewSection>
|
||||||
|
|
||||||
|
{/* Protocol Section */}
|
||||||
|
<EntityViewSection
|
||||||
|
title="Experiment Protocol"
|
||||||
|
icon="FileText"
|
||||||
|
actions={
|
||||||
|
canEdit && (
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
Edit Protocol
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{experiment.protocol &&
|
||||||
|
typeof experiment.protocol === "object" &&
|
||||||
|
experiment.protocol !== null ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
Protocol contains{" "}
|
||||||
|
{Array.isArray(
|
||||||
|
(experiment.protocol as { blocks: unknown[] }).blocks,
|
||||||
|
)
|
||||||
|
? (experiment.protocol as { blocks: unknown[] }).blocks
|
||||||
|
.length
|
||||||
|
: 0}{" "}
|
||||||
|
blocks
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon="FileText"
|
||||||
|
title="No protocol defined"
|
||||||
|
description="Create an experiment protocol using the visual designer"
|
||||||
|
action={
|
||||||
|
canEdit && (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}>
|
||||||
|
Open Designer
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</EntityViewSection>
|
||||||
|
|
||||||
|
{/* Recent Trials */}
|
||||||
|
<EntityViewSection
|
||||||
|
title="Recent Trials"
|
||||||
|
icon="Play"
|
||||||
|
actions={
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link href={`/studies/${experiment.study?.id}/trials`}>
|
||||||
|
View All
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{trials.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{trials.slice(0, 5).map((trial) => (
|
||||||
|
<div
|
||||||
|
key={trial.id}
|
||||||
|
className="hover:bg-muted/50 rounded-lg border p-4 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<Link
|
||||||
|
href={`/studies/${experiment.study.id}/trials/${trial.id}`}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
>
|
||||||
|
Trial #{trial.id.slice(-6)}
|
||||||
|
</Link>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
trial.status === "completed"
|
||||||
|
? "default"
|
||||||
|
: trial.status === "in_progress"
|
||||||
|
? "secondary"
|
||||||
|
: trial.status === "failed"
|
||||||
|
? "destructive"
|
||||||
|
: "outline"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{trial.status.charAt(0).toUpperCase() +
|
||||||
|
trial.status.slice(1).replace("_", " ")}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground flex items-center gap-4 text-sm">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
{formatDistanceToNow(trial.createdAt, {
|
||||||
|
addSuffix: true,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{trial.duration && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
{Math.round(trial.duration / 60)} min
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{trial.participant && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
{trial.participant.name ??
|
||||||
|
trial.participant.participantCode}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon="Play"
|
||||||
|
title="No trials yet"
|
||||||
|
description="Start your first trial to collect data"
|
||||||
|
action={
|
||||||
|
canEdit && (
|
||||||
|
<Button asChild>
|
||||||
|
<Link
|
||||||
|
href={`/studies/${studyId}/trials/new?experimentId=${experimentId}`}
|
||||||
|
>
|
||||||
|
Start Trial
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</EntityViewSection>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Statistics */}
|
||||||
|
<EntityViewSection title="Statistics" icon="BarChart">
|
||||||
|
<StatsGrid
|
||||||
|
stats={[
|
||||||
|
{
|
||||||
|
label: "Total Trials",
|
||||||
|
value: trials.length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Completed",
|
||||||
|
value: trials.filter((t) => t.status === "completed").length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "In Progress",
|
||||||
|
value: trials.filter((t) => t.status === "in_progress")
|
||||||
|
.length,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</EntityViewSection>
|
||||||
|
|
||||||
|
{/* Robot Information */}
|
||||||
|
{experiment.robot && (
|
||||||
|
<EntityViewSection title="Robot Platform" icon="Bot">
|
||||||
|
<InfoGrid
|
||||||
|
columns={1}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: "Platform",
|
||||||
|
value: experiment.robot.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Type",
|
||||||
|
value: experiment.robot.description ?? "Not specified",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</EntityViewSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<EntityViewSection title="Quick Actions" icon="Zap">
|
||||||
|
<QuickActions
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "Export Data",
|
||||||
|
icon: "Download" as const,
|
||||||
|
href: `/studies/${studyId}/experiments/${experimentId}/export`,
|
||||||
|
},
|
||||||
|
...(canEdit
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: "Edit Experiment",
|
||||||
|
icon: "Edit" as const,
|
||||||
|
href: `/studies/${studyId}/experiments/${experimentId}/edit`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Open Designer",
|
||||||
|
icon: "Palette" as const,
|
||||||
|
href: `/studies/${studyId}/experiments/${experimentId}/designer`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</EntityViewSection>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</EntityView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<ParticipantForm
|
||||||
|
mode="edit"
|
||||||
|
studyId={studyId}
|
||||||
|
participantId={participantId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<EntityView>
|
||||||
|
<EntityViewHeader
|
||||||
|
title={participant.participantCode}
|
||||||
|
subtitle={participant.name ?? "Unnamed Participant"}
|
||||||
|
icon="Users"
|
||||||
|
badge={
|
||||||
|
<Badge variant={participant.consentGiven ? "default" : "secondary"}>
|
||||||
|
{participant.consentGiven ? "Consent Given" : "No Consent"}
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link href={`/studies/${studyId}/participants/${participantId}/edit`}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
Edit Participant
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs defaultValue="overview" className="w-full">
|
||||||
|
<TabsList className="mb-4">
|
||||||
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||||
|
<TabsTrigger value="files">Files & Documents</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="overview">
|
||||||
|
<div className="grid gap-6 grid-cols-1">
|
||||||
|
<EntityViewSection title="Participant Information" icon="Info">
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm md:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground block mb-1">Code</span>
|
||||||
|
<span className="font-medium text-base">{participant.participantCode}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground block mb-1">Name</span>
|
||||||
|
<span className="font-medium text-base">{participant.name || "-"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground block mb-1">Email</span>
|
||||||
|
<span className="font-medium text-base">{participant.email || "-"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground block mb-1">Added</span>
|
||||||
|
<span className="font-medium text-base">{new Date(participant.createdAt).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground block mb-1">Age</span>
|
||||||
|
<span className="font-medium text-base">{(participant.demographics as any)?.age || "-"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground block mb-1">Gender</span>
|
||||||
|
<span className="font-medium capitalize text-base">{(participant.demographics as any)?.gender?.replace("_", " ") || "-"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</EntityViewSection>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="files">
|
||||||
|
<EntityViewSection title="Documents" icon="FileText">
|
||||||
|
<ParticipantDocuments participantId={participantId} />
|
||||||
|
</EntityViewSection>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</EntityView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle>Documents</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Manage consent forms and other files for this participant.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button disabled={isUploading} asChild>
|
||||||
|
<label className="cursor-pointer">
|
||||||
|
{isUploading ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Upload PDF
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
accept=".pdf,.doc,.docx,.txt" // User asked for PDF, but generic is fine
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
disabled={isUploading}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center p-4">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : documents?.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
|
||||||
|
<FileText className="mb-2 h-8 w-8 opacity-50" />
|
||||||
|
<p>No documents uploaded yet.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{documents?.map((doc) => (
|
||||||
|
<div
|
||||||
|
key={doc.id}
|
||||||
|
className="flex items-center justify-between rounded-lg border p-3 hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-md bg-blue-50 p-2">
|
||||||
|
<FileText className="h-4 w-4 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{doc.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatBytes(doc.fileSize ?? 0)} • {new Date(doc.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDownload(doc.storagePath, doc.name)}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm("Are you sure you want to delete this file?")) {
|
||||||
|
deleteDocument.mutate({ id: doc.id });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex h-96 items-center justify-center">
|
||||||
|
<div className="text-muted-foreground">Loading analysis...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !trial) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Trial Analysis"
|
||||||
|
description="Analyze trial results"
|
||||||
|
icon={LineChart}
|
||||||
|
actions={
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={`/studies/${studyId}/trials`}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Trials
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex h-96 items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-destructive mb-2 text-lg font-semibold">
|
||||||
|
{error ? "Error Loading Trial" : "Trial Not Found"}
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{error?.message || "The requested trial could not be found."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<PageHeader
|
||||||
|
title="Trial Analysis"
|
||||||
|
description={`Analysis for Session ${trial.sessionNumber} • Participant ${trial.participant.participantCode}`}
|
||||||
|
icon={LineChart}
|
||||||
|
actions={
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={`/studies/${studyId}/trials/${trialId}`}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Trial Details
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
<TrialAnalysisView trial={trialData} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TrialAnalysisPage() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex h-96 items-center justify-center">
|
||||||
|
<div className="text-muted-foreground">Loading...</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AnalysisPageContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -233,6 +233,22 @@ export function AppSidebar({
|
|||||||
// Show debug info in development
|
// Show debug info in development
|
||||||
const showDebug = process.env.NODE_ENV === "development";
|
const showDebug = process.env.NODE_ENV === "development";
|
||||||
|
|
||||||
|
const [mounted, setMounted] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<Sidebar collapsible="icon" variant="sidebar" {...props}>
|
||||||
|
<SidebarHeader />
|
||||||
|
<SidebarContent />
|
||||||
|
<SidebarFooter />
|
||||||
|
</Sidebar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon" variant="sidebar" {...props}>
|
<Sidebar collapsible="icon" variant="sidebar" {...props}>
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { type ColumnDef } from "@tanstack/react-table";
|
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 * as React from "react";
|
||||||
|
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
@@ -259,20 +259,26 @@ export const columns: ColumnDef<Experiment>[] = [
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigator.clipboard.writeText(experiment.id)}
|
onClick={() => navigator.clipboard.writeText(experiment.id)}
|
||||||
>
|
>
|
||||||
Copy experiment ID
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
|
Copy ID
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}>View details</Link>
|
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}>
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
Details
|
||||||
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
|
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
|
||||||
Edit experiment
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
|
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
|
||||||
Open designer
|
<LayoutTemplate className="mr-2 h-4 w-4" />
|
||||||
|
Designer
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
@@ -280,12 +286,14 @@ export const columns: ColumnDef<Experiment>[] = [
|
|||||||
<Link
|
<Link
|
||||||
href={`/studies/${experiment.studyId}/trials/new?experimentId=${experiment.id}`}
|
href={`/studies/${experiment.studyId}/trials/new?experimentId=${experiment.id}`}
|
||||||
>
|
>
|
||||||
Create trial
|
<PlayCircle className="mr-2 h-4 w-4" />
|
||||||
|
Start Trial
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem className="text-red-600">
|
<DropdownMenuItem className="text-red-600">
|
||||||
Archive experiment
|
<Archive className="mr-2 h-4 w-4" />
|
||||||
|
Archive
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -736,6 +736,16 @@ export function DesignerRoot({
|
|||||||
const targetStep = steps.find((s) => s.id === stepId);
|
const targetStep = steps.find((s) => s.id === stepId);
|
||||||
if (!targetStep) return;
|
if (!targetStep) return;
|
||||||
|
|
||||||
|
const fullDef = actionRegistry.getAction(actionDef.type);
|
||||||
|
const defaultParams: Record<string, unknown> = {};
|
||||||
|
if (fullDef?.parameters) {
|
||||||
|
for (const param of fullDef.parameters) {
|
||||||
|
if (param.default !== undefined) {
|
||||||
|
defaultParams[param.id] = param.default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const execution: ExperimentAction["execution"] =
|
const execution: ExperimentAction["execution"] =
|
||||||
actionDef.execution &&
|
actionDef.execution &&
|
||||||
(actionDef.execution.transport === "internal" ||
|
(actionDef.execution.transport === "internal" ||
|
||||||
@@ -754,7 +764,7 @@ export function DesignerRoot({
|
|||||||
type: actionDef.type,
|
type: actionDef.type,
|
||||||
name: actionDef.name,
|
name: actionDef.name,
|
||||||
category: actionDef.category as ExperimentAction["category"],
|
category: actionDef.category as ExperimentAction["category"],
|
||||||
parameters: {},
|
parameters: defaultParams,
|
||||||
source: actionDef.source as ExperimentAction["source"],
|
source: actionDef.source as ExperimentAction["source"],
|
||||||
execution,
|
execution,
|
||||||
};
|
};
|
||||||
@@ -818,6 +828,7 @@ export function DesignerRoot({
|
|||||||
description={designMeta.description || "No description"}
|
description={designMeta.description || "No description"}
|
||||||
icon={Play}
|
icon={Play}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
|
className="pb-6"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="relative flex flex-1 flex-col overflow-hidden">
|
<div className="relative flex flex-1 flex-col overflow-hidden">
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export interface PropertiesPanelProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PropertiesPanel({
|
export function PropertiesPanelBase({
|
||||||
design,
|
design,
|
||||||
selectedStep,
|
selectedStep,
|
||||||
selectedAction,
|
selectedAction,
|
||||||
@@ -633,3 +633,5 @@ export function PropertiesPanel({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const PropertiesPanel = React.memo(PropertiesPanelBase);
|
||||||
|
|||||||
@@ -284,14 +284,17 @@ export function InspectorPanel({
|
|||||||
<div className="flex-1 overflow-x-hidden overflow-y-auto">
|
<div className="flex-1 overflow-x-hidden overflow-y-auto">
|
||||||
<div className="w-full px-0 py-2 break-words whitespace-normal">
|
<div className="w-full px-0 py-2 break-words whitespace-normal">
|
||||||
<PropertiesPanel
|
<PropertiesPanel
|
||||||
design={{
|
design={useMemo(
|
||||||
|
() => ({
|
||||||
id: "design",
|
id: "design",
|
||||||
name: "Design",
|
name: "Design",
|
||||||
description: "",
|
description: "",
|
||||||
version: 1,
|
version: 1,
|
||||||
steps,
|
steps,
|
||||||
lastSaved: new Date(),
|
lastSaved: new Date(),
|
||||||
}}
|
}),
|
||||||
|
[steps],
|
||||||
|
)}
|
||||||
selectedStep={selectedStep}
|
selectedStep={selectedStep}
|
||||||
selectedAction={selectedAction}
|
selectedAction={selectedAction}
|
||||||
onActionUpdate={handleActionUpdate}
|
onActionUpdate={handleActionUpdate}
|
||||||
|
|||||||
@@ -645,7 +645,7 @@ export function validateExecution(
|
|||||||
issues.push({
|
issues.push({
|
||||||
severity: "warning",
|
severity: "warning",
|
||||||
message:
|
message:
|
||||||
"Multiple steps with trial_start trigger may cause execution conflicts",
|
"Multiple steps will start simultaneously. Ensure parallel execution is intended.",
|
||||||
category: "execution",
|
category: "execution",
|
||||||
field: "trigger.type",
|
field: "trigger.type",
|
||||||
stepId: step.id,
|
stepId: step.id,
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ export function ParticipantForm({
|
|||||||
email: data.email ?? undefined,
|
email: data.email ?? undefined,
|
||||||
demographics,
|
demographics,
|
||||||
});
|
});
|
||||||
router.push(`/participants/${newParticipant.id}`);
|
router.push(`/studies/${data.studyId}/participants/${newParticipant.id}`);
|
||||||
} else {
|
} else {
|
||||||
const updatedParticipant = await updateParticipantMutation.mutateAsync({
|
const updatedParticipant = await updateParticipantMutation.mutateAsync({
|
||||||
id: participantId!,
|
id: participantId!,
|
||||||
@@ -212,7 +212,7 @@ export function ParticipantForm({
|
|||||||
email: data.email ?? undefined,
|
email: data.email ?? undefined,
|
||||||
demographics,
|
demographics,
|
||||||
});
|
});
|
||||||
router.push(`/participants/${updatedParticipant.id}`);
|
router.push(`/studies/${contextStudyId}/participants/${updatedParticipant.id}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(
|
setError(
|
||||||
@@ -505,7 +505,8 @@ export function ParticipantForm({
|
|||||||
error={error}
|
error={error}
|
||||||
onDelete={mode === "edit" ? onDelete : undefined}
|
onDelete={mode === "edit" ? onDelete : undefined}
|
||||||
isDeleting={isDeleting}
|
isDeleting={isDeleting}
|
||||||
sidebar={sidebar}
|
isDeleting={isDeleting}
|
||||||
|
// sidebar={sidebar} // Removed for cleaner UI per user request
|
||||||
submitText={mode === "create" ? "Register Participant" : "Save Changes"}
|
submitText={mode === "create" ? "Register Participant" : "Save Changes"}
|
||||||
>
|
>
|
||||||
{formFields}
|
{formFields}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { type ColumnDef } from "@tanstack/react-table";
|
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 * as React from "react";
|
||||||
|
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
@@ -27,6 +27,7 @@ import { api } from "~/trpc/react";
|
|||||||
|
|
||||||
export type Participant = {
|
export type Participant = {
|
||||||
id: string;
|
id: string;
|
||||||
|
studyId: string;
|
||||||
participantCode: string;
|
participantCode: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
@@ -75,7 +76,7 @@ export const columns: ColumnDef<Participant>[] = [
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="font-mono text-sm">
|
<div className="font-mono text-sm">
|
||||||
<Link
|
<Link
|
||||||
href={`/participants/${row.original.id}`}
|
href={`/studies/${row.original.studyId ?? ""}/participants/${row.original.id}`}
|
||||||
className="hover:underline"
|
className="hover:underline"
|
||||||
>
|
>
|
||||||
{row.getValue("participantCode")}
|
{row.getValue("participantCode")}
|
||||||
@@ -176,6 +177,13 @@ export const columns: ColumnDef<Participant>[] = [
|
|||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const participant = row.original;
|
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 (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -190,26 +198,27 @@ export const columns: ColumnDef<Participant>[] = [
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigator.clipboard.writeText(participant.id)}
|
onClick={() => navigator.clipboard.writeText(participant.id)}
|
||||||
>
|
>
|
||||||
Copy participant ID
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
|
Copy ID
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href={`/participants/${participant.id}`}>View details</Link>
|
<Link href={`/studies/${studyId}/participants/${participant.id}/edit`}>
|
||||||
</DropdownMenuItem>
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link href={`/participants/${participant.id}/edit`}>
|
|
||||||
Edit participant
|
Edit participant
|
||||||
</Link>
|
</Link >
|
||||||
|
</DropdownMenuItem >
|
||||||
|
<DropdownMenuItem disabled>
|
||||||
|
<Mail className="mr-2 h-4 w-4" />
|
||||||
|
Send consent
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{!participant.consentGiven && (
|
|
||||||
<DropdownMenuItem>Send consent form</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem className="text-red-600">
|
<DropdownMenuItem className="text-red-600">
|
||||||
Remove participant
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Remove
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent >
|
||||||
</DropdownMenu>
|
</DropdownMenu >
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -250,6 +259,7 @@ export function ParticipantsTable({ studyId }: ParticipantsTableProps = {}) {
|
|||||||
return participantsData.participants.map(
|
return participantsData.participants.map(
|
||||||
(p): Participant => ({
|
(p): Participant => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
|
studyId: p.studyId,
|
||||||
participantCode: p.participantCode,
|
participantCode: p.participantCode,
|
||||||
email: p.email,
|
email: p.email,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
|||||||
sessionNumber: data.sessionNumber ?? 1,
|
sessionNumber: data.sessionNumber ?? 1,
|
||||||
notes: data.notes ?? undefined,
|
notes: data.notes ?? undefined,
|
||||||
});
|
});
|
||||||
router.push(`/trials/${newTrial!.id}`);
|
router.push(`/studies/${contextStudyId}/trials/${newTrial!.id}`);
|
||||||
} else {
|
} else {
|
||||||
const updatedTrial = await updateTrialMutation.mutateAsync({
|
const updatedTrial = await updateTrialMutation.mutateAsync({
|
||||||
id: trialId!,
|
id: trialId!,
|
||||||
@@ -170,7 +170,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
|||||||
sessionNumber: data.sessionNumber ?? 1,
|
sessionNumber: data.sessionNumber ?? 1,
|
||||||
notes: data.notes ?? undefined,
|
notes: data.notes ?? undefined,
|
||||||
});
|
});
|
||||||
router.push(`/trials/${updatedTrial!.id}`);
|
router.push(`/studies/${contextStudyId}/trials/${updatedTrial!.id}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(
|
setError(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { type ColumnDef } from "@tanstack/react-table";
|
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 * as React from "react";
|
||||||
|
|
||||||
import { format, formatDistanceToNow } from "date-fns";
|
import { format, formatDistanceToNow } from "date-fns";
|
||||||
@@ -51,27 +51,22 @@ const statusConfig = {
|
|||||||
scheduled: {
|
scheduled: {
|
||||||
label: "Scheduled",
|
label: "Scheduled",
|
||||||
className: "bg-blue-100 text-blue-800",
|
className: "bg-blue-100 text-blue-800",
|
||||||
icon: "📅",
|
|
||||||
},
|
},
|
||||||
in_progress: {
|
in_progress: {
|
||||||
label: "In Progress",
|
label: "In Progress",
|
||||||
className: "bg-yellow-100 text-yellow-800",
|
className: "bg-yellow-100 text-yellow-800",
|
||||||
icon: "▶️",
|
|
||||||
},
|
},
|
||||||
completed: {
|
completed: {
|
||||||
label: "Completed",
|
label: "Completed",
|
||||||
className: "bg-green-100 text-green-800",
|
className: "bg-green-100 text-green-800",
|
||||||
icon: "✅",
|
|
||||||
},
|
},
|
||||||
aborted: {
|
aborted: {
|
||||||
label: "Aborted",
|
label: "Aborted",
|
||||||
className: "bg-gray-100 text-gray-800",
|
className: "bg-gray-100 text-gray-800",
|
||||||
icon: "❌",
|
|
||||||
},
|
},
|
||||||
failed: {
|
failed: {
|
||||||
label: "Failed",
|
label: "Failed",
|
||||||
className: "bg-red-100 text-red-800",
|
className: "bg-red-100 text-red-800",
|
||||||
icon: "⚠️",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -145,7 +140,7 @@ export const columns: ColumnDef<Trial>[] = [
|
|||||||
<div className="max-w-[250px]">
|
<div className="max-w-[250px]">
|
||||||
<div className="truncate font-medium">
|
<div className="truncate font-medium">
|
||||||
<Link
|
<Link
|
||||||
href={`/experiments/${experimentId}`}
|
href={`/studies/${row.original.studyId}/experiments/${experimentId}`}
|
||||||
className="hover:underline"
|
className="hover:underline"
|
||||||
>
|
>
|
||||||
{String(experimentName)}
|
{String(experimentName)}
|
||||||
@@ -175,7 +170,7 @@ export const columns: ColumnDef<Trial>[] = [
|
|||||||
<div className="max-w-[150px]">
|
<div className="max-w-[150px]">
|
||||||
{participantId ? (
|
{participantId ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/participants/${participantId}`}
|
href={`/studies/${row.original.studyId}/participants/${participantId}`}
|
||||||
className="font-mono text-sm hover:underline"
|
className="font-mono text-sm hover:underline"
|
||||||
>
|
>
|
||||||
{(participantCode ?? "Unknown") as string}
|
{(participantCode ?? "Unknown") as string}
|
||||||
@@ -232,10 +227,10 @@ export const columns: ColumnDef<Trial>[] = [
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge className={statusInfo.className}>
|
<Badge className={statusInfo.className}>
|
||||||
<span className="mr-1">{statusInfo.icon}</span>
|
|
||||||
{statusInfo.label}
|
{statusInfo.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -366,13 +361,28 @@ export const columns: ColumnDef<Trial>[] = [
|
|||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => <ActionsCell row={row} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function ActionsCell({ row }: { row: { original: Trial } }) {
|
||||||
const trial = row.original;
|
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) {
|
if (!trial?.id) {
|
||||||
return (
|
return <span className="text-muted-foreground text-sm">No actions</span>;
|
||||||
<span className="text-muted-foreground text-sm">No actions</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -388,56 +398,56 @@ export const columns: ColumnDef<Trial>[] = [
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigator.clipboard.writeText(trial.id)}
|
onClick={() => navigator.clipboard.writeText(trial.id)}
|
||||||
>
|
>
|
||||||
Copy trial ID
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
|
Copy ID
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}`}>
|
<Link href={`/studies/${trial.studyId}/trials/${trial.id}`}>
|
||||||
View details
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
Details
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{trial.status === "scheduled" && (
|
{trial.status === "scheduled" && (
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link
|
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}>
|
||||||
href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}
|
<Play className="mr-2 h-4 w-4" />
|
||||||
>
|
Start Trial
|
||||||
Start trial
|
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{trial.status === "in_progress" && (
|
{trial.status === "in_progress" && (
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link
|
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}>
|
||||||
href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}
|
<Gamepad2 className="mr-2 h-4 w-4" />
|
||||||
>
|
Control Trial
|
||||||
Control trial
|
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{trial.status === "completed" && (
|
{trial.status === "completed" && (
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}`}>
|
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/analysis`}>
|
||||||
View analysis
|
<LineChart className="mr-2 h-4 w-4" />
|
||||||
|
Analysis
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem onClick={() => duplicateMutation.mutate({ id: trial.id })}>
|
||||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}`}>
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
Edit trial
|
Duplicate
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
{(trial.status === "scheduled" || trial.status === "failed") && (
|
{(trial.status === "scheduled" || trial.status === "failed") && (
|
||||||
<DropdownMenuItem className="text-red-600">
|
<DropdownMenuItem className="text-red-600">
|
||||||
Cancel trial
|
<Ban className="mr-2 h-4 w-4" />
|
||||||
|
Cancel
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface TrialsTableProps {
|
interface TrialsTableProps {
|
||||||
studyId?: string;
|
studyId?: string;
|
||||||
|
|||||||
203
src/components/trials/timeline/HorizontalTimeline.tsx
Normal file
203
src/components/trials/timeline/HorizontalTimeline.tsx
Normal file
@@ -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<TimelineEvent | null>(null);
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center text-sm text-muted-foreground py-8">
|
||||||
|
No events recorded yet
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Timeline visualization */}
|
||||||
|
<div className="relative">
|
||||||
|
<ScrollArea className="w-full">
|
||||||
|
<div className="min-w-[800px] px-4 py-8">
|
||||||
|
{/* Time markers */}
|
||||||
|
<div className="relative h-20 mb-8">
|
||||||
|
{/* Main horizontal line */}
|
||||||
|
<div className="absolute top-1/2 left-0 right-0 h-0.5 bg-border" style={{ transform: 'translateY(-50%)' }} />
|
||||||
|
|
||||||
|
{/* Time labels */}
|
||||||
|
{timeMarkers.map((marker, i) => {
|
||||||
|
const pos = getPosition(marker);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="absolute"
|
||||||
|
style={{ left: `${pos}%`, top: '50%', transform: 'translate(-50%, -50%)' }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="text-[10px] font-mono text-muted-foreground mb-2">
|
||||||
|
{marker.toLocaleTimeString([], {
|
||||||
|
hour12: false,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-4 bg-border" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event markers */}
|
||||||
|
<div className="relative h-40">
|
||||||
|
{/* Timeline line for events */}
|
||||||
|
<div className="absolute top-20 left-0 right-0 h-0.5 bg-border" />
|
||||||
|
|
||||||
|
{events.map((event, i) => {
|
||||||
|
const pos = getPosition(event.timestamp);
|
||||||
|
const { color, Icon } = getEventStyle(event.type);
|
||||||
|
const isSelected = selectedEvent === event;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="absolute"
|
||||||
|
style={{
|
||||||
|
left: `${pos}%`,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Clickable marker group */}
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedEvent(isSelected ? null : event)}
|
||||||
|
className="flex flex-col items-center gap-1 cursor-pointer group"
|
||||||
|
title={event.message || event.type}
|
||||||
|
>
|
||||||
|
{/* Vertical dash */}
|
||||||
|
<div className={`
|
||||||
|
w-1 h-20 ${color} rounded-full
|
||||||
|
group-hover:w-1.5 transition-all
|
||||||
|
${isSelected ? 'w-1.5 ring-2 ring-offset-2 ring-offset-background ring-primary' : ''}
|
||||||
|
`} />
|
||||||
|
|
||||||
|
{/* Icon indicator */}
|
||||||
|
<div className={`
|
||||||
|
p-1.5 rounded-full ${color} bg-opacity-20
|
||||||
|
group-hover:bg-opacity-30 transition-all
|
||||||
|
${isSelected ? 'ring-2 ring-primary bg-opacity-40' : ''}
|
||||||
|
`}>
|
||||||
|
<Icon className={`h-3.5 w-3.5 ${color.replace('bg-', 'text-')}`} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected event details */}
|
||||||
|
{selectedEvent && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{selectedEvent.type.replace(/_/g, " ")}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs font-mono text-muted-foreground">
|
||||||
|
{selectedEvent.timestamp.toLocaleTimeString([], {
|
||||||
|
hour12: false,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
fractionalSecondDigits: 3
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{selectedEvent.message && (
|
||||||
|
<p className="text-sm">{selectedEvent.message}</p>
|
||||||
|
)}
|
||||||
|
{selectedEvent.data !== undefined && selectedEvent.data !== null && (
|
||||||
|
<details className="text-xs">
|
||||||
|
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
||||||
|
Event data
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-2 p-2 bg-muted rounded text-[10px] overflow-auto">
|
||||||
|
{JSON.stringify(selectedEvent.data, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
src/components/trials/views/TrialAnalysisView.tsx
Normal file
124
src/components/trials/views/TrialAnalysisView.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="container mx-auto p-6 space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Status</CardTitle>
|
||||||
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold capitalize">{trial.status.replace("_", " ")}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{trial.completedAt
|
||||||
|
? `Completed ${formatDistanceToNow(new Date(trial.completedAt), { addSuffix: true })}`
|
||||||
|
: "Not completed"}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Duration</CardTitle>
|
||||||
|
<BarChart className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{trial.duration ? `${Math.floor(trial.duration / 60)}m ${trial.duration % 60}s` : "N/A"}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Total execution time
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Events Logged</CardTitle>
|
||||||
|
<Database className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{trial.eventCount ?? 0}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
System & user events
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Media Files</CardTitle>
|
||||||
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{trial.mediaCount ?? 0}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Recordings & snapshots
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="overview" className="space-y-4">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||||
|
<TabsTrigger value="events">Event Log</TabsTrigger>
|
||||||
|
<TabsTrigger value="charts">Charts</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="overview" className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Analysis Overview</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Summary of trial execution for {trial.participant.participantCode} in experiment {trial.experiment.name}.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="h-[400px] flex items-center justify-center border-2 border-dashed rounded-md m-4">
|
||||||
|
<div className="text-center text-muted-foreground">
|
||||||
|
<LineChart className="h-10 w-10 mx-auto mb-2 opacity-20" />
|
||||||
|
<p>Detailed analysis visualizations coming soon.</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="events">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Event Log</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Chronological record of all trial events.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="h-[400px] flex items-center justify-center border-2 border-dashed rounded-md m-4">
|
||||||
|
<div className="text-center text-muted-foreground">
|
||||||
|
<p>Event log view placeholder.</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { Play, CheckCircle, X, Clock, AlertCircle } from "lucide-react";
|
import { Play, CheckCircle, X, Clock, AlertCircle } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Progress } from "~/components/ui/progress";
|
import { Progress } from "~/components/ui/progress";
|
||||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
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 { WizardControlPanel } from "./panels/WizardControlPanel";
|
||||||
import { WizardExecutionPanel } from "./panels/WizardExecutionPanel";
|
import { WizardExecutionPanel } from "./panels/WizardExecutionPanel";
|
||||||
import { WizardMonitoringPanel } from "./panels/WizardMonitoringPanel";
|
import { WizardMonitoringPanel } from "./panels/WizardMonitoringPanel";
|
||||||
|
import { WizardObservationPane } from "./panels/WizardObservationPane";
|
||||||
|
import {
|
||||||
|
ResizableHandle,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from "~/components/ui/resizable";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { useWizardRos } from "~/hooks/useWizardRos";
|
import { useWizardRos } from "~/hooks/useWizardRos";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -42,6 +49,16 @@ interface WizardInterfaceProps {
|
|||||||
userRole: string;
|
userRole: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ActionData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
type: string;
|
||||||
|
parameters: Record<string, unknown>;
|
||||||
|
order: number;
|
||||||
|
pluginId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface StepData {
|
interface StepData {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -53,6 +70,7 @@ interface StepData {
|
|||||||
| "conditional_branch";
|
| "conditional_branch";
|
||||||
parameters: Record<string, unknown>;
|
parameters: Record<string, unknown>;
|
||||||
order: number;
|
order: number;
|
||||||
|
actions: ActionData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WizardInterface = React.memo(function WizardInterface({
|
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,
|
initialTrial.startedAt ? new Date(initialTrial.startedAt) : null,
|
||||||
);
|
);
|
||||||
const [elapsedTime, setElapsedTime] = useState(0);
|
const [elapsedTime, setElapsedTime] = useState(0);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
// Persistent tab states to prevent resets from parent re-renders
|
// Persistent tab states to prevent resets from parent re-renders
|
||||||
const [controlPanelTab, setControlPanelTab] = useState<
|
const [controlPanelTab, setControlPanelTab] = useState<
|
||||||
@@ -73,9 +92,16 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
const [executionPanelTab, setExecutionPanelTab] = useState<
|
const [executionPanelTab, setExecutionPanelTab] = useState<
|
||||||
"current" | "timeline" | "events"
|
"current" | "timeline" | "events"
|
||||||
>(trial.status === "in_progress" ? "current" : "timeline");
|
>(trial.status === "in_progress" ? "current" : "timeline");
|
||||||
|
const [isExecutingAction, setIsExecutingAction] = useState(false);
|
||||||
const [monitoringPanelTab, setMonitoringPanelTab] = useState<
|
const [monitoringPanelTab, setMonitoringPanelTab] = useState<
|
||||||
"status" | "robot" | "events"
|
"status" | "robot" | "events"
|
||||||
>("status");
|
>("status");
|
||||||
|
const [completedActionsCount, setCompletedActionsCount] = useState(0);
|
||||||
|
|
||||||
|
// Reset completed actions when step changes
|
||||||
|
useEffect(() => {
|
||||||
|
setCompletedActionsCount(0);
|
||||||
|
}, [currentStepIndex]);
|
||||||
|
|
||||||
// Get experiment steps from API
|
// Get experiment steps from API
|
||||||
const { data: experimentSteps } = api.experiments.getSteps.useQuery(
|
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
|
// Map database step types to component step types
|
||||||
const mapStepType = (dbType: string) => {
|
const mapStepType = (dbType: string) => {
|
||||||
switch (dbType) {
|
switch (dbType) {
|
||||||
@@ -136,6 +169,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
connect: connectRos,
|
connect: connectRos,
|
||||||
disconnect: disconnectRos,
|
disconnect: disconnectRos,
|
||||||
executeRobotAction: executeRosAction,
|
executeRobotAction: executeRosAction,
|
||||||
|
setAutonomousLife,
|
||||||
} = useWizardRos({
|
} = useWizardRos({
|
||||||
autoConnect: true,
|
autoConnect: true,
|
||||||
onActionCompleted,
|
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
|
// Update local trial state from polling
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pollingData) {
|
if (pollingData) {
|
||||||
@@ -168,7 +211,15 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
}
|
}
|
||||||
}, [pollingData]);
|
}, [pollingData]);
|
||||||
|
|
||||||
|
// Auto-start trial on mount if scheduled
|
||||||
|
useEffect(() => {
|
||||||
|
if (trial.status === "scheduled") {
|
||||||
|
handleStartTrial();
|
||||||
|
}
|
||||||
|
}, []); // Run once on mount
|
||||||
|
|
||||||
// Trial events from robot actions
|
// Trial events from robot actions
|
||||||
|
|
||||||
const trialEvents = useMemo<
|
const trialEvents = useMemo<
|
||||||
Array<{
|
Array<{
|
||||||
type: string;
|
type: string;
|
||||||
@@ -176,7 +227,38 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
data?: unknown;
|
data?: unknown;
|
||||||
message?: string;
|
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
|
// Transform experiment steps to component format
|
||||||
const steps: StepData[] =
|
const steps: StepData[] =
|
||||||
@@ -187,6 +269,15 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
type: mapStepType(step.type),
|
type: mapStepType(step.type),
|
||||||
parameters: step.parameters ?? {},
|
parameters: step.parameters ?? {},
|
||||||
order: step.order ?? index,
|
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;
|
const currentStep = steps[currentStepIndex] ?? null;
|
||||||
@@ -261,6 +352,8 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
status: data.status,
|
status: data.status,
|
||||||
completedAt: data.completedAt,
|
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 = () => {
|
const handleNextStep = () => {
|
||||||
if (currentStepIndex < steps.length - 1) {
|
if (currentStepIndex < steps.length - 1) {
|
||||||
|
setCompletedActionsCount(0); // Reset immediately to prevent flickering/double-click issues
|
||||||
setCurrentStepIndex(currentStepIndex + 1);
|
setCurrentStepIndex(currentStepIndex + 1);
|
||||||
// Note: Step transitions can be enhanced later with database logging
|
// 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 (
|
const handleExecuteAction = async (
|
||||||
actionId: string,
|
actionId: string,
|
||||||
parameters?: Record<string, unknown>,
|
parameters?: Record<string, unknown>,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
console.log("Executing action:", actionId, parameters);
|
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
|
// Note: Action execution can be enhanced later with tRPC mutations
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to execute action:", 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,
|
pluginName: string,
|
||||||
actionId: string,
|
actionId: string,
|
||||||
parameters: Record<string, unknown>,
|
parameters: Record<string, unknown>,
|
||||||
|
options?: { autoAdvance?: boolean },
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
|
setIsExecutingAction(true);
|
||||||
// Try direct WebSocket execution first for better performance
|
// Try direct WebSocket execution first for better performance
|
||||||
if (rosConnected) {
|
if (rosConnected) {
|
||||||
try {
|
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
|
// Log to trial events for data capture
|
||||||
await executeRobotActionMutation.mutateAsync({
|
await logRobotActionMutation.mutateAsync({
|
||||||
trialId: trial.id,
|
trialId: trial.id,
|
||||||
pluginName,
|
pluginName,
|
||||||
actionId,
|
actionId,
|
||||||
parameters,
|
parameters,
|
||||||
|
duration,
|
||||||
|
result: { status: result.status },
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success(`Robot action executed: ${actionId}`);
|
toast.success(`Robot action executed: ${actionId}`);
|
||||||
|
if (options?.autoAdvance) {
|
||||||
|
handleNextStep();
|
||||||
|
}
|
||||||
} catch (rosError) {
|
} catch (rosError) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"WebSocket execution failed, falling back to tRPC:",
|
"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}`);
|
toast.success(`Robot action executed via fallback: ${actionId}`);
|
||||||
|
if (options?.autoAdvance) {
|
||||||
|
handleNextStep();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Use tRPC execution if WebSocket not connected
|
// Use tRPC execution if WebSocket not connected
|
||||||
@@ -394,17 +560,51 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
});
|
});
|
||||||
|
|
||||||
toast.success(`Robot action executed: ${actionId}`);
|
toast.success(`Robot action executed: ${actionId}`);
|
||||||
|
if (options?.autoAdvance) {
|
||||||
|
handleNextStep();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to execute robot action:", error);
|
console.error("Failed to execute robot action:", error);
|
||||||
toast.error(`Failed to execute robot action: ${actionId}`, {
|
toast.error(`Failed to execute robot action: ${actionId}`, {
|
||||||
description: error instanceof Error ? error.message : "Unknown error",
|
description: error instanceof Error ? error.message : "Unknown error",
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
setIsExecutingAction(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[rosConnected, executeRosAction, executeRobotActionMutation, trial.id],
|
[rosConnected, executeRosAction, executeRobotActionMutation, trial.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleSkipAction = useCallback(
|
||||||
|
async (
|
||||||
|
pluginName: string,
|
||||||
|
actionId: string,
|
||||||
|
parameters: Record<string, unknown>,
|
||||||
|
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 (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* Compact Status Bar */}
|
{/* Compact Status Bar */}
|
||||||
@@ -451,10 +651,10 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* No connection status alert - ROS connection shown in monitoring panel */}
|
{/* Main Content with Vertical Resizable Split */}
|
||||||
|
|
||||||
{/* Main Content - Three Panel Layout */}
|
|
||||||
<div className="min-h-0 flex-1">
|
<div className="min-h-0 flex-1">
|
||||||
|
<ResizablePanelGroup direction="vertical">
|
||||||
|
<ResizablePanel defaultSize={75} minSize={30}>
|
||||||
<PanelsContainer
|
<PanelsContainer
|
||||||
left={
|
left={
|
||||||
<WizardControlPanel
|
<WizardControlPanel
|
||||||
@@ -474,6 +674,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
activeTab={controlPanelTab}
|
activeTab={controlPanelTab}
|
||||||
onTabChange={setControlPanelTab}
|
onTabChange={setControlPanelTab}
|
||||||
isStarting={startTrialMutation.isPending}
|
isStarting={startTrialMutation.isPending}
|
||||||
|
onSetAutonomousLife={setAutonomousLife}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
center={
|
center={
|
||||||
@@ -485,8 +686,15 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
trialEvents={trialEvents}
|
trialEvents={trialEvents}
|
||||||
onStepSelect={(index: number) => setCurrentStepIndex(index)}
|
onStepSelect={(index: number) => setCurrentStepIndex(index)}
|
||||||
onExecuteAction={handleExecuteAction}
|
onExecuteAction={handleExecuteAction}
|
||||||
|
onExecuteRobotAction={handleExecuteRobotAction}
|
||||||
activeTab={executionPanelTab}
|
activeTab={executionPanelTab}
|
||||||
onTabChange={setExecutionPanelTab}
|
onTabChange={setExecutionPanelTab}
|
||||||
|
onSkipAction={handleSkipAction}
|
||||||
|
isExecuting={isExecutingAction}
|
||||||
|
onNextStep={handleNextStep}
|
||||||
|
completedActionsCount={completedActionsCount}
|
||||||
|
onActionCompleted={() => setCompletedActionsCount(c => c + 1)}
|
||||||
|
onCompleteTrial={handleCompleteTrial}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
right={
|
right={
|
||||||
@@ -503,6 +711,18 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
showDividers={true}
|
showDividers={true}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
/>
|
/>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle />
|
||||||
|
|
||||||
|
<ResizablePanel defaultSize={25} minSize={10}>
|
||||||
|
<WizardObservationPane
|
||||||
|
onAddAnnotation={handleAddAnnotation}
|
||||||
|
isSubmitting={addAnnotationMutation.isPending}
|
||||||
|
trialEvents={trialEvents}
|
||||||
|
/>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { Button } from "~/components/ui/button";
|
|||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Progress } from "~/components/ui/progress";
|
import { Progress } from "~/components/ui/progress";
|
||||||
import { Separator } from "~/components/ui/separator";
|
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 { Alert, AlertDescription } from "~/components/ui/alert";
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
||||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||||
@@ -35,6 +37,15 @@ interface StepData {
|
|||||||
| "conditional_branch";
|
| "conditional_branch";
|
||||||
parameters: Record<string, unknown>;
|
parameters: Record<string, unknown>;
|
||||||
order: number;
|
order: number;
|
||||||
|
actions?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
type: string;
|
||||||
|
parameters: Record<string, unknown>;
|
||||||
|
order: number;
|
||||||
|
pluginId: string | null;
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TrialData {
|
interface TrialData {
|
||||||
@@ -86,6 +97,7 @@ interface WizardControlPanelProps {
|
|||||||
activeTab: "control" | "step" | "actions" | "robot";
|
activeTab: "control" | "step" | "actions" | "robot";
|
||||||
onTabChange: (tab: "control" | "step" | "actions" | "robot") => void;
|
onTabChange: (tab: "control" | "step" | "actions" | "robot") => void;
|
||||||
isStarting?: boolean;
|
isStarting?: boolean;
|
||||||
|
onSetAutonomousLife?: (enabled: boolean) => Promise<boolean | void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WizardControlPanel({
|
export function WizardControlPanel({
|
||||||
@@ -105,65 +117,28 @@ export function WizardControlPanel({
|
|||||||
activeTab,
|
activeTab,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
isStarting = false,
|
isStarting = false,
|
||||||
|
onSetAutonomousLife,
|
||||||
}: WizardControlPanelProps) {
|
}: WizardControlPanelProps) {
|
||||||
const progress =
|
const [autonomousLife, setAutonomousLife] = React.useState(true);
|
||||||
steps.length > 0 ? ((currentStepIndex + 1) / steps.length) * 100 : 0;
|
|
||||||
|
|
||||||
const getStatusConfig = (status: string) => {
|
const handleAutonomousLifeChange = async (checked: boolean) => {
|
||||||
switch (status) {
|
setAutonomousLife(checked); // Optimistic update
|
||||||
case "scheduled":
|
if (onSetAutonomousLife) {
|
||||||
return { variant: "outline" as const, icon: Clock };
|
try {
|
||||||
case "in_progress":
|
const result = await onSetAutonomousLife(checked);
|
||||||
return { variant: "default" as const, icon: Play };
|
if (result === false) {
|
||||||
case "completed":
|
throw new Error("Service unavailable");
|
||||||
return { variant: "secondary" as const, icon: CheckCircle };
|
}
|
||||||
case "aborted":
|
} catch (error) {
|
||||||
case "failed":
|
console.error("Failed to set autonomous life:", error);
|
||||||
return { variant: "destructive" as const, icon: X };
|
setAutonomousLife(!checked); // Revert on failure
|
||||||
default:
|
// Optional: Toast error?
|
||||||
return { variant: "outline" as const, icon: Clock };
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusConfig = getStatusConfig(trial.status);
|
|
||||||
const StatusIcon = statusConfig.icon;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* Trial Info Header */}
|
|
||||||
<div className="border-b p-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Badge
|
|
||||||
variant={statusConfig.variant}
|
|
||||||
className="flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<StatusIcon className="h-3 w-3" />
|
|
||||||
{trial.status.replace("_", " ")}
|
|
||||||
</Badge>
|
|
||||||
<span className="text-muted-foreground text-xs">
|
|
||||||
Session #{trial.sessionNumber}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-sm font-medium">
|
|
||||||
{trial.participant.participantCode}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{trial.status === "in_progress" && steps.length > 0 && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex justify-between text-xs">
|
|
||||||
<span className="text-muted-foreground">Progress</span>
|
|
||||||
<span>
|
|
||||||
{currentStepIndex + 1} of {steps.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={progress} className="h-1.5" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabbed Content */}
|
{/* Tabbed Content */}
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
@@ -275,17 +250,36 @@ export function WizardControlPanel({
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Connection Status */}
|
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="space-y-2">
|
<div className="space-y-4">
|
||||||
<div className="text-xs font-medium">Connection</div>
|
<div className="text-xs font-medium">Robot Status</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs">
|
||||||
Status
|
Connection
|
||||||
</span>
|
</span>
|
||||||
<Badge variant="default" className="text-xs">
|
{_isConnected ? (
|
||||||
Polling
|
<Badge variant="default" className="bg-green-600 text-xs">
|
||||||
|
Connected
|
||||||
</Badge>
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-yellow-600 border-yellow-600 text-xs">
|
||||||
|
Polling...
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="autonomous-life" className="text-xs font-normal text-muted-foreground">Autonomous Life</Label>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="autonomous-life"
|
||||||
|
checked={autonomousLife}
|
||||||
|
onCheckedChange={handleAutonomousLifeChange}
|
||||||
|
disabled={!_isConnected}
|
||||||
|
className="scale-75"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import {
|
|||||||
Zap,
|
Zap,
|
||||||
Eye,
|
Eye,
|
||||||
List,
|
List,
|
||||||
|
Loader2,
|
||||||
|
ArrowRight,
|
||||||
|
AlertTriangle,
|
||||||
|
RotateCcw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
@@ -30,6 +34,15 @@ interface StepData {
|
|||||||
| "conditional_branch";
|
| "conditional_branch";
|
||||||
parameters: Record<string, unknown>;
|
parameters: Record<string, unknown>;
|
||||||
order: number;
|
order: number;
|
||||||
|
actions?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
type: string;
|
||||||
|
parameters: Record<string, unknown>;
|
||||||
|
order: number;
|
||||||
|
pluginId: string | null;
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TrialData {
|
interface TrialData {
|
||||||
@@ -75,8 +88,25 @@ interface WizardExecutionPanelProps {
|
|||||||
actionId: string,
|
actionId: string,
|
||||||
parameters?: Record<string, unknown>,
|
parameters?: Record<string, unknown>,
|
||||||
) => void;
|
) => void;
|
||||||
activeTab: "current" | "timeline" | "events";
|
onExecuteRobotAction: (
|
||||||
onTabChange: (tab: "current" | "timeline" | "events") => void;
|
pluginName: string,
|
||||||
|
actionId: string,
|
||||||
|
parameters: Record<string, unknown>,
|
||||||
|
options?: { autoAdvance?: boolean },
|
||||||
|
) => Promise<void>;
|
||||||
|
activeTab: "current" | "timeline" | "events"; // Deprecated/Ignored
|
||||||
|
onTabChange: (tab: "current" | "timeline" | "events") => void; // Deprecated/Ignored
|
||||||
|
onSkipAction: (
|
||||||
|
pluginName: string,
|
||||||
|
actionId: string,
|
||||||
|
parameters: Record<string, unknown>,
|
||||||
|
options?: { autoAdvance?: boolean },
|
||||||
|
) => Promise<void>;
|
||||||
|
isExecuting?: boolean;
|
||||||
|
onNextStep?: () => void;
|
||||||
|
onCompleteTrial?: () => void;
|
||||||
|
completedActionsCount: number;
|
||||||
|
onActionCompleted: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WizardExecutionPanel({
|
export function WizardExecutionPanel({
|
||||||
@@ -87,9 +117,21 @@ export function WizardExecutionPanel({
|
|||||||
trialEvents,
|
trialEvents,
|
||||||
onStepSelect,
|
onStepSelect,
|
||||||
onExecuteAction,
|
onExecuteAction,
|
||||||
|
onExecuteRobotAction,
|
||||||
activeTab,
|
activeTab,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
|
onSkipAction,
|
||||||
|
isExecuting = false,
|
||||||
|
onNextStep,
|
||||||
|
onCompleteTrial,
|
||||||
|
completedActionsCount,
|
||||||
|
onActionCompleted,
|
||||||
}: WizardExecutionPanelProps) {
|
}: 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) => {
|
const getStepIcon = (type: string) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "wizard_action":
|
case "wizard_action":
|
||||||
@@ -169,7 +211,7 @@ export function WizardExecutionPanel({
|
|||||||
</h3>
|
</h3>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
{trial.completedAt &&
|
{trial.completedAt &&
|
||||||
`Ended at ${new Date(trial.completedAt).toLocaleTimeString()}`}
|
`Ended at ${new Date(trial.completedAt).toLocaleTimeString()} `}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -209,281 +251,228 @@ export function WizardExecutionPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabbed Content */}
|
{/* Simplified Content - Sequential Focus */}
|
||||||
<Tabs
|
<div className="flex-1 overflow-hidden">
|
||||||
value={activeTab}
|
|
||||||
onValueChange={(value: string) => {
|
|
||||||
if (
|
|
||||||
value === "current" ||
|
|
||||||
value === "timeline" ||
|
|
||||||
value === "events"
|
|
||||||
) {
|
|
||||||
onTabChange(value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="flex min-h-0 flex-1 flex-col"
|
|
||||||
>
|
|
||||||
<div className="border-b px-2 py-1">
|
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
|
||||||
<TabsTrigger value="current" className="text-xs">
|
|
||||||
<Eye className="mr-1 h-3 w-3" />
|
|
||||||
Current
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="timeline" className="text-xs">
|
|
||||||
<List className="mr-1 h-3 w-3" />
|
|
||||||
Timeline
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="events" className="text-xs">
|
|
||||||
<Activity className="mr-1 h-3 w-3" />
|
|
||||||
Events
|
|
||||||
{trialEvents.length > 0 && (
|
|
||||||
<Badge variant="secondary" className="ml-1 text-xs">
|
|
||||||
{trialEvents.length}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-h-0 flex-1">
|
|
||||||
{/* Current Step Tab */}
|
|
||||||
<TabsContent value="current" className="m-0 h-full">
|
|
||||||
<div className="h-full">
|
|
||||||
{currentStep ? (
|
|
||||||
<div className="flex h-full flex-col p-4">
|
|
||||||
{/* Current Step Display */}
|
|
||||||
<div className="flex-1 space-y-4 text-left">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="bg-primary/10 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full">
|
|
||||||
{React.createElement(getStepIcon(currentStep.type), {
|
|
||||||
className: "h-5 w-5 text-primary",
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<h4 className="text-sm font-medium">
|
|
||||||
{currentStep.name}
|
|
||||||
</h4>
|
|
||||||
<Badge variant="outline" className="mt-1 text-xs">
|
|
||||||
{currentStep.type.replace("_", " ")}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{currentStep.description && (
|
|
||||||
<div className="text-muted-foreground text-sm">
|
|
||||||
{currentStep.description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step-specific content */}
|
|
||||||
{currentStep.type === "wizard_action" && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="text-sm font-medium">
|
|
||||||
Available Actions
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="w-full justify-start"
|
|
||||||
onClick={() => onExecuteAction("acknowledge")}
|
|
||||||
>
|
|
||||||
<CheckCircle className="mr-2 h-4 w-4" />
|
|
||||||
Acknowledge Step
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="w-full justify-start"
|
|
||||||
onClick={() => onExecuteAction("intervene")}
|
|
||||||
>
|
|
||||||
<Zap className="mr-2 h-4 w-4" />
|
|
||||||
Manual Intervention
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="w-full justify-start"
|
|
||||||
onClick={() =>
|
|
||||||
onExecuteAction("note", {
|
|
||||||
content: "Step observation",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<User className="mr-2 h-4 w-4" />
|
|
||||||
Add Observation
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentStep.type === "robot_action" && (
|
|
||||||
<Alert>
|
|
||||||
<Bot className="h-4 w-4" />
|
|
||||||
<AlertDescription className="text-sm">
|
|
||||||
<div className="font-medium">
|
|
||||||
Robot Action in Progress
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xs">
|
|
||||||
The robot is executing this step. Monitor status in
|
|
||||||
the monitoring panel.
|
|
||||||
</div>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentStep.type === "parallel_steps" && (
|
|
||||||
<Alert>
|
|
||||||
<Activity className="h-4 w-4" />
|
|
||||||
<AlertDescription className="text-sm">
|
|
||||||
<div className="font-medium">Parallel Execution</div>
|
|
||||||
<div className="mt-1 text-xs">
|
|
||||||
Multiple actions are running simultaneously.
|
|
||||||
</div>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full items-center justify-center p-6">
|
|
||||||
<div className="w-full max-w-md text-center">
|
|
||||||
<div className="text-muted-foreground text-sm">
|
|
||||||
No current step available
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Timeline Tab */}
|
|
||||||
<TabsContent value="timeline" className="m-0 h-full">
|
|
||||||
<ScrollArea className="h-full">
|
<ScrollArea className="h-full">
|
||||||
<div className="space-y-2 p-3">
|
{currentStep ? (
|
||||||
{steps.map((step, index) => {
|
<div className="flex flex-col gap-6 p-6">
|
||||||
const status = getStepStatus(index);
|
{/* Header Info (Simplified) */}
|
||||||
const StepIcon = getStepIcon(step.type);
|
<div className="space-y-4">
|
||||||
const isActive = index === currentStepIndex;
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold tracking-tight">{currentStep.name}</h2>
|
||||||
|
{currentStep.description && (
|
||||||
|
<div className="text-muted-foreground text-sm mt-1">{currentStep.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Sequence */}
|
||||||
|
{currentStep.actions && currentStep.actions.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
|
||||||
|
Execution Sequence
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{currentStep.actions.map((action, idx) => {
|
||||||
|
const isCompleted = idx < activeActionIndex;
|
||||||
|
const isActive = idx === activeActionIndex;
|
||||||
|
const isPending = idx > activeActionIndex;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={step.id}
|
key={action.id}
|
||||||
className={`hover:bg-muted/50 flex cursor-pointer items-start gap-3 rounded-lg p-2 transition-colors ${
|
className={`group relative flex items-center gap-4 rounded-xl border p-5 transition-all ${isActive ? "bg-card border-primary ring-1 ring-primary shadow-md" :
|
||||||
isActive ? "bg-primary/5 border-primary/20 border" : ""
|
isCompleted ? "bg-muted/30 border-transparent opacity-70" :
|
||||||
}`}
|
"bg-card border-border opacity-50"
|
||||||
onClick={() => onStepSelect(index)}
|
|
||||||
>
|
|
||||||
{/* Step Number and Status */}
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<div
|
|
||||||
className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-medium ${
|
|
||||||
status === "completed"
|
|
||||||
? "bg-green-100 text-green-700"
|
|
||||||
: status === "active"
|
|
||||||
? "bg-primary/10 text-primary"
|
|
||||||
: "bg-muted text-muted-foreground"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{status === "completed" ? (
|
<div className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full border text-sm font-medium ${isCompleted ? "bg-transparent text-green-600 border-green-600" :
|
||||||
<CheckCircle className="h-3 w-3" />
|
isActive ? "bg-transparent text-primary border-primary font-bold shadow-sm" :
|
||||||
) : (
|
"bg-transparent text-muted-foreground border-transparent"
|
||||||
index + 1
|
}`}>
|
||||||
)}
|
{isCompleted ? <CheckCircle className="h-5 w-5" /> : idx + 1}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className={`font - medium truncate ${isCompleted ? "line-through text-muted-foreground" : ""} `}>{action.name}</div>
|
||||||
|
{action.description && (
|
||||||
|
<div className="text-xs text-muted-foreground line-clamp-1">
|
||||||
|
{action.description}
|
||||||
</div>
|
</div>
|
||||||
{index < steps.length - 1 && (
|
|
||||||
<div
|
|
||||||
className={`mt-1 h-4 w-0.5 ${
|
|
||||||
status === "completed"
|
|
||||||
? "bg-green-200"
|
|
||||||
: "bg-border"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Step Content */}
|
{action.pluginId && isActive && (
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<StepIcon className="text-muted-foreground h-3 w-3 flex-shrink-0" />
|
<Button
|
||||||
<div className="truncate text-sm font-medium">
|
size="sm"
|
||||||
{step.name}
|
variant="ghost"
|
||||||
</div>
|
className="h-9 px-3 text-muted-foreground hover:text-foreground"
|
||||||
<Badge
|
onClick={(e) => {
|
||||||
variant={getStepVariant(status)}
|
e.preventDefault();
|
||||||
className="ml-auto flex-shrink-0 text-xs"
|
e.stopPropagation();
|
||||||
>
|
console.log("Skip clicked");
|
||||||
{step.type.replace("_", " ")}
|
// Fire and forget
|
||||||
</Badge>
|
onSkipAction(
|
||||||
</div>
|
action.pluginId!,
|
||||||
|
action.type.includes(".")
|
||||||
{step.description && (
|
? action.type.split(".").pop()!
|
||||||
<p className="text-muted-foreground mt-1 line-clamp-2 text-xs">
|
: action.type,
|
||||||
{step.description}
|
action.parameters || {},
|
||||||
</p>
|
{ autoAdvance: false }
|
||||||
)}
|
|
||||||
|
|
||||||
{isActive && trial.status === "in_progress" && (
|
|
||||||
<div className="mt-1 flex items-center gap-1">
|
|
||||||
<div className="bg-primary h-1.5 w-1.5 animate-pulse rounded-full" />
|
|
||||||
<span className="text-primary text-xs">
|
|
||||||
Executing
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
onActionCompleted();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="default"
|
||||||
|
className="h-10 px-4 shadow-sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
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();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
Execute
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fallback for actions with no plugin ID (e.g. manual steps) */}
|
||||||
|
{!action.pluginId && isActive && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onActionCompleted();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Mark Done
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Completed State Indicator */}
|
||||||
|
{isCompleted && (
|
||||||
|
<div className="flex items-center gap-2 px-3">
|
||||||
|
<div className="text-xs font-medium text-green-600">
|
||||||
|
Done
|
||||||
|
</div>
|
||||||
|
{action.pluginId && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||||
|
title="Retry Action"
|
||||||
|
onClick={(e) => {
|
||||||
|
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 },
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7 text-amber-500 hover:text-amber-600 hover:bg-amber-100"
|
||||||
|
title="Mark Issue"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onExecuteAction("note", {
|
||||||
|
content: `Reported issue with action: ${action.name}`,
|
||||||
|
category: "system_issue"
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Events Tab */}
|
{/* Manual Advance Button */}
|
||||||
<TabsContent value="events" className="m-0 h-full">
|
{activeActionIndex >= (currentStep.actions?.length || 0) && (
|
||||||
<ScrollArea className="h-full">
|
<div className="mt-6 flex justify-end">
|
||||||
<div className="p-3">
|
<Button
|
||||||
{trialEvents.length === 0 ? (
|
size="lg"
|
||||||
<div className="flex h-32 items-center justify-center">
|
onClick={currentStepIndex === steps.length - 1 ? onCompleteTrial : onNextStep}
|
||||||
<div className="text-muted-foreground text-center text-sm">
|
className={`w-full text-white shadow-md transition-all hover:scale-[1.02] ${currentStepIndex === steps.length - 1
|
||||||
No events recorded yet
|
? "bg-blue-600 hover:bg-blue-700"
|
||||||
|
: "bg-green-600 hover:bg-green-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{currentStepIndex === steps.length - 1 ? "Complete Trial" : "Complete Step"}
|
||||||
|
<ArrowRight className="ml-2 h-5 w-5" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Manual Wizard Controls (If applicable) */}
|
||||||
|
{currentStep.type === "wizard_action" && (
|
||||||
|
<div className="rounded-xl border border-dashed p-6 space-y-4">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground">Manual Controls</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-12 justify-start"
|
||||||
|
onClick={() => onExecuteAction("acknowledge")}
|
||||||
|
>
|
||||||
|
<CheckCircle className="mr-2 h-4 w-4" />
|
||||||
|
Acknowledge
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-12 justify-start"
|
||||||
|
onClick={() => onExecuteAction("intervene")}
|
||||||
|
>
|
||||||
|
<Zap className="mr-2 h-4 w-4" />
|
||||||
|
Intervene
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||||
{trialEvents
|
No active step
|
||||||
.slice()
|
|
||||||
.reverse()
|
|
||||||
.map((event, index) => (
|
|
||||||
<div
|
|
||||||
key={`${event.timestamp.getTime()}-${index}`}
|
|
||||||
className="border-border/50 flex items-start gap-2 rounded-lg border p-2"
|
|
||||||
>
|
|
||||||
<div className="bg-muted flex h-6 w-6 flex-shrink-0 items-center justify-center rounded">
|
|
||||||
<Activity className="h-3 w-3" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-sm font-medium capitalize">
|
|
||||||
{event.type.replace(/_/g, " ")}
|
|
||||||
</div>
|
|
||||||
{event.message && (
|
|
||||||
<div className="text-muted-foreground mt-1 text-xs">
|
|
||||||
{event.message}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-muted-foreground mt-1 text-xs">
|
|
||||||
{event.timestamp.toLocaleTimeString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</TabsContent>
|
|
||||||
</div>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
158
src/components/trials/wizard/panels/WizardObservationPane.tsx
Normal file
158
src/components/trials/wizard/panels/WizardObservationPane.tsx
Normal file
@@ -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<void>;
|
||||||
|
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<string[]>([]);
|
||||||
|
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 (
|
||||||
|
<div className="flex h-full flex-col border-t bg-background">
|
||||||
|
<Tabs defaultValue="notes" className="flex h-full flex-col">
|
||||||
|
<div className="border-b px-4 bg-muted/30">
|
||||||
|
<TabsList className="h-9 -mb-px bg-transparent p-0">
|
||||||
|
<TabsTrigger value="notes" className="h-9 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 pb-2 pt-2 font-medium text-muted-foreground data-[state=active]:text-foreground shadow-none">
|
||||||
|
Notes & Observations
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="timeline" className="h-9 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 pb-2 pt-2 font-medium text-muted-foreground data-[state=active]:text-foreground shadow-none">
|
||||||
|
Timeline
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabsContent value="notes" className="flex-1 flex flex-col p-4 m-0 data-[state=inactive]:hidden">
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<Textarea
|
||||||
|
placeholder="Type your observation here..."
|
||||||
|
className="flex-1 resize-none font-mono text-sm"
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={category} onValueChange={setCategory}>
|
||||||
|
<SelectTrigger className="w-[140px] h-8 text-xs">
|
||||||
|
<SelectValue placeholder="Category" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="observation">Observation</SelectItem>
|
||||||
|
<SelectItem value="participant_behavior">Behavior</SelectItem>
|
||||||
|
<SelectItem value="system_issue">System Issue</SelectItem>
|
||||||
|
<SelectItem value="success">Success</SelectItem>
|
||||||
|
<SelectItem value="failure">Failure</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div className="flex flex-1 items-center gap-2 rounded-md border px-2 h-8">
|
||||||
|
<Tag className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Add tags..."
|
||||||
|
className="flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground"
|
||||||
|
value={currentTag}
|
||||||
|
onChange={(e) => setCurrentTag(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
addTag();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={addTag}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting || !note.trim()}
|
||||||
|
className="h-8"
|
||||||
|
>
|
||||||
|
<Send className="mr-2 h-3 w-3" />
|
||||||
|
Add Note
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<Badge
|
||||||
|
key={tag}
|
||||||
|
variant="secondary"
|
||||||
|
className="px-1 py-0 text-[10px] cursor-pointer hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
onClick={() => setTags(tags.filter((t) => t !== tag))}
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="timeline" className="flex-1 m-0 min-h-0 p-4 data-[state=inactive]:hidden">
|
||||||
|
<HorizontalTimeline events={trialEvents} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -119,8 +119,8 @@ export function EntityForm<T extends FieldValues = FieldValues>({
|
|||||||
{/* Form Layout */}
|
{/* Form Layout */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid gap-8",
|
"grid gap-8 w-full",
|
||||||
layout === "default" && "grid-cols-1 lg:grid-cols-3",
|
layout === "default" && "grid-cols-1 lg:grid-cols-3", // Keep the column split but remove max-width
|
||||||
layout === "full-width" && "grid-cols-1",
|
layout === "full-width" && "grid-cols-1",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as LucideIcons from "lucide-react";
|
import * as LucideIcons from "lucide-react";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
@@ -45,10 +46,15 @@ interface EntityViewSidebarProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
interface EntityViewProps {
|
interface EntityViewProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
layout?: "default" | "full-width";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ... existing code ...
|
||||||
|
|
||||||
|
|
||||||
export function EntityViewHeader({
|
export function EntityViewHeader({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
@@ -115,8 +121,15 @@ export function EntityViewSidebar({ children }: EntityViewSidebarProps) {
|
|||||||
return <div className="space-y-6">{children}</div>;
|
return <div className="space-y-6">{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EntityView({ children }: EntityViewProps) {
|
export function EntityView({ children, layout = "default" }: EntityViewProps) {
|
||||||
return <div className="space-y-6">{children}</div>;
|
// 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 (
|
||||||
|
<div className="flex flex-col gap-6 w-full h-full">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utility component for empty states
|
// Utility component for empty states
|
||||||
@@ -158,8 +171,7 @@ interface InfoGridProps {
|
|||||||
export function InfoGrid({ items, columns = 2 }: InfoGridProps) {
|
export function InfoGrid({ items, columns = 2 }: InfoGridProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`grid gap-4 ${
|
className={`grid gap-4 ${columns === 1
|
||||||
columns === 1
|
|
||||||
? "grid-cols-1"
|
? "grid-cols-1"
|
||||||
: columns === 2
|
: columns === 2
|
||||||
? "md:grid-cols-2"
|
? "md:grid-cols-2"
|
||||||
|
|||||||
@@ -39,8 +39,11 @@ export interface UseWizardRosReturn {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
) => Promise<RobotActionExecution>;
|
) => Promise<RobotActionExecution>;
|
||||||
|
callService: (service: string, args?: Record<string, unknown>) => Promise<any>;
|
||||||
|
setAutonomousLife: (enabled: boolean) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function useWizardRos(
|
export function useWizardRos(
|
||||||
options: UseWizardRosOptions = {},
|
options: UseWizardRosOptions = {},
|
||||||
): UseWizardRosReturn {
|
): UseWizardRosReturn {
|
||||||
@@ -288,6 +291,24 @@ export function useWizardRos(
|
|||||||
[isConnected],
|
[isConnected],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const callService = useCallback(
|
||||||
|
async (service: string, args?: Record<string, unknown>): Promise<any> => {
|
||||||
|
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<boolean> => {
|
||||||
|
const srv = serviceRef.current;
|
||||||
|
if (!srv || !isConnected) throw new Error("Not connected");
|
||||||
|
return srv.setAutonomousLife(enabled);
|
||||||
|
},
|
||||||
|
[isConnected],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isConnected,
|
isConnected,
|
||||||
isConnecting,
|
isConnecting,
|
||||||
@@ -297,5 +318,7 @@ export function useWizardRos(
|
|||||||
connect,
|
connect,
|
||||||
disconnect,
|
disconnect,
|
||||||
executeRobotAction,
|
executeRobotAction,
|
||||||
|
callService,
|
||||||
|
setAutonomousLife,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,17 @@ export interface RosMessage {
|
|||||||
values?: Record<string, unknown>;
|
values?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServiceRequest {
|
||||||
|
service: string;
|
||||||
|
args?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceResponse {
|
||||||
|
result: boolean;
|
||||||
|
values?: Record<string, unknown>;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RobotStatus {
|
export interface RobotStatus {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
battery: number;
|
battery: number;
|
||||||
@@ -405,7 +416,8 @@ export class WizardRosService extends EventEmitter {
|
|||||||
let msg: Record<string, unknown>;
|
let msg: Record<string, unknown>;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
config.payloadMapping.type === "template" &&
|
(config.payloadMapping.type === "template" ||
|
||||||
|
config.payloadMapping.type === "static") &&
|
||||||
config.payloadMapping.payload
|
config.payloadMapping.payload
|
||||||
) {
|
) {
|
||||||
// Template-based payload construction
|
// Template-based payload construction
|
||||||
@@ -451,10 +463,15 @@ export class WizardRosService extends EventEmitter {
|
|||||||
this.executeMovementAction(actionId, parameters);
|
this.executeMovementAction(actionId, parameters);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "move_head":
|
||||||
case "turn_head":
|
case "turn_head":
|
||||||
this.executeTurnHead(parameters);
|
this.executeTurnHead(parameters);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "move_arm":
|
||||||
|
this.executeMoveArm(parameters);
|
||||||
|
break;
|
||||||
|
|
||||||
case "emergency_stop":
|
case "emergency_stop":
|
||||||
this.publish("/cmd_vel", "geometry_msgs/Twist", {
|
this.publish("/cmd_vel", "geometry_msgs/Twist", {
|
||||||
linear: { x: 0, y: 0, z: 0 },
|
linear: { x: 0, y: 0, z: 0 },
|
||||||
@@ -497,7 +514,7 @@ export class WizardRosService extends EventEmitter {
|
|||||||
break;
|
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<string, unknown>): 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<string, unknown> = {},
|
||||||
|
): Promise<ServiceResponse> {
|
||||||
|
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<boolean> {
|
||||||
|
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
|
* Build template-based payload
|
||||||
*/
|
*/
|
||||||
@@ -574,11 +724,13 @@ export class WizardRosService extends EventEmitter {
|
|||||||
};
|
};
|
||||||
|
|
||||||
case "naoSpeechTransform":
|
case "naoSpeechTransform":
|
||||||
|
case "transformToStringMessage":
|
||||||
return {
|
return {
|
||||||
data: String(parameters.text || "Hello"),
|
data: String(parameters.text || "Hello"),
|
||||||
};
|
};
|
||||||
|
|
||||||
case "naoHeadTransform":
|
case "naoHeadTransform":
|
||||||
|
case "transformToHeadMovement":
|
||||||
return {
|
return {
|
||||||
joint_names: ["HeadYaw", "HeadPitch"],
|
joint_names: ["HeadYaw", "HeadPitch"],
|
||||||
joint_angles: [
|
joint_angles: [
|
||||||
@@ -588,6 +740,13 @@ export class WizardRosService extends EventEmitter {
|
|||||||
speed: Number(parameters.speed) || 0.3,
|
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:
|
default:
|
||||||
console.warn(`Unknown transform function: ${transformFn}`);
|
console.warn(`Unknown transform function: ${transformFn}`);
|
||||||
return parameters;
|
return parameters;
|
||||||
|
|||||||
@@ -4,3 +4,15 @@ import { twMerge } from "tailwind-merge"
|
|||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
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]}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { authRouter } from "~/server/api/routers/auth";
|
|||||||
import { collaborationRouter } from "~/server/api/routers/collaboration";
|
import { collaborationRouter } from "~/server/api/routers/collaboration";
|
||||||
import { dashboardRouter } from "~/server/api/routers/dashboard";
|
import { dashboardRouter } from "~/server/api/routers/dashboard";
|
||||||
import { experimentsRouter } from "~/server/api/routers/experiments";
|
import { experimentsRouter } from "~/server/api/routers/experiments";
|
||||||
|
import { filesRouter } from "~/server/api/routers/files";
|
||||||
import { mediaRouter } from "~/server/api/routers/media";
|
import { mediaRouter } from "~/server/api/routers/media";
|
||||||
import { participantsRouter } from "~/server/api/routers/participants";
|
import { participantsRouter } from "~/server/api/routers/participants";
|
||||||
import { robotsRouter } from "~/server/api/routers/robots";
|
import { robotsRouter } from "~/server/api/routers/robots";
|
||||||
@@ -25,6 +26,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
participants: participantsRouter,
|
participants: participantsRouter,
|
||||||
trials: trialsRouter,
|
trials: trialsRouter,
|
||||||
robots: robotsRouter,
|
robots: robotsRouter,
|
||||||
|
files: filesRouter,
|
||||||
media: mediaRouter,
|
media: mediaRouter,
|
||||||
analytics: analyticsRouter,
|
analytics: analyticsRouter,
|
||||||
collaboration: collaborationRouter,
|
collaboration: collaborationRouter,
|
||||||
|
|||||||
@@ -1542,6 +1542,15 @@ export const experimentsRouter = createTRPCRouter({
|
|||||||
parameters: step.conditions as Record<string, unknown>,
|
parameters: step.conditions as Record<string, unknown>,
|
||||||
parentId: undefined, // Not supported in current schema
|
parentId: undefined, // Not supported in current schema
|
||||||
children: [], // TODO: implement hierarchical steps if needed
|
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<string, unknown>,
|
||||||
|
pluginId: action.pluginId,
|
||||||
|
})),
|
||||||
}));
|
}));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
146
src/server/api/routers/files.ts
Normal file
146
src/server/api/routers/files.ts
Normal file
@@ -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.
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
wizardInterventions,
|
wizardInterventions,
|
||||||
mediaCaptures,
|
mediaCaptures,
|
||||||
users,
|
users,
|
||||||
|
annotations,
|
||||||
} from "~/server/db/schema";
|
} from "~/server/db/schema";
|
||||||
import {
|
import {
|
||||||
TrialExecutionEngine,
|
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
|
create: protectedProcedure
|
||||||
@@ -384,6 +400,58 @@ export const trialsRouter = createTRPCRouter({
|
|||||||
return trial;
|
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
|
start: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
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") {
|
if (currentTrial[0].status !== "scheduled") {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
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;
|
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
|
getEvents: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -967,4 +1095,46 @@ export const trialsRouter = createTRPCRouter({
|
|||||||
duration: result.duration,
|
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 };
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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", {
|
export const trials = createTable("trial", {
|
||||||
id: uuid("id").notNull().primaryKey().defaultRandom(),
|
id: uuid("id").notNull().primaryKey().defaultRandom(),
|
||||||
experimentId: uuid("experiment_id")
|
experimentId: uuid("experiment_id")
|
||||||
|
|||||||
@@ -507,6 +507,8 @@ export class TrialExecutionEngine {
|
|||||||
// Parse plugin.action format
|
// Parse plugin.action format
|
||||||
const [pluginName, actionId] = action.type.split(".");
|
const [pluginName, actionId] = action.type.split(".");
|
||||||
|
|
||||||
|
console.log(`[TrialExecution] Parsed action: pluginName=${pluginName}, actionId=${actionId}`);
|
||||||
|
|
||||||
if (!pluginName || !actionId) {
|
if (!pluginName || !actionId) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid robot action format: ${action.type}. Expected format: plugin.action`,
|
`Invalid robot action format: ${action.type}. Expected format: plugin.action`,
|
||||||
@@ -516,9 +518,12 @@ export class TrialExecutionEngine {
|
|||||||
// Get plugin configuration from database
|
// Get plugin configuration from database
|
||||||
const plugin = await this.getPluginDefinition(pluginName);
|
const plugin = await this.getPluginDefinition(pluginName);
|
||||||
if (!plugin) {
|
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
|
// Find action definition in plugin
|
||||||
const actionDefinition = plugin.actions?.find(
|
const actionDefinition = plugin.actions?.find(
|
||||||
(a: any) => a.id === actionId,
|
(a: any) => a.id === actionId,
|
||||||
@@ -582,14 +587,27 @@ export class TrialExecutionEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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
|
const [plugin] = await this.db
|
||||||
.select()
|
.select()
|
||||||
.from(plugins)
|
.from(plugins)
|
||||||
.where(eq(plugins.name, pluginName))
|
.where(query)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (plugin) {
|
if (plugin) {
|
||||||
// Cache the plugin definition
|
// 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 = {
|
const pluginData = {
|
||||||
...plugin,
|
...plugin,
|
||||||
actions: plugin.actionDefinitions,
|
actions: plugin.actionDefinitions,
|
||||||
@@ -597,7 +615,12 @@ export class TrialExecutionEngine {
|
|||||||
ros2Config: (plugin.metadata as any)?.ros2Config,
|
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;
|
return pluginData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user