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",
|
||||
"drizzle-orm": "^0.41.0",
|
||||
"lucide-react": "^0.536.0",
|
||||
"next": "^16.0.3",
|
||||
"minio": "^8.0.6",
|
||||
"next": "^16.0.10",
|
||||
"next-auth": "^5.0.0-beta.29",
|
||||
"postgres": "^3.4.4",
|
||||
"react": "^19.0.0",
|
||||
@@ -339,25 +340,25 @@
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
|
||||
|
||||
"@next/env": ["@next/env@16.0.3", "", {}, "sha512-IqgtY5Vwsm14mm/nmQaRMmywCU+yyMIYfk3/MHZ2ZTJvwVbBn3usZnjMi1GacrMVzVcAxJShTCpZlPs26EdEjQ=="],
|
||||
"@next/env": ["@next/env@16.0.10", "", {}, "sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang=="],
|
||||
|
||||
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.4.5", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-YhbrlbEt0m4jJnXHMY/cCUDBAWgd5SaTa5mJjzOt82QwflAFfW/h3+COp2TfVSzhmscIZ5sg2WXt3MLziqCSCw=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MOnbd92+OByu0p6QBAzq1ahVWzF6nyfiH07dQDez4/Nku7G249NjxDVyEfVhz8WkLiOEU+KFVnqtgcsfP2nLXg=="],
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.0.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4XgdKtdVsaflErz+B5XeG0T5PeXKDdruDf3CRpnhN+8UebNa5N2H58+3GDgpn/9GBurrQ1uWW768FfscwYkJRg=="],
|
||||
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-i70C4O1VmbTivYdRlk+5lj9xRc2BlK3oUikt3yJeHT1unL4LsNtN7UiOhVanFdc7vDAgZn1tV/9mQwMkWOJvHg=="],
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.0.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-spbEObMvRKkQ3CkYVOME+ocPDFo5UqHb8EMTS78/0mQ+O1nqE8toHJVioZo4TvebATxgA8XMTHHrScPrn68OGw=="],
|
||||
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-O88gCZ95sScwD00mn/AtalyCoykhhlokxH/wi1huFK+rmiP5LAYVs/i2ruk7xST6SuXN4NI5y4Xf5vepb2jf6A=="],
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.0.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-uQtWE3X0iGB8apTIskOMi2w/MKONrPOUCi5yLO+v3O8Mb5c7K4Q5KD1jvTpTF5gJKa3VH/ijKjKUq9O9UhwOYw=="],
|
||||
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-CEErFt78S/zYXzFIiv18iQCbRbLgBluS8z1TNDQoyPi8/Jr5qhR3e8XHAIxVxPBjDbEMITprqELVc5KTfFj0gg=="],
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.0.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw=="],
|
||||
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Tc3i+nwt6mQ+Dwzcri/WNDj56iWdycGVh5YwwklleClzPzz7UpfaMw1ci7bLl6GRYMXhWDBfe707EXNjKtiswQ=="],
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.0.10", "", { "os": "linux", "cpu": "x64" }, "sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA=="],
|
||||
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-zTh03Z/5PBBPdTurgEtr6nY0vI9KR9Ifp/jZCcHlODzwVOEKcKRBtQIGrkc7izFgOMuXDEJBmirwpGqdM/ZixA=="],
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.0.10", "", { "os": "linux", "cpu": "x64" }, "sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g=="],
|
||||
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.0.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-Jc1EHxtZovcJcg5zU43X3tuqzl/sS+CmLgjRP28ZT4vk869Ncm2NoF8qSTaL99gh6uOzgM99Shct06pSO6kA6g=="],
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.0.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg=="],
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-N7EJ6zbxgIYpI/sWNzpVKRMbfEGgsWuOIvzkML7wxAAZhPk1Msxuo/JDu1PKjWGrAoOLaZcIX5s+/pF5LIbBBg=="],
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.0.10", "", { "os": "win32", "cpu": "x64" }, "sha512-E+njfCoFLb01RAFEnGZn6ERoOqhK1Gl3Lfz1Kjnj0Ulfu7oJbuMyvBKNj/bw8XZnenHDASlygTjZICQW+rYW1Q=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
@@ -697,6 +698,8 @@
|
||||
|
||||
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
|
||||
|
||||
"@zxing/text-encoding": ["@zxing/text-encoding@0.9.0", "", {}, "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA=="],
|
||||
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
@@ -731,6 +734,8 @@
|
||||
|
||||
"ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="],
|
||||
|
||||
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
||||
|
||||
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
|
||||
|
||||
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
|
||||
@@ -747,14 +752,20 @@
|
||||
|
||||
"bl": ["bl@5.1.0", "", { "dependencies": { "buffer": "^6.0.3", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ=="],
|
||||
|
||||
"block-stream2": ["block-stream2@2.1.0", "", { "dependencies": { "readable-stream": "^3.4.0" } }, "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg=="],
|
||||
|
||||
"bowser": ["bowser@2.11.0", "", {}, "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
"browser-or-node": ["browser-or-node@2.1.1", "", {}, "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg=="],
|
||||
|
||||
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
||||
|
||||
"buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="],
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
|
||||
@@ -813,6 +824,8 @@
|
||||
|
||||
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
|
||||
|
||||
"decode-uri-component": ["decode-uri-component@0.2.2", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
|
||||
"defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="],
|
||||
@@ -895,6 +908,8 @@
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
|
||||
|
||||
"execa": ["execa@7.2.0", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.1", "human-signals": "^4.3.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^3.0.7", "strip-final-newline": "^3.0.0" } }, "sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
@@ -905,7 +920,7 @@
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="],
|
||||
"fast-xml-parser": ["fast-xml-parser@4.5.3", "", { "dependencies": { "strnum": "^1.1.1" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig=="],
|
||||
|
||||
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
|
||||
|
||||
@@ -917,6 +932,8 @@
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
"filter-obj": ["filter-obj@1.1.0", "", {}, "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ=="],
|
||||
|
||||
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||
|
||||
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
|
||||
@@ -989,6 +1006,10 @@
|
||||
|
||||
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
|
||||
|
||||
"is-arguments": ["is-arguments@1.2.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="],
|
||||
|
||||
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
|
||||
|
||||
"is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="],
|
||||
@@ -1109,6 +1130,8 @@
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||
|
||||
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||
|
||||
"log-symbols": ["log-symbols@5.1.0", "", { "dependencies": { "chalk": "^5.0.0", "is-unicode-supported": "^1.1.0" } }, "sha512-l0x2DvrW294C9uDCoQe1VSU4gf529FkSZ6leBl4TiqZH/e+0R7hSfHQBNut2mNygDgHwvYHfFLn6Oxb3VWj2rA=="],
|
||||
@@ -1127,12 +1150,18 @@
|
||||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="],
|
||||
|
||||
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"minio": ["minio@8.0.6", "", { "dependencies": { "async": "^3.2.4", "block-stream2": "^2.1.0", "browser-or-node": "^2.1.1", "buffer-crc32": "^1.0.0", "eventemitter3": "^5.0.1", "fast-xml-parser": "^4.4.1", "ipaddr.js": "^2.0.1", "lodash": "^4.17.21", "mime-types": "^2.1.35", "query-string": "^7.1.3", "stream-json": "^1.8.0", "through2": "^4.0.2", "web-encoding": "^1.1.5", "xml2js": "^0.5.0 || ^0.6.2" } }, "sha512-sOeh2/b/XprRmEtYsnNRFtOqNRTPDvYtMWh+spWlfsuCV/+IdxNeKVUMKLqI7b5Dr07ZqCPuaRGU/rB9pZYVdQ=="],
|
||||
|
||||
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
|
||||
|
||||
"minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="],
|
||||
@@ -1147,7 +1176,7 @@
|
||||
|
||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||
|
||||
"next": ["next@16.0.3", "", { "dependencies": { "@next/env": "16.0.3", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.0.3", "@next/swc-darwin-x64": "16.0.3", "@next/swc-linux-arm64-gnu": "16.0.3", "@next/swc-linux-arm64-musl": "16.0.3", "@next/swc-linux-x64-gnu": "16.0.3", "@next/swc-linux-x64-musl": "16.0.3", "@next/swc-win32-arm64-msvc": "16.0.3", "@next/swc-win32-x64-msvc": "16.0.3", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-Ka0/iNBblPFcIubTA1Jjh6gvwqfjrGq1Y2MTI5lbjeLIAfmC+p5bQmojpRZqgHHVu5cG4+qdIiwXiBSm/8lZ3w=="],
|
||||
"next": ["next@16.0.10", "", { "dependencies": { "@next/env": "16.0.10", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.0.10", "@next/swc-darwin-x64": "16.0.10", "@next/swc-linux-arm64-gnu": "16.0.10", "@next/swc-linux-arm64-musl": "16.0.10", "@next/swc-linux-x64-gnu": "16.0.10", "@next/swc-linux-x64-musl": "16.0.10", "@next/swc-win32-arm64-msvc": "16.0.10", "@next/swc-win32-x64-msvc": "16.0.10", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-RtWh5PUgI+vxlV3HdR+IfWA1UUHu0+Ram/JBO4vWB54cVPentCD0e+lxyAYEsDTqGGMg7qpjhKh6dc6aW7W/sA=="],
|
||||
|
||||
"next-auth": ["next-auth@5.0.0-beta.29", "", { "dependencies": { "@auth/core": "0.40.0" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "next": "^14.0.0-0 || ^15.0.0-0", "nodemailer": "^6.6.5", "react": "^18.2.0 || ^19.0.0-0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-Ukpnuk3NMc/LiOl32njZPySk7pABEzbjhMUFd5/n10I0ZNC7NCuVv8IY2JgbDek2t/PUOifQEoUiOOTLy4os5A=="],
|
||||
|
||||
@@ -1221,6 +1250,8 @@
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="],
|
||||
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
|
||||
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
|
||||
@@ -1265,6 +1296,8 @@
|
||||
|
||||
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
|
||||
|
||||
"sax": ["sax@1.4.3", "", {}, "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ=="],
|
||||
|
||||
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
|
||||
|
||||
"semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
@@ -1305,12 +1338,20 @@
|
||||
|
||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||
|
||||
"split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="],
|
||||
|
||||
"stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
|
||||
|
||||
"stdin-discarder": ["stdin-discarder@0.1.0", "", { "dependencies": { "bl": "^5.0.0" } }, "sha512-xhV7w8S+bUwlPTb4bAOUQhv8/cSS5offJuX8GQGq32ONF0ZtDWKfkdomM3HMRA+LhX6um/FZ0COqlwsjD53LeQ=="],
|
||||
|
||||
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
||||
|
||||
"stream-chain": ["stream-chain@2.2.5", "", {}, "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA=="],
|
||||
|
||||
"stream-json": ["stream-json@1.9.1", "", { "dependencies": { "stream-chain": "^2.2.5" } }, "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw=="],
|
||||
|
||||
"strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="],
|
||||
|
||||
"string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="],
|
||||
|
||||
"string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="],
|
||||
@@ -1333,7 +1374,7 @@
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||
|
||||
"strnum": ["strnum@2.1.1", "", {}, "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw=="],
|
||||
"strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="],
|
||||
|
||||
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
|
||||
|
||||
@@ -1351,6 +1392,8 @@
|
||||
|
||||
"tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="],
|
||||
|
||||
"through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
|
||||
|
||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
@@ -1395,12 +1438,16 @@
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="],
|
||||
|
||||
"util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
|
||||
|
||||
"wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
|
||||
|
||||
"web-encoding": ["web-encoding@1.1.5", "", { "dependencies": { "util": "^0.12.3" }, "optionalDependencies": { "@zxing/text-encoding": "0.9.0" } }, "sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA=="],
|
||||
|
||||
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||
|
||||
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
|
||||
@@ -1417,6 +1464,10 @@
|
||||
|
||||
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||
|
||||
"xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
|
||||
|
||||
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
|
||||
|
||||
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
@@ -1431,6 +1482,8 @@
|
||||
|
||||
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||
|
||||
"@aws-sdk/core/fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
@@ -1507,6 +1560,8 @@
|
||||
|
||||
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||
|
||||
"@aws-sdk/core/fast-xml-parser/strnum": ["strnum@2.1.1", "", {}, "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
|
||||
|
||||
@@ -17,18 +17,18 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# minio:
|
||||
# image: minio/minio
|
||||
# ports:
|
||||
# - "9000:9000" # API
|
||||
# - "9001:9001" # Console
|
||||
# environment:
|
||||
# MINIO_ROOT_USER: minioadmin
|
||||
# MINIO_ROOT_PASSWORD: minioadmin
|
||||
# volumes:
|
||||
# - minio_data:/data
|
||||
# command: server --console-address ":9001" /data
|
||||
minio:
|
||||
image: minio/minio
|
||||
ports:
|
||||
- "9000:9000" # API
|
||||
- "9001:9001" # Console
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
command: server --console-address ":9001" /data
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
# minio_data:
|
||||
minio_data:
|
||||
|
||||
@@ -62,7 +62,8 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.41.0",
|
||||
"lucide-react": "^0.536.0",
|
||||
"next": "^16.0.3",
|
||||
"minio": "^8.0.6",
|
||||
"next": "^16.0.10",
|
||||
"next-auth": "^5.0.0-beta.29",
|
||||
"postgres": "^3.4.4",
|
||||
"react": "^19.0.0",
|
||||
|
||||
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
|
||||
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 (
|
||||
<Sidebar collapsible="icon" variant="sidebar" {...props}>
|
||||
<SidebarHeader>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||
import { ArrowUpDown, MoreHorizontal, Copy, Eye, Edit, LayoutTemplate, PlayCircle, Archive } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
@@ -259,20 +259,26 @@ export const columns: ColumnDef<Experiment>[] = [
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(experiment.id)}
|
||||
>
|
||||
Copy experiment ID
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<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 asChild>
|
||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
|
||||
Edit experiment
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
|
||||
Open designer
|
||||
<LayoutTemplate className="mr-2 h-4 w-4" />
|
||||
Designer
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -280,12 +286,14 @@ export const columns: ColumnDef<Experiment>[] = [
|
||||
<Link
|
||||
href={`/studies/${experiment.studyId}/trials/new?experimentId=${experiment.id}`}
|
||||
>
|
||||
Create trial
|
||||
<PlayCircle className="mr-2 h-4 w-4" />
|
||||
Start Trial
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-red-600">
|
||||
Archive experiment
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -736,6 +736,16 @@ export function DesignerRoot({
|
||||
const targetStep = steps.find((s) => s.id === stepId);
|
||||
if (!targetStep) return;
|
||||
|
||||
const fullDef = actionRegistry.getAction(actionDef.type);
|
||||
const defaultParams: Record<string, unknown> = {};
|
||||
if (fullDef?.parameters) {
|
||||
for (const param of fullDef.parameters) {
|
||||
if (param.default !== undefined) {
|
||||
defaultParams[param.id] = param.default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const execution: ExperimentAction["execution"] =
|
||||
actionDef.execution &&
|
||||
(actionDef.execution.transport === "internal" ||
|
||||
@@ -754,7 +764,7 @@ export function DesignerRoot({
|
||||
type: actionDef.type,
|
||||
name: actionDef.name,
|
||||
category: actionDef.category as ExperimentAction["category"],
|
||||
parameters: {},
|
||||
parameters: defaultParams,
|
||||
source: actionDef.source as ExperimentAction["source"],
|
||||
execution,
|
||||
};
|
||||
@@ -818,6 +828,7 @@ export function DesignerRoot({
|
||||
description={designMeta.description || "No description"}
|
||||
icon={Play}
|
||||
actions={actions}
|
||||
className="pb-6"
|
||||
/>
|
||||
|
||||
<div className="relative flex flex-1 flex-col overflow-hidden">
|
||||
|
||||
@@ -70,7 +70,7 @@ export interface PropertiesPanelProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PropertiesPanel({
|
||||
export function PropertiesPanelBase({
|
||||
design,
|
||||
selectedStep,
|
||||
selectedAction,
|
||||
@@ -633,3 +633,5 @@ export function PropertiesPanel({
|
||||
</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="w-full px-0 py-2 break-words whitespace-normal">
|
||||
<PropertiesPanel
|
||||
design={{
|
||||
design={useMemo(
|
||||
() => ({
|
||||
id: "design",
|
||||
name: "Design",
|
||||
description: "",
|
||||
version: 1,
|
||||
steps,
|
||||
lastSaved: new Date(),
|
||||
}}
|
||||
}),
|
||||
[steps],
|
||||
)}
|
||||
selectedStep={selectedStep}
|
||||
selectedAction={selectedAction}
|
||||
onActionUpdate={handleActionUpdate}
|
||||
|
||||
@@ -645,7 +645,7 @@ export function validateExecution(
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
message:
|
||||
"Multiple steps with trial_start trigger may cause execution conflicts",
|
||||
"Multiple steps will start simultaneously. Ensure parallel execution is intended.",
|
||||
category: "execution",
|
||||
field: "trigger.type",
|
||||
stepId: step.id,
|
||||
|
||||
@@ -203,7 +203,7 @@ export function ParticipantForm({
|
||||
email: data.email ?? undefined,
|
||||
demographics,
|
||||
});
|
||||
router.push(`/participants/${newParticipant.id}`);
|
||||
router.push(`/studies/${data.studyId}/participants/${newParticipant.id}`);
|
||||
} else {
|
||||
const updatedParticipant = await updateParticipantMutation.mutateAsync({
|
||||
id: participantId!,
|
||||
@@ -212,7 +212,7 @@ export function ParticipantForm({
|
||||
email: data.email ?? undefined,
|
||||
demographics,
|
||||
});
|
||||
router.push(`/participants/${updatedParticipant.id}`);
|
||||
router.push(`/studies/${contextStudyId}/participants/${updatedParticipant.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(
|
||||
@@ -505,7 +505,8 @@ export function ParticipantForm({
|
||||
error={error}
|
||||
onDelete={mode === "edit" ? onDelete : undefined}
|
||||
isDeleting={isDeleting}
|
||||
sidebar={sidebar}
|
||||
isDeleting={isDeleting}
|
||||
// sidebar={sidebar} // Removed for cleaner UI per user request
|
||||
submitText={mode === "create" ? "Register Participant" : "Save Changes"}
|
||||
>
|
||||
{formFields}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||
import { ArrowUpDown, MoreHorizontal, Copy, Eye, Edit, Mail, Trash2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
@@ -27,6 +27,7 @@ import { api } from "~/trpc/react";
|
||||
|
||||
export type Participant = {
|
||||
id: string;
|
||||
studyId: string;
|
||||
participantCode: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
@@ -75,7 +76,7 @@ export const columns: ColumnDef<Participant>[] = [
|
||||
cell: ({ row }) => (
|
||||
<div className="font-mono text-sm">
|
||||
<Link
|
||||
href={`/participants/${row.original.id}`}
|
||||
href={`/studies/${row.original.studyId ?? ""}/participants/${row.original.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{row.getValue("participantCode")}
|
||||
@@ -176,6 +177,13 @@ export const columns: ColumnDef<Participant>[] = [
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => {
|
||||
const participant = row.original;
|
||||
// Use studyId from participant or fallback might be needed but for now presume row has it?
|
||||
// Wait, the Participant type definition above doesn't have studyId!
|
||||
// I need to add studyId to the type definition in this file or rely on context if I'm inside the component,
|
||||
// but 'columns' is defined outside.
|
||||
// Best practice: Add studyId to the Participant type.
|
||||
|
||||
const studyId = participant.studyId;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -190,23 +198,24 @@ export const columns: ColumnDef<Participant>[] = [
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(participant.id)}
|
||||
>
|
||||
Copy participant ID
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/participants/${participant.id}`}>View details</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/participants/${participant.id}/edit`}>
|
||||
<Link href={`/studies/${studyId}/participants/${participant.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit participant
|
||||
</Link >
|
||||
</DropdownMenuItem >
|
||||
{!participant.consentGiven && (
|
||||
<DropdownMenuItem>Send consent form</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem disabled>
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
Send consent
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-red-600">
|
||||
Remove participant
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent >
|
||||
</DropdownMenu >
|
||||
@@ -250,6 +259,7 @@ export function ParticipantsTable({ studyId }: ParticipantsTableProps = {}) {
|
||||
return participantsData.participants.map(
|
||||
(p): Participant => ({
|
||||
id: p.id,
|
||||
studyId: p.studyId,
|
||||
participantCode: p.participantCode,
|
||||
email: p.email,
|
||||
name: p.name,
|
||||
|
||||
@@ -161,7 +161,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
sessionNumber: data.sessionNumber ?? 1,
|
||||
notes: data.notes ?? undefined,
|
||||
});
|
||||
router.push(`/trials/${newTrial!.id}`);
|
||||
router.push(`/studies/${contextStudyId}/trials/${newTrial!.id}`);
|
||||
} else {
|
||||
const updatedTrial = await updateTrialMutation.mutateAsync({
|
||||
id: trialId!,
|
||||
@@ -170,7 +170,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
sessionNumber: data.sessionNumber ?? 1,
|
||||
notes: data.notes ?? undefined,
|
||||
});
|
||||
router.push(`/trials/${updatedTrial!.id}`);
|
||||
router.push(`/studies/${contextStudyId}/trials/${updatedTrial!.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { ArrowUpDown, ChevronDown, MoreHorizontal } from "lucide-react";
|
||||
import { ArrowUpDown, ChevronDown, MoreHorizontal, Copy, Eye, Play, Gamepad2, LineChart, Ban } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
@@ -51,27 +51,22 @@ const statusConfig = {
|
||||
scheduled: {
|
||||
label: "Scheduled",
|
||||
className: "bg-blue-100 text-blue-800",
|
||||
icon: "📅",
|
||||
},
|
||||
in_progress: {
|
||||
label: "In Progress",
|
||||
className: "bg-yellow-100 text-yellow-800",
|
||||
icon: "▶️",
|
||||
},
|
||||
completed: {
|
||||
label: "Completed",
|
||||
className: "bg-green-100 text-green-800",
|
||||
icon: "✅",
|
||||
},
|
||||
aborted: {
|
||||
label: "Aborted",
|
||||
className: "bg-gray-100 text-gray-800",
|
||||
icon: "❌",
|
||||
},
|
||||
failed: {
|
||||
label: "Failed",
|
||||
className: "bg-red-100 text-red-800",
|
||||
icon: "⚠️",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -145,7 +140,7 @@ export const columns: ColumnDef<Trial>[] = [
|
||||
<div className="max-w-[250px]">
|
||||
<div className="truncate font-medium">
|
||||
<Link
|
||||
href={`/experiments/${experimentId}`}
|
||||
href={`/studies/${row.original.studyId}/experiments/${experimentId}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{String(experimentName)}
|
||||
@@ -175,7 +170,7 @@ export const columns: ColumnDef<Trial>[] = [
|
||||
<div className="max-w-[150px]">
|
||||
{participantId ? (
|
||||
<Link
|
||||
href={`/participants/${participantId}`}
|
||||
href={`/studies/${row.original.studyId}/participants/${participantId}`}
|
||||
className="font-mono text-sm hover:underline"
|
||||
>
|
||||
{(participantCode ?? "Unknown") as string}
|
||||
@@ -232,10 +227,10 @@ export const columns: ColumnDef<Trial>[] = [
|
||||
|
||||
return (
|
||||
<Badge className={statusInfo.className}>
|
||||
<span className="mr-1">{statusInfo.icon}</span>
|
||||
{statusInfo.label}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -366,13 +361,28 @@ export const columns: ColumnDef<Trial>[] = [
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => {
|
||||
cell: ({ row }) => <ActionsCell row={row} />,
|
||||
},
|
||||
];
|
||||
|
||||
function ActionsCell({ row }: { row: { original: Trial } }) {
|
||||
const trial = row.original;
|
||||
const router = React.useMemo(() => require("next/navigation").useRouter(), []); // Dynamic import to avoid hook rules in static context? No, this component is rendered in Table.
|
||||
// Actually, hooks must be at top level. This ActionsCell will be a regular component.
|
||||
// But useRouter might fail if columns is not in component tree?
|
||||
// Table cells are rendered by flexRender in React, so they are components.
|
||||
// importing useRouter is fine.
|
||||
|
||||
const utils = api.useUtils();
|
||||
const duplicateMutation = api.trials.duplicate.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.trials.list.invalidate();
|
||||
// toast.success("Trial duplicated"); // We need toast
|
||||
},
|
||||
});
|
||||
|
||||
if (!trial?.id) {
|
||||
return (
|
||||
<span className="text-muted-foreground text-sm">No actions</span>
|
||||
);
|
||||
return <span className="text-muted-foreground text-sm">No actions</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -388,56 +398,56 @@ export const columns: ColumnDef<Trial>[] = [
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(trial.id)}
|
||||
>
|
||||
Copy trial ID
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}`}>
|
||||
View details
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Details
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{trial.status === "scheduled" && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}
|
||||
>
|
||||
Start trial
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start Trial
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{trial.status === "in_progress" && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}
|
||||
>
|
||||
Control trial
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}>
|
||||
<Gamepad2 className="mr-2 h-4 w-4" />
|
||||
Control Trial
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{trial.status === "completed" && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}`}>
|
||||
View analysis
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/analysis`}>
|
||||
<LineChart className="mr-2 h-4 w-4" />
|
||||
Analysis
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}`}>
|
||||
Edit trial
|
||||
</Link>
|
||||
<DropdownMenuItem onClick={() => duplicateMutation.mutate({ id: trial.id })}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{(trial.status === "scheduled" || trial.status === "failed") && (
|
||||
<DropdownMenuItem className="text-red-600">
|
||||
Cancel trial
|
||||
<Ban className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
interface TrialsTableProps {
|
||||
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 { Play, CheckCircle, X, Clock, AlertCircle } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
@@ -9,6 +10,12 @@ import { PanelsContainer } from "~/components/experiments/designer/layout/Panels
|
||||
import { WizardControlPanel } from "./panels/WizardControlPanel";
|
||||
import { WizardExecutionPanel } from "./panels/WizardExecutionPanel";
|
||||
import { WizardMonitoringPanel } from "./panels/WizardMonitoringPanel";
|
||||
import { WizardObservationPane } from "./panels/WizardObservationPane";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "~/components/ui/resizable";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useWizardRos } from "~/hooks/useWizardRos";
|
||||
import { toast } from "sonner";
|
||||
@@ -42,6 +49,16 @@ interface WizardInterfaceProps {
|
||||
userRole: string;
|
||||
}
|
||||
|
||||
interface ActionData {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type: string;
|
||||
parameters: Record<string, unknown>;
|
||||
order: number;
|
||||
pluginId: string | null;
|
||||
}
|
||||
|
||||
interface StepData {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -53,6 +70,7 @@ interface StepData {
|
||||
| "conditional_branch";
|
||||
parameters: Record<string, unknown>;
|
||||
order: number;
|
||||
actions: ActionData[];
|
||||
}
|
||||
|
||||
export const WizardInterface = React.memo(function WizardInterface({
|
||||
@@ -65,6 +83,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
initialTrial.startedAt ? new Date(initialTrial.startedAt) : null,
|
||||
);
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
const router = useRouter();
|
||||
|
||||
// Persistent tab states to prevent resets from parent re-renders
|
||||
const [controlPanelTab, setControlPanelTab] = useState<
|
||||
@@ -73,9 +92,16 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
const [executionPanelTab, setExecutionPanelTab] = useState<
|
||||
"current" | "timeline" | "events"
|
||||
>(trial.status === "in_progress" ? "current" : "timeline");
|
||||
const [isExecutingAction, setIsExecutingAction] = useState(false);
|
||||
const [monitoringPanelTab, setMonitoringPanelTab] = useState<
|
||||
"status" | "robot" | "events"
|
||||
>("status");
|
||||
const [completedActionsCount, setCompletedActionsCount] = useState(0);
|
||||
|
||||
// Reset completed actions when step changes
|
||||
useEffect(() => {
|
||||
setCompletedActionsCount(0);
|
||||
}, [currentStepIndex]);
|
||||
|
||||
// Get experiment steps from API
|
||||
const { data: experimentSteps } = api.experiments.getSteps.useQuery(
|
||||
@@ -100,6 +126,13 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
},
|
||||
});
|
||||
|
||||
// Log robot action mutation (for client-side execution)
|
||||
const logRobotActionMutation = api.trials.logRobotAction.useMutation({
|
||||
onError: (error) => {
|
||||
console.error("Failed to log robot action:", error);
|
||||
},
|
||||
});
|
||||
|
||||
// Map database step types to component step types
|
||||
const mapStepType = (dbType: string) => {
|
||||
switch (dbType) {
|
||||
@@ -136,6 +169,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
connect: connectRos,
|
||||
disconnect: disconnectRos,
|
||||
executeRobotAction: executeRosAction,
|
||||
setAutonomousLife,
|
||||
} = useWizardRos({
|
||||
autoConnect: true,
|
||||
onActionCompleted,
|
||||
@@ -152,6 +186,15 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
},
|
||||
);
|
||||
|
||||
// Poll for trial events
|
||||
const { data: fetchedEvents } = api.trials.getEvents.useQuery(
|
||||
{ trialId: trial.id, limit: 100 },
|
||||
{
|
||||
refetchInterval: 3000,
|
||||
staleTime: 1000,
|
||||
}
|
||||
);
|
||||
|
||||
// Update local trial state from polling
|
||||
useEffect(() => {
|
||||
if (pollingData) {
|
||||
@@ -168,7 +211,15 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
}
|
||||
}, [pollingData]);
|
||||
|
||||
// Auto-start trial on mount if scheduled
|
||||
useEffect(() => {
|
||||
if (trial.status === "scheduled") {
|
||||
handleStartTrial();
|
||||
}
|
||||
}, []); // Run once on mount
|
||||
|
||||
// Trial events from robot actions
|
||||
|
||||
const trialEvents = useMemo<
|
||||
Array<{
|
||||
type: string;
|
||||
@@ -176,7 +227,38 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
data?: unknown;
|
||||
message?: string;
|
||||
}>
|
||||
>(() => [], []);
|
||||
>(() => {
|
||||
return (fetchedEvents ?? []).map(event => {
|
||||
let message: string | undefined;
|
||||
const eventData = event.data as any;
|
||||
|
||||
// Extract or generate message based on event type
|
||||
if (event.eventType.startsWith('annotation_')) {
|
||||
message = eventData?.description || eventData?.label || 'Annotation added';
|
||||
} else if (event.eventType.startsWith('robot_action_')) {
|
||||
const actionName = event.eventType.replace('robot_action_', '').replace(/_/g, ' ');
|
||||
message = `Robot action: ${actionName}`;
|
||||
} else if (event.eventType === 'trial_started') {
|
||||
message = 'Trial started';
|
||||
} else if (event.eventType === 'trial_completed') {
|
||||
message = 'Trial completed';
|
||||
} else if (event.eventType === 'step_changed') {
|
||||
message = `Step changed to: ${eventData?.stepName || 'next step'}`;
|
||||
} else if (event.eventType.startsWith('wizard_')) {
|
||||
message = eventData?.notes || eventData?.message || event.eventType.replace('wizard_', '').replace(/_/g, ' ');
|
||||
} else {
|
||||
// Generic fallback
|
||||
message = eventData?.notes || eventData?.message || eventData?.description || event.eventType.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
return {
|
||||
type: event.eventType,
|
||||
timestamp: new Date(event.timestamp),
|
||||
data: event.data,
|
||||
message,
|
||||
};
|
||||
}).sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); // Newest first
|
||||
}, [fetchedEvents]);
|
||||
|
||||
// Transform experiment steps to component format
|
||||
const steps: StepData[] =
|
||||
@@ -187,6 +269,15 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
type: mapStepType(step.type),
|
||||
parameters: step.parameters ?? {},
|
||||
order: step.order ?? index,
|
||||
actions: step.actions?.map((action) => ({
|
||||
id: action.id,
|
||||
name: action.name,
|
||||
description: action.description,
|
||||
type: action.type,
|
||||
parameters: action.parameters ?? {},
|
||||
order: action.order,
|
||||
pluginId: action.pluginId,
|
||||
})) ?? [],
|
||||
})) ?? [];
|
||||
|
||||
const currentStep = steps[currentStepIndex] ?? null;
|
||||
@@ -261,6 +352,8 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
status: data.status,
|
||||
completedAt: data.completedAt,
|
||||
});
|
||||
toast.success("Trial completed! Redirecting to analysis...");
|
||||
router.push(`/studies/${trial.experiment.studyId}/trials/${trial.id}/analysis`);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -314,6 +407,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
|
||||
const handleNextStep = () => {
|
||||
if (currentStepIndex < steps.length - 1) {
|
||||
setCompletedActionsCount(0); // Reset immediately to prevent flickering/double-click issues
|
||||
setCurrentStepIndex(currentStepIndex + 1);
|
||||
// Note: Step transitions can be enhanced later with database logging
|
||||
}
|
||||
@@ -335,15 +429,72 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
}
|
||||
};
|
||||
|
||||
// Mutations for annotations
|
||||
const addAnnotationMutation = api.trials.addAnnotation.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Note added");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to add note", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddAnnotation = async (
|
||||
description: string,
|
||||
category?: string,
|
||||
tags?: string[],
|
||||
) => {
|
||||
await addAnnotationMutation.mutateAsync({
|
||||
trialId: trial.id,
|
||||
description,
|
||||
category,
|
||||
tags,
|
||||
});
|
||||
};
|
||||
|
||||
// Mutation for events (Acknowledge)
|
||||
const logEventMutation = api.trials.logEvent.useMutation({
|
||||
onSuccess: () => toast.success("Event logged"),
|
||||
});
|
||||
|
||||
// Mutation for interventions
|
||||
const addInterventionMutation = api.trials.addIntervention.useMutation({
|
||||
onSuccess: () => toast.success("Intervention logged"),
|
||||
});
|
||||
|
||||
const handleExecuteAction = async (
|
||||
actionId: string,
|
||||
parameters?: Record<string, unknown>,
|
||||
) => {
|
||||
try {
|
||||
console.log("Executing action:", actionId, parameters);
|
||||
|
||||
if (actionId === "acknowledge") {
|
||||
await logEventMutation.mutateAsync({
|
||||
trialId: trial.id,
|
||||
type: "wizard_acknowledge",
|
||||
data: parameters,
|
||||
});
|
||||
handleNextStep();
|
||||
} else if (actionId === "intervene") {
|
||||
await addInterventionMutation.mutateAsync({
|
||||
trialId: trial.id,
|
||||
type: "manual_intervention",
|
||||
description: "Wizard manual intervention triggered",
|
||||
data: parameters,
|
||||
});
|
||||
} else if (actionId === "note") {
|
||||
await addAnnotationMutation.mutateAsync({
|
||||
trialId: trial.id,
|
||||
description: String(parameters?.content || "Quick note"),
|
||||
category: String(parameters?.category || "quick_note")
|
||||
});
|
||||
}
|
||||
|
||||
// Note: Action execution can be enhanced later with tRPC mutations
|
||||
} catch (error) {
|
||||
console.error("Failed to execute action:", error);
|
||||
toast.error("Failed to execute action");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -352,22 +503,34 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
pluginName: string,
|
||||
actionId: string,
|
||||
parameters: Record<string, unknown>,
|
||||
options?: { autoAdvance?: boolean },
|
||||
) => {
|
||||
try {
|
||||
setIsExecutingAction(true);
|
||||
// Try direct WebSocket execution first for better performance
|
||||
if (rosConnected) {
|
||||
try {
|
||||
await executeRosAction(pluginName, actionId, parameters);
|
||||
const result = await executeRosAction(pluginName, actionId, parameters);
|
||||
|
||||
const duration =
|
||||
result.endTime && result.startTime
|
||||
? result.endTime.getTime() - result.startTime.getTime()
|
||||
: 0;
|
||||
|
||||
// Log to trial events for data capture
|
||||
await executeRobotActionMutation.mutateAsync({
|
||||
await logRobotActionMutation.mutateAsync({
|
||||
trialId: trial.id,
|
||||
pluginName,
|
||||
actionId,
|
||||
parameters,
|
||||
duration,
|
||||
result: { status: result.status },
|
||||
});
|
||||
|
||||
toast.success(`Robot action executed: ${actionId}`);
|
||||
if (options?.autoAdvance) {
|
||||
handleNextStep();
|
||||
}
|
||||
} catch (rosError) {
|
||||
console.warn(
|
||||
"WebSocket execution failed, falling back to tRPC:",
|
||||
@@ -383,6 +546,9 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
});
|
||||
|
||||
toast.success(`Robot action executed via fallback: ${actionId}`);
|
||||
if (options?.autoAdvance) {
|
||||
handleNextStep();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Use tRPC execution if WebSocket not connected
|
||||
@@ -394,17 +560,51 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
});
|
||||
|
||||
toast.success(`Robot action executed: ${actionId}`);
|
||||
if (options?.autoAdvance) {
|
||||
handleNextStep();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to execute robot action:", error);
|
||||
toast.error(`Failed to execute robot action: ${actionId}`, {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
setIsExecutingAction(false);
|
||||
}
|
||||
},
|
||||
[rosConnected, executeRosAction, executeRobotActionMutation, trial.id],
|
||||
);
|
||||
|
||||
const handleSkipAction = useCallback(
|
||||
async (
|
||||
pluginName: string,
|
||||
actionId: string,
|
||||
parameters: Record<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 (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Compact Status Bar */}
|
||||
@@ -451,10 +651,10 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* No connection status alert - ROS connection shown in monitoring panel */}
|
||||
|
||||
{/* Main Content - Three Panel Layout */}
|
||||
{/* Main Content with Vertical Resizable Split */}
|
||||
<div className="min-h-0 flex-1">
|
||||
<ResizablePanelGroup direction="vertical">
|
||||
<ResizablePanel defaultSize={75} minSize={30}>
|
||||
<PanelsContainer
|
||||
left={
|
||||
<WizardControlPanel
|
||||
@@ -474,6 +674,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
activeTab={controlPanelTab}
|
||||
onTabChange={setControlPanelTab}
|
||||
isStarting={startTrialMutation.isPending}
|
||||
onSetAutonomousLife={setAutonomousLife}
|
||||
/>
|
||||
}
|
||||
center={
|
||||
@@ -485,8 +686,15 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
trialEvents={trialEvents}
|
||||
onStepSelect={(index: number) => setCurrentStepIndex(index)}
|
||||
onExecuteAction={handleExecuteAction}
|
||||
onExecuteRobotAction={handleExecuteRobotAction}
|
||||
activeTab={executionPanelTab}
|
||||
onTabChange={setExecutionPanelTab}
|
||||
onSkipAction={handleSkipAction}
|
||||
isExecuting={isExecutingAction}
|
||||
onNextStep={handleNextStep}
|
||||
completedActionsCount={completedActionsCount}
|
||||
onActionCompleted={() => setCompletedActionsCount(c => c + 1)}
|
||||
onCompleteTrial={handleCompleteTrial}
|
||||
/>
|
||||
}
|
||||
right={
|
||||
@@ -503,6 +711,18 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
showDividers={true}
|
||||
className="h-full"
|
||||
/>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle />
|
||||
|
||||
<ResizablePanel defaultSize={25} minSize={10}>
|
||||
<WizardObservationPane
|
||||
onAddAnnotation={handleAddAnnotation}
|
||||
isSubmitting={addAnnotationMutation.isPending}
|
||||
trialEvents={trialEvents}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,8 @@ import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
@@ -35,6 +37,15 @@ interface StepData {
|
||||
| "conditional_branch";
|
||||
parameters: Record<string, unknown>;
|
||||
order: number;
|
||||
actions?: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type: string;
|
||||
parameters: Record<string, unknown>;
|
||||
order: number;
|
||||
pluginId: string | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface TrialData {
|
||||
@@ -86,6 +97,7 @@ interface WizardControlPanelProps {
|
||||
activeTab: "control" | "step" | "actions" | "robot";
|
||||
onTabChange: (tab: "control" | "step" | "actions" | "robot") => void;
|
||||
isStarting?: boolean;
|
||||
onSetAutonomousLife?: (enabled: boolean) => Promise<boolean | void>;
|
||||
}
|
||||
|
||||
export function WizardControlPanel({
|
||||
@@ -105,65 +117,28 @@ export function WizardControlPanel({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
isStarting = false,
|
||||
onSetAutonomousLife,
|
||||
}: WizardControlPanelProps) {
|
||||
const progress =
|
||||
steps.length > 0 ? ((currentStepIndex + 1) / steps.length) * 100 : 0;
|
||||
const [autonomousLife, setAutonomousLife] = React.useState(true);
|
||||
|
||||
const getStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
case "scheduled":
|
||||
return { variant: "outline" as const, icon: Clock };
|
||||
case "in_progress":
|
||||
return { variant: "default" as const, icon: Play };
|
||||
case "completed":
|
||||
return { variant: "secondary" as const, icon: CheckCircle };
|
||||
case "aborted":
|
||||
case "failed":
|
||||
return { variant: "destructive" as const, icon: X };
|
||||
default:
|
||||
return { variant: "outline" as const, icon: Clock };
|
||||
const handleAutonomousLifeChange = async (checked: boolean) => {
|
||||
setAutonomousLife(checked); // Optimistic update
|
||||
if (onSetAutonomousLife) {
|
||||
try {
|
||||
const result = await onSetAutonomousLife(checked);
|
||||
if (result === false) {
|
||||
throw new Error("Service unavailable");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to set autonomous life:", error);
|
||||
setAutonomousLife(!checked); // Revert on failure
|
||||
// Optional: Toast error?
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const statusConfig = getStatusConfig(trial.status);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
@@ -275,17 +250,36 @@ export function WizardControlPanel({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Connection Status */}
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium">Connection</div>
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs font-medium">Robot Status</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Status
|
||||
Connection
|
||||
</span>
|
||||
<Badge variant="default" className="text-xs">
|
||||
Polling
|
||||
{_isConnected ? (
|
||||
<Badge variant="default" className="bg-green-600 text-xs">
|
||||
Connected
|
||||
</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>
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
Zap,
|
||||
Eye,
|
||||
List,
|
||||
Loader2,
|
||||
ArrowRight,
|
||||
AlertTriangle,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
@@ -30,6 +34,15 @@ interface StepData {
|
||||
| "conditional_branch";
|
||||
parameters: Record<string, unknown>;
|
||||
order: number;
|
||||
actions?: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type: string;
|
||||
parameters: Record<string, unknown>;
|
||||
order: number;
|
||||
pluginId: string | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface TrialData {
|
||||
@@ -75,8 +88,25 @@ interface WizardExecutionPanelProps {
|
||||
actionId: string,
|
||||
parameters?: Record<string, unknown>,
|
||||
) => void;
|
||||
activeTab: "current" | "timeline" | "events";
|
||||
onTabChange: (tab: "current" | "timeline" | "events") => void;
|
||||
onExecuteRobotAction: (
|
||||
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({
|
||||
@@ -87,9 +117,21 @@ export function WizardExecutionPanel({
|
||||
trialEvents,
|
||||
onStepSelect,
|
||||
onExecuteAction,
|
||||
onExecuteRobotAction,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
onSkipAction,
|
||||
isExecuting = false,
|
||||
onNextStep,
|
||||
onCompleteTrial,
|
||||
completedActionsCount,
|
||||
onActionCompleted,
|
||||
}: WizardExecutionPanelProps) {
|
||||
// Local state removed in favor of parent state to prevent reset on re-render
|
||||
// const [completedCount, setCompletedCount] = React.useState(0);
|
||||
|
||||
const activeActionIndex = completedActionsCount;
|
||||
|
||||
const getStepIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "wizard_action":
|
||||
@@ -209,281 +251,228 @@ export function WizardExecutionPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabbed Content */}
|
||||
<Tabs
|
||||
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">
|
||||
{/* Simplified Content - Sequential Focus */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-2 p-3">
|
||||
{steps.map((step, index) => {
|
||||
const status = getStepStatus(index);
|
||||
const StepIcon = getStepIcon(step.type);
|
||||
const isActive = index === currentStepIndex;
|
||||
{currentStep ? (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
{/* Header Info (Simplified) */}
|
||||
<div className="space-y-4">
|
||||
<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 (
|
||||
<div
|
||||
key={step.id}
|
||||
className={`hover:bg-muted/50 flex cursor-pointer items-start gap-3 rounded-lg p-2 transition-colors ${
|
||||
isActive ? "bg-primary/5 border-primary/20 border" : ""
|
||||
}`}
|
||||
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"
|
||||
key={action.id}
|
||||
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" :
|
||||
isCompleted ? "bg-muted/30 border-transparent opacity-70" :
|
||||
"bg-card border-border opacity-50"
|
||||
}`}
|
||||
>
|
||||
{status === "completed" ? (
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
<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" :
|
||||
isActive ? "bg-transparent text-primary border-primary font-bold shadow-sm" :
|
||||
"bg-transparent text-muted-foreground border-transparent"
|
||||
}`}>
|
||||
{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>
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={`mt-1 h-4 w-0.5 ${
|
||||
status === "completed"
|
||||
? "bg-green-200"
|
||||
: "bg-border"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
{action.pluginId && isActive && (
|
||||
<div className="flex items-center gap-2">
|
||||
<StepIcon className="text-muted-foreground h-3 w-3 flex-shrink-0" />
|
||||
<div className="truncate text-sm font-medium">
|
||||
{step.name}
|
||||
</div>
|
||||
<Badge
|
||||
variant={getStepVariant(status)}
|
||||
className="ml-auto flex-shrink-0 text-xs"
|
||||
>
|
||||
{step.type.replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{step.description && (
|
||||
<p className="text-muted-foreground mt-1 line-clamp-2 text-xs">
|
||||
{step.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-9 px-3 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log("Skip clicked");
|
||||
// Fire and forget
|
||||
onSkipAction(
|
||||
action.pluginId!,
|
||||
action.type.includes(".")
|
||||
? action.type.split(".").pop()!
|
||||
: action.type,
|
||||
action.parameters || {},
|
||||
{ autoAdvance: false }
|
||||
);
|
||||
onActionCompleted();
|
||||
}}
|
||||
>
|
||||
Skip
|
||||
</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>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{/* Events Tab */}
|
||||
<TabsContent value="events" className="m-0 h-full">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-3">
|
||||
{trialEvents.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<div className="text-muted-foreground text-center text-sm">
|
||||
No events recorded yet
|
||||
{/* Manual Advance Button */}
|
||||
{activeActionIndex >= (currentStep.actions?.length || 0) && (
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={currentStepIndex === steps.length - 1 ? onCompleteTrial : onNextStep}
|
||||
className={`w-full text-white shadow-md transition-all hover:scale-[1.02] ${currentStepIndex === steps.length - 1
|
||||
? "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>
|
||||
)}
|
||||
|
||||
{/* 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 className="space-y-2">
|
||||
{trialEvents
|
||||
.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 className="flex h-full items-center justify-center text-muted-foreground">
|
||||
No active step
|
||||
</div>
|
||||
)}
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
{event.timestamp.toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</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 */}
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-8",
|
||||
layout === "default" && "grid-cols-1 lg:grid-cols-3",
|
||||
"grid gap-8 w-full",
|
||||
layout === "default" && "grid-cols-1 lg:grid-cols-3", // Keep the column split but remove max-width
|
||||
layout === "full-width" && "grid-cols-1",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as LucideIcons from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { type ReactNode } from "react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
@@ -45,10 +46,15 @@ interface EntityViewSidebarProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
|
||||
interface EntityViewProps {
|
||||
children: ReactNode;
|
||||
layout?: "default" | "full-width";
|
||||
}
|
||||
|
||||
// ... existing code ...
|
||||
|
||||
|
||||
export function EntityViewHeader({
|
||||
title,
|
||||
subtitle,
|
||||
@@ -115,8 +121,15 @@ export function EntityViewSidebar({ children }: EntityViewSidebarProps) {
|
||||
return <div className="space-y-6">{children}</div>;
|
||||
}
|
||||
|
||||
export function EntityView({ children }: EntityViewProps) {
|
||||
return <div className="space-y-6">{children}</div>;
|
||||
export function EntityView({ children, layout = "default" }: EntityViewProps) {
|
||||
// Simplification: Always take full width of the parent container provided by DashboardLayout
|
||||
// The DashboardLayout already provides padding (p-4).
|
||||
// We remove 'container mx-auto max-w-5xl' to stop it from shrinking.
|
||||
return (
|
||||
<div className="flex flex-col gap-6 w-full h-full">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Utility component for empty states
|
||||
@@ -158,8 +171,7 @@ interface InfoGridProps {
|
||||
export function InfoGrid({ items, columns = 2 }: InfoGridProps) {
|
||||
return (
|
||||
<div
|
||||
className={`grid gap-4 ${
|
||||
columns === 1
|
||||
className={`grid gap-4 ${columns === 1
|
||||
? "grid-cols-1"
|
||||
: columns === 2
|
||||
? "md:grid-cols-2"
|
||||
|
||||
@@ -39,8 +39,11 @@ export interface UseWizardRosReturn {
|
||||
};
|
||||
},
|
||||
) => Promise<RobotActionExecution>;
|
||||
callService: (service: string, args?: Record<string, unknown>) => Promise<any>;
|
||||
setAutonomousLife: (enabled: boolean) => Promise<boolean>;
|
||||
}
|
||||
|
||||
|
||||
export function useWizardRos(
|
||||
options: UseWizardRosOptions = {},
|
||||
): UseWizardRosReturn {
|
||||
@@ -288,6 +291,24 @@ export function useWizardRos(
|
||||
[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 {
|
||||
isConnected,
|
||||
isConnecting,
|
||||
@@ -297,5 +318,7 @@ export function useWizardRos(
|
||||
connect,
|
||||
disconnect,
|
||||
executeRobotAction,
|
||||
callService,
|
||||
setAutonomousLife,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,17 @@ export interface RosMessage {
|
||||
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 {
|
||||
connected: boolean;
|
||||
battery: number;
|
||||
@@ -405,7 +416,8 @@ export class WizardRosService extends EventEmitter {
|
||||
let msg: Record<string, unknown>;
|
||||
|
||||
if (
|
||||
config.payloadMapping.type === "template" &&
|
||||
(config.payloadMapping.type === "template" ||
|
||||
config.payloadMapping.type === "static") &&
|
||||
config.payloadMapping.payload
|
||||
) {
|
||||
// Template-based payload construction
|
||||
@@ -451,10 +463,15 @@ export class WizardRosService extends EventEmitter {
|
||||
this.executeMovementAction(actionId, parameters);
|
||||
break;
|
||||
|
||||
case "move_head":
|
||||
case "turn_head":
|
||||
this.executeTurnHead(parameters);
|
||||
break;
|
||||
|
||||
case "move_arm":
|
||||
this.executeMoveArm(parameters);
|
||||
break;
|
||||
|
||||
case "emergency_stop":
|
||||
this.publish("/cmd_vel", "geometry_msgs/Twist", {
|
||||
linear: { x: 0, y: 0, z: 0 },
|
||||
@@ -497,7 +514,7 @@ export class WizardRosService extends EventEmitter {
|
||||
break;
|
||||
}
|
||||
|
||||
this.publish("/cmd_vel", "geometry_msgs/Twist", { linear, angular });
|
||||
this.publish("/naoqi_driver/cmd_vel", "geometry_msgs/Twist", { linear, angular });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -515,6 +532,139 @@ export class WizardRosService extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute arm movement
|
||||
*/
|
||||
private executeMoveArm(parameters: Record<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
|
||||
*/
|
||||
@@ -574,11 +724,13 @@ export class WizardRosService extends EventEmitter {
|
||||
};
|
||||
|
||||
case "naoSpeechTransform":
|
||||
case "transformToStringMessage":
|
||||
return {
|
||||
data: String(parameters.text || "Hello"),
|
||||
};
|
||||
|
||||
case "naoHeadTransform":
|
||||
case "transformToHeadMovement":
|
||||
return {
|
||||
joint_names: ["HeadYaw", "HeadPitch"],
|
||||
joint_angles: [
|
||||
@@ -588,6 +740,13 @@ export class WizardRosService extends EventEmitter {
|
||||
speed: Number(parameters.speed) || 0.3,
|
||||
};
|
||||
|
||||
case "transformToJointAngles":
|
||||
return {
|
||||
joint_names: [String(parameters.joint_name || "HeadYaw")],
|
||||
joint_angles: [Number(parameters.angle) || 0],
|
||||
speed: Number(parameters.speed) || 0.2,
|
||||
};
|
||||
|
||||
default:
|
||||
console.warn(`Unknown transform function: ${transformFn}`);
|
||||
return parameters;
|
||||
|
||||
@@ -4,3 +4,15 @@ import { twMerge } from "tailwind-merge"
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number, decimals = 2) {
|
||||
if (!+bytes) return "0 Bytes";
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ["Bytes", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { authRouter } from "~/server/api/routers/auth";
|
||||
import { collaborationRouter } from "~/server/api/routers/collaboration";
|
||||
import { dashboardRouter } from "~/server/api/routers/dashboard";
|
||||
import { experimentsRouter } from "~/server/api/routers/experiments";
|
||||
import { filesRouter } from "~/server/api/routers/files";
|
||||
import { mediaRouter } from "~/server/api/routers/media";
|
||||
import { participantsRouter } from "~/server/api/routers/participants";
|
||||
import { robotsRouter } from "~/server/api/routers/robots";
|
||||
@@ -25,6 +26,7 @@ export const appRouter = createTRPCRouter({
|
||||
participants: participantsRouter,
|
||||
trials: trialsRouter,
|
||||
robots: robotsRouter,
|
||||
files: filesRouter,
|
||||
media: mediaRouter,
|
||||
analytics: analyticsRouter,
|
||||
collaboration: collaborationRouter,
|
||||
|
||||
@@ -1542,6 +1542,15 @@ export const experimentsRouter = createTRPCRouter({
|
||||
parameters: step.conditions as Record<string, unknown>,
|
||||
parentId: undefined, // Not supported in current schema
|
||||
children: [], // TODO: implement hierarchical steps if needed
|
||||
actions: step.actions.map((action) => ({
|
||||
id: action.id,
|
||||
name: action.name,
|
||||
description: action.description,
|
||||
type: action.type,
|
||||
order: action.orderIndex,
|
||||
parameters: action.parameters as Record<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,
|
||||
mediaCaptures,
|
||||
users,
|
||||
annotations,
|
||||
} from "~/server/db/schema";
|
||||
import {
|
||||
TrialExecutionEngine,
|
||||
@@ -263,7 +264,22 @@ export const trialsRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
return trial[0];
|
||||
// Fetch additional stats
|
||||
const eventCount = await db
|
||||
.select({ count: count() })
|
||||
.from(trialEvents)
|
||||
.where(eq(trialEvents.trialId, input.id));
|
||||
|
||||
const mediaCount = await db
|
||||
.select({ count: count() })
|
||||
.from(mediaCaptures)
|
||||
.where(eq(mediaCaptures.trialId, input.id));
|
||||
|
||||
return {
|
||||
...trial[0],
|
||||
eventCount: eventCount[0]?.count ?? 0,
|
||||
mediaCount: mediaCount[0]?.count ?? 0,
|
||||
};
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
@@ -384,6 +400,58 @@ export const trialsRouter = createTRPCRouter({
|
||||
return trial;
|
||||
}),
|
||||
|
||||
duplicate: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
await checkTrialAccess(db, userId, input.id, [
|
||||
"owner",
|
||||
"researcher",
|
||||
"wizard",
|
||||
]);
|
||||
|
||||
// Get source trial
|
||||
const sourceTrial = await db
|
||||
.select()
|
||||
.from(trials)
|
||||
.where(eq(trials.id, input.id))
|
||||
.limit(1);
|
||||
|
||||
if (!sourceTrial[0]) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Source trial not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Create new trial based on source
|
||||
const [newTrial] = await db
|
||||
.insert(trials)
|
||||
.values({
|
||||
experimentId: sourceTrial[0].experimentId,
|
||||
participantId: sourceTrial[0].participantId,
|
||||
// Scheduled for now + 1 hour by default, or null? Let's use null or source time?
|
||||
// New duplicate usually implies "planning to run soon".
|
||||
// I'll leave scheduledAt null or same as source if future?
|
||||
// Let's set it to tomorrow by default to avoid confusion
|
||||
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
wizardId: sourceTrial[0].wizardId,
|
||||
sessionNumber: (sourceTrial[0].sessionNumber || 0) + 1, // Increment session
|
||||
status: "scheduled",
|
||||
notes: `Duplicate of trial ${sourceTrial[0].id}. ${sourceTrial[0].notes || ""}`,
|
||||
metadata: sourceTrial[0].metadata,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return newTrial;
|
||||
}),
|
||||
|
||||
start: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
@@ -414,10 +482,15 @@ export const trialsRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// Idempotency: If already in progress, return success
|
||||
if (currentTrial[0].status === "in_progress") {
|
||||
return currentTrial[0];
|
||||
}
|
||||
|
||||
if (currentTrial[0].status !== "scheduled") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Trial can only be started from scheduled status",
|
||||
message: `Trial is in ${currentTrial[0].status} status and cannot be started`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -599,6 +672,61 @@ export const trialsRouter = createTRPCRouter({
|
||||
return intervention;
|
||||
}),
|
||||
|
||||
addAnnotation: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
trialId: z.string(),
|
||||
category: z.string().optional(),
|
||||
label: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
timestampStart: z.date().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
metadata: z.any().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
await checkTrialAccess(db, userId, input.trialId, [
|
||||
"owner",
|
||||
"researcher",
|
||||
"wizard",
|
||||
]);
|
||||
|
||||
const [annotation] = await db
|
||||
.insert(annotations)
|
||||
.values({
|
||||
trialId: input.trialId,
|
||||
annotatorId: userId,
|
||||
category: input.category,
|
||||
label: input.label,
|
||||
description: input.description,
|
||||
timestampStart: input.timestampStart ?? new Date(),
|
||||
tags: input.tags,
|
||||
metadata: input.metadata,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Also create a trial event so it appears in the timeline
|
||||
if (annotation) {
|
||||
await db.insert(trialEvents).values({
|
||||
trialId: input.trialId,
|
||||
eventType: `annotation_${input.category || 'note'}`,
|
||||
timestamp: input.timestampStart ?? new Date(),
|
||||
data: {
|
||||
annotationId: annotation.id,
|
||||
description: input.description,
|
||||
category: input.category,
|
||||
label: input.label,
|
||||
tags: input.tags,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return annotation;
|
||||
}),
|
||||
|
||||
getEvents: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
@@ -967,4 +1095,46 @@ export const trialsRouter = createTRPCRouter({
|
||||
duration: result.duration,
|
||||
};
|
||||
}),
|
||||
|
||||
logRobotAction: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
trialId: z.string(),
|
||||
pluginName: z.string(),
|
||||
actionId: z.string(),
|
||||
parameters: z.record(z.string(), z.unknown()).optional().default({}),
|
||||
duration: z.number().optional(),
|
||||
result: z.any().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
await checkTrialAccess(db, userId, input.trialId, [
|
||||
"owner",
|
||||
"researcher",
|
||||
"wizard",
|
||||
]);
|
||||
|
||||
await db.insert(trialEvents).values({
|
||||
trialId: input.trialId,
|
||||
eventType: "manual_robot_action",
|
||||
data: {
|
||||
userId,
|
||||
pluginName: input.pluginName,
|
||||
actionId: input.actionId,
|
||||
parameters: input.parameters,
|
||||
result: input.result,
|
||||
duration: input.duration,
|
||||
error: input.error,
|
||||
executionMode: "websocket_client",
|
||||
},
|
||||
timestamp: new Date(),
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -438,6 +438,29 @@ export const participants = createTable(
|
||||
}),
|
||||
);
|
||||
|
||||
export const participantDocuments = createTable(
|
||||
"participant_document",
|
||||
{
|
||||
id: uuid("id").notNull().primaryKey().defaultRandom(),
|
||||
participantId: uuid("participant_id")
|
||||
.notNull()
|
||||
.references(() => participants.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
type: varchar("type", { length: 100 }), // MIME type or custom category
|
||||
storagePath: text("storage_path").notNull(),
|
||||
fileSize: integer("file_size"),
|
||||
uploadedBy: uuid("uploaded_by").references(() => users.id),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
participantDocIdx: index("participant_document_participant_idx").on(
|
||||
table.participantId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const trials = createTable("trial", {
|
||||
id: uuid("id").notNull().primaryKey().defaultRandom(),
|
||||
experimentId: uuid("experiment_id")
|
||||
|
||||
@@ -507,6 +507,8 @@ export class TrialExecutionEngine {
|
||||
// Parse plugin.action format
|
||||
const [pluginName, actionId] = action.type.split(".");
|
||||
|
||||
console.log(`[TrialExecution] Parsed action: pluginName=${pluginName}, actionId=${actionId}`);
|
||||
|
||||
if (!pluginName || !actionId) {
|
||||
throw new Error(
|
||||
`Invalid robot action format: ${action.type}. Expected format: plugin.action`,
|
||||
@@ -516,9 +518,12 @@ export class TrialExecutionEngine {
|
||||
// Get plugin configuration from database
|
||||
const plugin = await this.getPluginDefinition(pluginName);
|
||||
if (!plugin) {
|
||||
throw new Error(`Plugin not found: ${pluginName}`);
|
||||
throw new Error(`Plugin '${pluginName}' not found`);
|
||||
}
|
||||
|
||||
console.log(`[TrialExecution] Plugin loaded: ${plugin.name} (ID: ${plugin.id})`);
|
||||
console.log(`[TrialExecution] Available actions: ${plugin.actions?.map((a: any) => a.id).join(", ")}`);
|
||||
|
||||
// Find action definition in plugin
|
||||
const actionDefinition = plugin.actions?.find(
|
||||
(a: any) => a.id === actionId,
|
||||
@@ -582,14 +587,27 @@ export class TrialExecutionEngine {
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if pluginName is a UUID
|
||||
const isUuid =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||
pluginName,
|
||||
);
|
||||
|
||||
const query = isUuid
|
||||
? eq(plugins.id, pluginName)
|
||||
: eq(plugins.name, pluginName);
|
||||
|
||||
const [plugin] = await this.db
|
||||
.select()
|
||||
.from(plugins)
|
||||
.where(eq(plugins.name, pluginName))
|
||||
.where(query)
|
||||
.limit(1);
|
||||
|
||||
if (plugin) {
|
||||
// Cache the plugin definition
|
||||
// Use the actual name for cache key if we looked up by ID
|
||||
const cacheKey = isUuid ? plugin.name : pluginName;
|
||||
|
||||
const pluginData = {
|
||||
...plugin,
|
||||
actions: plugin.actionDefinitions,
|
||||
@@ -597,7 +615,12 @@ export class TrialExecutionEngine {
|
||||
ros2Config: (plugin.metadata as any)?.ros2Config,
|
||||
};
|
||||
|
||||
this.pluginCache.set(pluginName, pluginData);
|
||||
this.pluginCache.set(cacheKey, pluginData);
|
||||
// Also cache by ID if accessible
|
||||
if (plugin.id) {
|
||||
this.pluginCache.set(plugin.id, pluginData);
|
||||
}
|
||||
|
||||
return pluginData;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user