mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-23 19:27:51 -04:00
feat: Implement digital signatures for participant consent and introduce study forms management.
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
"extends": [".eslintrc.cjs"],
|
extends: [".eslintrc.cjs"],
|
||||||
"rules": {
|
rules: {
|
||||||
// Only enable the rule we want to autofix
|
// Only enable the rule we want to autofix
|
||||||
"@typescript-eslint/prefer-nullish-coalescing": "error"
|
"@typescript-eslint/prefer-nullish-coalescing": "error",
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
217
bun.lock
217
bun.lock
@@ -33,8 +33,16 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@shadcn/ui": "^0.0.4",
|
"@shadcn/ui": "^0.0.4",
|
||||||
"@t3-oss/env-nextjs": "^0.13.10",
|
"@t3-oss/env-nextjs": "^0.13.10",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tiptap/extension-table": "^3.20.0",
|
||||||
|
"@tiptap/extension-table-cell": "^3.20.0",
|
||||||
|
"@tiptap/extension-table-header": "^3.20.0",
|
||||||
|
"@tiptap/extension-table-row": "^3.20.0",
|
||||||
|
"@tiptap/pm": "^3.20.0",
|
||||||
|
"@tiptap/react": "^3.20.0",
|
||||||
|
"@tiptap/starter-kit": "^3.20.0",
|
||||||
"@trpc/client": "^11.10.0",
|
"@trpc/client": "^11.10.0",
|
||||||
"@trpc/react-query": "^11.10.0",
|
"@trpc/react-query": "^11.10.0",
|
||||||
"@trpc/server": "^11.10.0",
|
"@trpc/server": "^11.10.0",
|
||||||
@@ -47,6 +55,7 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"driver.js": "^1.4.0",
|
"driver.js": "^1.4.0",
|
||||||
"drizzle-orm": "^0.41.0",
|
"drizzle-orm": "^0.41.0",
|
||||||
|
"html2pdf.js": "^0.14.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lucide-react": "^0.536.0",
|
"lucide-react": "^0.536.0",
|
||||||
"minio": "^8.0.6",
|
"minio": "^8.0.6",
|
||||||
@@ -60,11 +69,13 @@
|
|||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-hook-form": "^7.71.1",
|
"react-hook-form": "^7.71.1",
|
||||||
"react-resizable-panels": "^3.0.6",
|
"react-resizable-panels": "^3.0.6",
|
||||||
|
"react-signature-canvas": "^1.1.0-alpha.2",
|
||||||
"react-webcam": "^7.2.0",
|
"react-webcam": "^7.2.0",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"superjson": "^2.2.6",
|
"superjson": "^2.2.6",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tiptap-markdown": "^0.9.0",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.19.0",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
"zustand": "^4.5.7",
|
"zustand": "^4.5.7",
|
||||||
@@ -193,6 +204,8 @@
|
|||||||
|
|
||||||
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.3", "", {}, "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw=="],
|
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.3", "", {}, "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw=="],
|
||||||
|
|
||||||
|
"@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
|
||||||
|
|
||||||
"@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="],
|
"@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="],
|
||||||
|
|
||||||
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
|
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
|
||||||
@@ -517,6 +530,8 @@
|
|||||||
|
|
||||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
||||||
|
|
||||||
|
"@remirror/core-constants": ["@remirror/core-constants@3.0.0", "", {}, "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg=="],
|
||||||
|
|
||||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
|
||||||
|
|
||||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
|
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
|
||||||
@@ -715,6 +730,8 @@
|
|||||||
|
|
||||||
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="],
|
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="],
|
||||||
|
|
||||||
|
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="],
|
||||||
|
|
||||||
"@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
|
"@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
|
||||||
|
|
||||||
"@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="],
|
"@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="],
|
||||||
@@ -723,6 +740,70 @@
|
|||||||
|
|
||||||
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
|
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
|
||||||
|
|
||||||
|
"@tiptap/core": ["@tiptap/core@3.20.0", "", { "peerDependencies": { "@tiptap/pm": "^3.20.0" } }, "sha512-aC9aROgia/SpJqhsXFiX9TsligL8d+oeoI8W3u00WI45s0VfsqjgeKQLDLF7Tu7hC+7F02teC84SAHuup003VQ=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-blockquote": ["@tiptap/extension-blockquote@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-LQzn6aGtL4WXz2+rYshl/7/VnP2qJTpD7fWL96GXAzhqviPEY1bJES7poqJb3MU/gzl8VJUVzVzU1VoVfUKlbA=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-bold": ["@tiptap/extension-bold@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-sQklEWiyf58yDjiHtm5vmkVjfIc/cBuSusmCsQ0q9vGYnEF1iOHKhGpvnCeEXNeqF3fiJQRlquzt/6ymle3Iwg=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-bubble-menu": ["@tiptap/extension-bubble-menu@3.20.0", "", { "dependencies": { "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "@tiptap/core": "^3.20.0", "@tiptap/pm": "^3.20.0" } }, "sha512-MDosUfs8Tj+nwg8RC+wTMWGkLJORXmbR6YZgbiX4hrc7G90Gopdd6kj6ht5/T8t7dLLaX7N0+DEHdUEPGED7dw=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-bullet-list": ["@tiptap/extension-bullet-list@3.20.0", "", { "peerDependencies": { "@tiptap/extension-list": "^3.20.0" } }, "sha512-OcKMeopBbqWzhSi6o8nNz0aayogg1sfOAhto3NxJu3Ya32dwBFqmHXSYM6uW4jOphNvVPyjiq9aNRh3qTdd1dw=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-code": ["@tiptap/extension-code@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-TYDWFeSQ9umiyrqsT6VecbuhL8XIHkUhO+gEk0sVvH67ZLwjFDhAIIgWIr1/dbIGPcvMZM19E7xUUhAdIaXaOQ=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-code-block": ["@tiptap/extension-code-block@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0", "@tiptap/pm": "^3.20.0" } }, "sha512-lBbmNek14aCjrHcBcq3PRqWfNLvC6bcRa2Osc6e/LtmXlcpype4f6n+Yx+WZ+f2uUh0UmDRCz7BEyUETEsDmlQ=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-document": ["@tiptap/extension-document@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-oJfLIG3vAtZo/wg29WiBcyWt22KUgddpP8wqtCE+kY5Dw8znLR9ehNmVWlSWJA5OJUMO0ntAHx4bBT+I2MBd5w=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-dropcursor": ["@tiptap/extension-dropcursor@3.20.0", "", { "peerDependencies": { "@tiptap/extensions": "^3.20.0" } }, "sha512-d+cxplRlktVgZPwatnc34IArlppM0IFKS1J5wLk+ba1jidizsbMVh45tP/BTK2flhyfRqcNoB5R0TArhUpbkNQ=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-floating-menu": ["@tiptap/extension-floating-menu@3.20.0", "", { "peerDependencies": { "@floating-ui/dom": "^1.0.0", "@tiptap/core": "^3.20.0", "@tiptap/pm": "^3.20.0" } }, "sha512-rYs4Bv5pVjqZ/2vvR6oe7ammZapkAwN51As/WDbemvYDjfOGRqK58qGauUjYZiDzPOEIzI2mxGwsZ4eJhPW4Ig=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-gapcursor": ["@tiptap/extension-gapcursor@3.20.0", "", { "peerDependencies": { "@tiptap/extensions": "^3.20.0" } }, "sha512-P/LasfvG9/qFq43ZAlNbAnPnXC+/RJf49buTrhtFvI9Zg0+Lbpjx1oh6oMHB19T88Y28KtrckfFZ8aTSUWDq6w=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-hard-break": ["@tiptap/extension-hard-break@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-rqvhMOw4f+XQmEthncbvDjgLH6fz8L9splnKZC7OeS0eX8b0qd7+xI1u5kyxF3KA2Z0BnigES++jjWuecqV6mA=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-heading": ["@tiptap/extension-heading@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-JgJhurnCe3eN6a0lEsNQM/46R1bcwzwWWZEFDSb1P9dR8+t1/5v7cMZWsSInpD7R4/74iJn0+M5hcXLwCmBmYA=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-horizontal-rule": ["@tiptap/extension-horizontal-rule@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0", "@tiptap/pm": "^3.20.0" } }, "sha512-6uvcutFMv+9wPZgptDkbRDjAm3YVxlibmkhWD5GuaWwS9L/yUtobpI3GycujRSUZ8D3q6Q9J7LqpmQtQRTalWA=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-italic": ["@tiptap/extension-italic@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-/DhnKQF8yN8RxtuL8abZ28wd5281EaGoE2Oha35zXSOF1vNYnbyt8Ymkv/7u1BcWEWTvRPgaju0YCGXisPRLYw=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-link": ["@tiptap/extension-link@3.20.0", "", { "dependencies": { "linkifyjs": "^4.3.2" }, "peerDependencies": { "@tiptap/core": "^3.20.0", "@tiptap/pm": "^3.20.0" } }, "sha512-qI/5A+R0ZWBxo/8HxSn1uOyr7odr3xHBZ/gzOR1GUJaZqjlJxkWFX0RtXMbLKEGEvT25o345cF7b0wFznEh8qA=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-list": ["@tiptap/extension-list@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0", "@tiptap/pm": "^3.20.0" } }, "sha512-+V0/gsVWAv+7vcY0MAe6D52LYTIicMSHw00wz3ISZgprSb2yQhJ4+4gurOnUrQ4Du3AnRQvxPROaofwxIQ66WQ=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-list-item": ["@tiptap/extension-list-item@3.20.0", "", { "peerDependencies": { "@tiptap/extension-list": "^3.20.0" } }, "sha512-qEtjaaGPuqaFB4VpLrGDoIe9RHnckxPfu6d3rc22ap6TAHCDyRv05CEyJogqccnFceG/v5WN4znUBER8RWnWHA=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-list-keymap": ["@tiptap/extension-list-keymap@3.20.0", "", { "peerDependencies": { "@tiptap/extension-list": "^3.20.0" } }, "sha512-Z4GvKy04Ms4cLFN+CY6wXswd36xYsT2p/YL0V89LYFMZTerOeTjFYlndzn6svqL8NV1PRT5Diw4WTTxJSmcJPA=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-ordered-list": ["@tiptap/extension-ordered-list@3.20.0", "", { "peerDependencies": { "@tiptap/extension-list": "^3.20.0" } }, "sha512-jVKnJvrizLk7etwBMfyoj6H2GE4M+PD4k7Bwp6Bh1ohBWtfIA1TlngdS842Mx5i1VB2e3UWIwr8ZH46gl6cwMA=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-paragraph": ["@tiptap/extension-paragraph@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-mM99zK4+RnEXIMCv6akfNATAs0Iija6FgyFA9J9NZ6N4o8y9QiNLLa6HjLpAC+W+VoCgQIekyoF/Q9ftxmAYDQ=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-strike": ["@tiptap/extension-strike@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-0vcTZRRAiDfon3VM1mHBr9EFmTkkUXMhm0Xtdtn0bGe+sIqufyi+hUYTEw93EQOD9XNsPkrud6jzQNYpX2H3AQ=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-table": ["@tiptap/extension-table@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0", "@tiptap/pm": "^3.20.0" } }, "sha512-vaaMtQ2KnSSr8WVwgWf7NYNzPwrHx/6T0ekA5CxV8qNUEpXIaLXa5+tE7tJHWEdNR2KY3gUJ46D3lfOkxyFrBQ=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-table-cell": ["@tiptap/extension-table-cell@3.20.0", "", { "peerDependencies": { "@tiptap/extension-table": "^3.20.0" } }, "sha512-9Dg4zda3UWwtpBwSG7b9BeQy5oT27a/yEIBeARuxe19bloMLZgqpPRtnSrOK0OAITtVnjA+NZdKPcVLRMS2E8A=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-table-header": ["@tiptap/extension-table-header@3.20.0", "", { "peerDependencies": { "@tiptap/extension-table": "^3.20.0" } }, "sha512-2tVHHlihpeHO/gh2uU46gAX3NTGdKR+yDmfLlO2l0QAvx2TXNfNzX2pOM4MmyostW5Ko9TCWV4x0D9h3IQDhPw=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-table-row": ["@tiptap/extension-table-row@3.20.0", "", { "peerDependencies": { "@tiptap/extension-table": "^3.20.0" } }, "sha512-clkfQahkYW/U48QBh1rPZv3AWWSC9AqGKp2DLTH/SGIorM/NwI0jpPtBETMlvowyQu0ivlH9B896smEph+Do2A=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-text": ["@tiptap/extension-text@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-tf8bE8tSaOEWabCzPm71xwiUhyMFKqY9jkP5af3Kr1/F45jzZFIQAYZooHI/+zCHRrgJ99MQHKHe1ZNvODrKHQ=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-underline": ["@tiptap/extension-underline@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-LzNXuy2jwR/y+ymoUqC72TiGzbOCjioIjsDu0MNYpHuHqTWPK5aV9Mh0nbZcYFy/7fPlV1q0W139EbJeYBZEAQ=="],
|
||||||
|
|
||||||
|
"@tiptap/extensions": ["@tiptap/extensions@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0", "@tiptap/pm": "^3.20.0" } }, "sha512-HIsXX942w3nbxEQBlMAAR/aa6qiMBEP7CsSMxaxmTIVAmW35p6yUASw6GdV1u0o3lCZjXq2OSRMTskzIqi5uLg=="],
|
||||||
|
|
||||||
|
"@tiptap/pm": ["@tiptap/pm@3.20.0", "", { "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", "prosemirror-commands": "^1.6.2", "prosemirror-dropcursor": "^1.8.1", "prosemirror-gapcursor": "^1.3.2", "prosemirror-history": "^1.4.1", "prosemirror-inputrules": "^1.4.0", "prosemirror-keymap": "^1.2.2", "prosemirror-markdown": "^1.13.1", "prosemirror-menu": "^1.2.4", "prosemirror-model": "^1.24.1", "prosemirror-schema-basic": "^1.2.3", "prosemirror-schema-list": "^1.5.0", "prosemirror-state": "^1.4.3", "prosemirror-tables": "^1.6.4", "prosemirror-trailing-node": "^3.0.0", "prosemirror-transform": "^1.10.2", "prosemirror-view": "^1.38.1" } }, "sha512-jn+2KnQZn+b+VXr8EFOJKsnjVNaA4diAEr6FOazupMt8W8ro1hfpYtZ25JL87Kao/WbMze55sd8M8BDXLUKu1A=="],
|
||||||
|
|
||||||
|
"@tiptap/react": ["@tiptap/react@3.20.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "fast-equals": "^5.3.3", "use-sync-external-store": "^1.4.0" }, "optionalDependencies": { "@tiptap/extension-bubble-menu": "^3.20.0", "@tiptap/extension-floating-menu": "^3.20.0" }, "peerDependencies": { "@tiptap/core": "^3.20.0", "@tiptap/pm": "^3.20.0", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-jFLNzkmn18zqefJwPje0PPd9VhZ7Oy28YHiSvSc7YpBnQIbuN/HIxZ2lrOsKyEHta0WjRZjfU5X1pGxlbcGwOA=="],
|
||||||
|
|
||||||
|
"@tiptap/starter-kit": ["@tiptap/starter-kit@3.20.0", "", { "dependencies": { "@tiptap/core": "^3.20.0", "@tiptap/extension-blockquote": "^3.20.0", "@tiptap/extension-bold": "^3.20.0", "@tiptap/extension-bullet-list": "^3.20.0", "@tiptap/extension-code": "^3.20.0", "@tiptap/extension-code-block": "^3.20.0", "@tiptap/extension-document": "^3.20.0", "@tiptap/extension-dropcursor": "^3.20.0", "@tiptap/extension-gapcursor": "^3.20.0", "@tiptap/extension-hard-break": "^3.20.0", "@tiptap/extension-heading": "^3.20.0", "@tiptap/extension-horizontal-rule": "^3.20.0", "@tiptap/extension-italic": "^3.20.0", "@tiptap/extension-link": "^3.20.0", "@tiptap/extension-list": "^3.20.0", "@tiptap/extension-list-item": "^3.20.0", "@tiptap/extension-list-keymap": "^3.20.0", "@tiptap/extension-ordered-list": "^3.20.0", "@tiptap/extension-paragraph": "^3.20.0", "@tiptap/extension-strike": "^3.20.0", "@tiptap/extension-text": "^3.20.0", "@tiptap/extension-underline": "^3.20.0", "@tiptap/extensions": "^3.20.0", "@tiptap/pm": "^3.20.0" } }, "sha512-W4+1re35pDNY/7rpXVg+OKo/Fa4Gfrn08Bq3E3fzlJw6gjE3tYU8dY9x9vC2rK9pd9NOp7Af11qCFDaWpohXkw=="],
|
||||||
|
|
||||||
"@trpc/client": ["@trpc/client@11.10.0", "", { "peerDependencies": { "@trpc/server": "11.10.0", "typescript": ">=5.7.2" } }, "sha512-h0s2AwDtuhS8INRb4hlo4z3RKCkarWqlOy+3ffJgrlDxzzW6aLUN+9nDrcN4huPje1Em15tbCOqhIc6oaKYTRw=="],
|
"@trpc/client": ["@trpc/client@11.10.0", "", { "peerDependencies": { "@trpc/server": "11.10.0", "typescript": ">=5.7.2" } }, "sha512-h0s2AwDtuhS8INRb4hlo4z3RKCkarWqlOy+3ffJgrlDxzzW6aLUN+9nDrcN4huPje1Em15tbCOqhIc6oaKYTRw=="],
|
||||||
|
|
||||||
"@trpc/react-query": ["@trpc/react-query@11.10.0", "", { "peerDependencies": { "@tanstack/react-query": "^5.80.3", "@trpc/client": "11.10.0", "@trpc/server": "11.10.0", "react": ">=18.2.0", "typescript": ">=5.7.2" } }, "sha512-SKLpwEMU32mpDTCc3msMxb0fx113x4Jsiw0/t+ENY6AtyvhvDMRF1bpWtoNyY6zpX5wN4JCQMhHef8k0T1rJIw=="],
|
"@trpc/react-query": ["@trpc/react-query@11.10.0", "", { "peerDependencies": { "@tanstack/react-query": "^5.80.3", "@trpc/client": "11.10.0", "@trpc/server": "11.10.0", "react": ">=18.2.0", "typescript": ">=5.7.2" } }, "sha512-SKLpwEMU32mpDTCc3msMxb0fx113x4Jsiw0/t+ENY6AtyvhvDMRF1bpWtoNyY6zpX5wN4JCQMhHef8k0T1rJIw=="],
|
||||||
@@ -749,12 +830,28 @@
|
|||||||
|
|
||||||
"@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
|
"@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
|
||||||
|
|
||||||
|
"@types/linkify-it": ["@types/linkify-it@3.0.5", "", {}, "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw=="],
|
||||||
|
|
||||||
|
"@types/markdown-it": ["@types/markdown-it@13.0.9", "", { "dependencies": { "@types/linkify-it": "^3", "@types/mdurl": "^1" } }, "sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw=="],
|
||||||
|
|
||||||
|
"@types/mdurl": ["@types/mdurl@1.0.5", "", {}, "sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@20.19.33", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw=="],
|
"@types/node": ["@types/node@20.19.33", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw=="],
|
||||||
|
|
||||||
|
"@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="],
|
||||||
|
|
||||||
|
"@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="],
|
||||||
|
|
||||||
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||||
|
|
||||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||||
|
|
||||||
|
"@types/signature_pad": ["@types/signature_pad@2.3.6", "", {}, "sha512-v3j92gCQJoxomHhd+yaG4Vsf8tRS/XbzWKqDv85UsqjMGy4zhokuwKe4b6vhbgncKkh+thF+gpz6+fypTtnFqQ=="],
|
||||||
|
|
||||||
|
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||||
|
|
||||||
|
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
||||||
|
|
||||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||||
|
|
||||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.55.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/type-utils": "8.55.0", "@typescript-eslint/utils": "8.55.0", "@typescript-eslint/visitor-keys": "8.55.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.55.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ=="],
|
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.55.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/type-utils": "8.55.0", "@typescript-eslint/utils": "8.55.0", "@typescript-eslint/visitor-keys": "8.55.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.55.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ=="],
|
||||||
@@ -879,6 +976,8 @@
|
|||||||
|
|
||||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
|
"base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="],
|
||||||
|
|
||||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||||
|
|
||||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
|
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
|
||||||
@@ -915,6 +1014,8 @@
|
|||||||
|
|
||||||
"caniuse-lite": ["caniuse-lite@1.0.30001731", "", {}, "sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg=="],
|
"caniuse-lite": ["caniuse-lite@1.0.30001731", "", {}, "sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg=="],
|
||||||
|
|
||||||
|
"canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="],
|
||||||
|
|
||||||
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||||
|
|
||||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||||
@@ -943,8 +1044,16 @@
|
|||||||
|
|
||||||
"copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="],
|
"copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="],
|
||||||
|
|
||||||
|
"core-js": ["core-js@3.48.0", "", {}, "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ=="],
|
||||||
|
|
||||||
|
"crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="],
|
||||||
|
|
||||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||||
|
|
||||||
|
"css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="],
|
||||||
|
|
||||||
|
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||||
|
|
||||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
"damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
|
"damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
|
||||||
@@ -979,6 +1088,8 @@
|
|||||||
|
|
||||||
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
||||||
|
|
||||||
|
"dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="],
|
||||||
|
|
||||||
"driver.js": ["driver.js@1.4.0", "", {}, "sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew=="],
|
"driver.js": ["driver.js@1.4.0", "", {}, "sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew=="],
|
||||||
|
|
||||||
"drizzle-kit": ["drizzle-kit@0.30.6", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0", "gel": "^2.0.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g=="],
|
"drizzle-kit": ["drizzle-kit@0.30.6", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0", "gel": "^2.0.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g=="],
|
||||||
@@ -991,6 +1102,8 @@
|
|||||||
|
|
||||||
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
|
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
|
||||||
|
|
||||||
|
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||||
|
|
||||||
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
||||||
|
|
||||||
"es-abstract": ["es-abstract@1.24.0", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg=="],
|
"es-abstract": ["es-abstract@1.24.0", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg=="],
|
||||||
@@ -1061,12 +1174,16 @@
|
|||||||
|
|
||||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||||
|
|
||||||
|
"fast-equals": ["fast-equals@5.4.0", "", {}, "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="],
|
||||||
|
|
||||||
"fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="],
|
"fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="],
|
||||||
|
|
||||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||||
|
|
||||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||||
|
|
||||||
|
"fast-png": ["fast-png@6.4.0", "", { "dependencies": { "@types/pako": "^2.0.3", "iobuffer": "^5.3.2", "pako": "^2.1.0" } }, "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q=="],
|
||||||
|
|
||||||
"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=="],
|
"fast-xml-parser": ["fast-xml-parser@4.5.3", "", { "dependencies": { "strnum": "^1.1.1" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig=="],
|
||||||
|
|
||||||
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
|
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
|
||||||
@@ -1075,6 +1192,8 @@
|
|||||||
|
|
||||||
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||||
|
|
||||||
|
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
|
||||||
|
|
||||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||||
|
|
||||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||||
@@ -1139,6 +1258,10 @@
|
|||||||
|
|
||||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||||
|
|
||||||
|
"html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="],
|
||||||
|
|
||||||
|
"html2pdf.js": ["html2pdf.js@0.14.0", "", { "dependencies": { "dompurify": "^3.3.1", "html2canvas": "^1.0.0", "jspdf": "^4.0.0" } }, "sha512-yvNJgE/8yru2UeGflkPdjW8YEY+nDH5X7/2WG4uiuSCwYiCp8PZ8EKNiTAa6HxJ1NjC51fZSIEq6xld5CADKBQ=="],
|
||||||
|
|
||||||
"human-signals": ["human-signals@4.3.1", "", {}, "sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ=="],
|
"human-signals": ["human-signals@4.3.1", "", {}, "sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ=="],
|
||||||
|
|
||||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||||
@@ -1153,6 +1276,8 @@
|
|||||||
|
|
||||||
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
|
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
|
||||||
|
|
||||||
|
"iobuffer": ["iobuffer@5.4.0", "", {}, "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="],
|
||||||
|
|
||||||
"ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
|
"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-arguments": ["is-arguments@1.2.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="],
|
||||||
@@ -1243,6 +1368,8 @@
|
|||||||
|
|
||||||
"jsonfile": ["jsonfile@6.1.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ=="],
|
"jsonfile": ["jsonfile@6.1.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ=="],
|
||||||
|
|
||||||
|
"jspdf": ["jspdf@4.2.0", "", { "dependencies": { "@babel/runtime": "^7.28.6", "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", "dompurify": "^3.3.1", "html2canvas": "^1.0.0-rc.5" } }, "sha512-hR/hnRevAXXlrjeqU5oahOE+Ln9ORJUB5brLHHqH67A+RBQZuFr5GkbI9XQI8OUFSEezKegsi45QRpc4bGj75Q=="],
|
||||||
|
|
||||||
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
|
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
|
||||||
|
|
||||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||||
@@ -1279,6 +1406,10 @@
|
|||||||
|
|
||||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
|
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
|
||||||
|
|
||||||
|
"linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="],
|
||||||
|
|
||||||
|
"linkifyjs": ["linkifyjs@4.3.2", "", {}, "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA=="],
|
||||||
|
|
||||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||||
|
|
||||||
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||||
@@ -1293,8 +1424,14 @@
|
|||||||
|
|
||||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
|
"markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="],
|
||||||
|
|
||||||
|
"markdown-it-task-lists": ["markdown-it-task-lists@2.1.1", "", {}, "sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA=="],
|
||||||
|
|
||||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||||
|
|
||||||
|
"mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="],
|
||||||
|
|
||||||
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
|
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
|
||||||
|
|
||||||
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||||
@@ -1359,12 +1496,16 @@
|
|||||||
|
|
||||||
"ora": ["ora@6.3.1", "", { "dependencies": { "chalk": "^5.0.0", "cli-cursor": "^4.0.0", "cli-spinners": "^2.6.1", "is-interactive": "^2.0.0", "is-unicode-supported": "^1.1.0", "log-symbols": "^5.1.0", "stdin-discarder": "^0.1.0", "strip-ansi": "^7.0.1", "wcwidth": "^1.0.1" } }, "sha512-ERAyNnZOfqM+Ao3RAvIXkYh5joP220yf59gVe2X/cI6SiCxIdi4c9HZKZD8R6q/RDXEje1THBju6iExiSsgJaQ=="],
|
"ora": ["ora@6.3.1", "", { "dependencies": { "chalk": "^5.0.0", "cli-cursor": "^4.0.0", "cli-spinners": "^2.6.1", "is-interactive": "^2.0.0", "is-unicode-supported": "^1.1.0", "log-symbols": "^5.1.0", "stdin-discarder": "^0.1.0", "strip-ansi": "^7.0.1", "wcwidth": "^1.0.1" } }, "sha512-ERAyNnZOfqM+Ao3RAvIXkYh5joP220yf59gVe2X/cI6SiCxIdi4c9HZKZD8R6q/RDXEje1THBju6iExiSsgJaQ=="],
|
||||||
|
|
||||||
|
"orderedmap": ["orderedmap@2.1.1", "", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="],
|
||||||
|
|
||||||
"own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
|
"own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
|
||||||
|
|
||||||
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||||
|
|
||||||
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||||
|
|
||||||
|
"pako": ["pako@2.1.0", "", {}, "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="],
|
||||||
|
|
||||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||||
|
|
||||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||||
@@ -1375,6 +1516,8 @@
|
|||||||
|
|
||||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||||
|
|
||||||
|
"performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="],
|
||||||
|
|
||||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||||
@@ -1383,6 +1526,8 @@
|
|||||||
|
|
||||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||||
|
|
||||||
|
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
|
||||||
|
|
||||||
"postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
|
"postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
|
||||||
|
|
||||||
"preact": ["preact@10.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="],
|
"preact": ["preact@10.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="],
|
||||||
@@ -1399,14 +1544,54 @@
|
|||||||
|
|
||||||
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||||
|
|
||||||
|
"prosemirror-changeset": ["prosemirror-changeset@2.4.0", "", { "dependencies": { "prosemirror-transform": "^1.0.0" } }, "sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng=="],
|
||||||
|
|
||||||
|
"prosemirror-collab": ["prosemirror-collab@1.3.1", "", { "dependencies": { "prosemirror-state": "^1.0.0" } }, "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ=="],
|
||||||
|
|
||||||
|
"prosemirror-commands": ["prosemirror-commands@1.7.1", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.10.2" } }, "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w=="],
|
||||||
|
|
||||||
|
"prosemirror-dropcursor": ["prosemirror-dropcursor@1.8.2", "", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0", "prosemirror-view": "^1.1.0" } }, "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw=="],
|
||||||
|
|
||||||
|
"prosemirror-gapcursor": ["prosemirror-gapcursor@1.4.0", "", { "dependencies": { "prosemirror-keymap": "^1.0.0", "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-view": "^1.0.0" } }, "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ=="],
|
||||||
|
|
||||||
|
"prosemirror-history": ["prosemirror-history@1.5.0", "", { "dependencies": { "prosemirror-state": "^1.2.2", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.31.0", "rope-sequence": "^1.3.0" } }, "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg=="],
|
||||||
|
|
||||||
|
"prosemirror-inputrules": ["prosemirror-inputrules@1.5.1", "", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.0.0" } }, "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw=="],
|
||||||
|
|
||||||
|
"prosemirror-keymap": ["prosemirror-keymap@1.2.3", "", { "dependencies": { "prosemirror-state": "^1.0.0", "w3c-keyname": "^2.2.0" } }, "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw=="],
|
||||||
|
|
||||||
|
"prosemirror-markdown": ["prosemirror-markdown@1.13.4", "", { "dependencies": { "@types/markdown-it": "^14.0.0", "markdown-it": "^14.0.0", "prosemirror-model": "^1.25.0" } }, "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw=="],
|
||||||
|
|
||||||
|
"prosemirror-menu": ["prosemirror-menu@1.3.0", "", { "dependencies": { "crelt": "^1.0.0", "prosemirror-commands": "^1.0.0", "prosemirror-history": "^1.0.0", "prosemirror-state": "^1.0.0" } }, "sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg=="],
|
||||||
|
|
||||||
|
"prosemirror-model": ["prosemirror-model@1.25.4", "", { "dependencies": { "orderedmap": "^2.0.0" } }, "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA=="],
|
||||||
|
|
||||||
|
"prosemirror-schema-basic": ["prosemirror-schema-basic@1.2.4", "", { "dependencies": { "prosemirror-model": "^1.25.0" } }, "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ=="],
|
||||||
|
|
||||||
|
"prosemirror-schema-list": ["prosemirror-schema-list@1.5.1", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.7.3" } }, "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q=="],
|
||||||
|
|
||||||
|
"prosemirror-state": ["prosemirror-state@1.4.4", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.27.0" } }, "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw=="],
|
||||||
|
|
||||||
|
"prosemirror-tables": ["prosemirror-tables@1.8.5", "", { "dependencies": { "prosemirror-keymap": "^1.2.3", "prosemirror-model": "^1.25.4", "prosemirror-state": "^1.4.4", "prosemirror-transform": "^1.10.5", "prosemirror-view": "^1.41.4" } }, "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw=="],
|
||||||
|
|
||||||
|
"prosemirror-trailing-node": ["prosemirror-trailing-node@3.0.0", "", { "dependencies": { "@remirror/core-constants": "3.0.0", "escape-string-regexp": "^4.0.0" }, "peerDependencies": { "prosemirror-model": "^1.22.1", "prosemirror-state": "^1.4.2", "prosemirror-view": "^1.33.8" } }, "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ=="],
|
||||||
|
|
||||||
|
"prosemirror-transform": ["prosemirror-transform@1.11.0", "", { "dependencies": { "prosemirror-model": "^1.21.0" } }, "sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw=="],
|
||||||
|
|
||||||
|
"prosemirror-view": ["prosemirror-view@1.41.6", "", { "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0" } }, "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg=="],
|
||||||
|
|
||||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||||
|
|
||||||
|
"punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="],
|
||||||
|
|
||||||
"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=="],
|
"query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="],
|
||||||
|
|
||||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||||
|
|
||||||
"radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="],
|
"radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="],
|
||||||
|
|
||||||
|
"raf": ["raf@3.4.1", "", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="],
|
||||||
|
|
||||||
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||||
|
|
||||||
"react-day-picker": ["react-day-picker@9.13.2", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0", "date-fns-jalali": "^4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-IMPiXfXVIAuR5Yk58DDPBC8QKClrhdXV+Tr/alBrwrHUw0qDDYB1m5zPNuTnnPIr/gmJ4ChMxmtqPdxm8+R4Eg=="],
|
"react-day-picker": ["react-day-picker@9.13.2", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0", "date-fns-jalali": "^4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-IMPiXfXVIAuR5Yk58DDPBC8QKClrhdXV+Tr/alBrwrHUw0qDDYB1m5zPNuTnnPIr/gmJ4ChMxmtqPdxm8+R4Eg=="],
|
||||||
@@ -1423,6 +1608,8 @@
|
|||||||
|
|
||||||
"react-resizable-panels": ["react-resizable-panels@3.0.6", "", { "peerDependencies": { "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew=="],
|
"react-resizable-panels": ["react-resizable-panels@3.0.6", "", { "peerDependencies": { "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew=="],
|
||||||
|
|
||||||
|
"react-signature-canvas": ["react-signature-canvas@1.1.0-alpha.2", "", { "dependencies": { "@babel/runtime": "^7.17.9", "@types/signature_pad": "^2.3.0", "signature_pad": "^2.3.2", "trim-canvas": "^0.1.0" }, "peerDependencies": { "@types/prop-types": "^15.7.3", "@types/react": "0.14 - 19", "prop-types": "^15.5.8", "react": "0.14 - 19", "react-dom": "0.14 - 19" }, "optionalPeers": ["@types/prop-types", "@types/react"] }, "sha512-tKUNk3Gmh04Ug4K8p5g8Is08BFUKvbXxi0PyetQ/f8OgCBzcx4vqNf9+OArY/TdNdfHtswXQNRwZD6tyELjkjQ=="],
|
||||||
|
|
||||||
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||||
|
|
||||||
"react-webcam": ["react-webcam@7.2.0", "", { "peerDependencies": { "react": ">=16.2.0", "react-dom": ">=16.2.0" } }, "sha512-xkrzYPqa1ag2DP+2Q/kLKBmCIfEx49bVdgCCCcZf88oF+0NPEbkwYk3/s/C7Zy0mhM8k+hpdNkBLzxg8H0aWcg=="],
|
"react-webcam": ["react-webcam@7.2.0", "", { "peerDependencies": { "react": ">=16.2.0", "react-dom": ">=16.2.0" } }, "sha512-xkrzYPqa1ag2DP+2Q/kLKBmCIfEx49bVdgCCCcZf88oF+0NPEbkwYk3/s/C7Zy0mhM8k+hpdNkBLzxg8H0aWcg=="],
|
||||||
@@ -1431,6 +1618,8 @@
|
|||||||
|
|
||||||
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
||||||
|
|
||||||
|
"regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="],
|
||||||
|
|
||||||
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
|
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
|
||||||
|
|
||||||
"resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
|
"resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
|
||||||
@@ -1443,8 +1632,12 @@
|
|||||||
|
|
||||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||||
|
|
||||||
|
"rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="],
|
||||||
|
|
||||||
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
|
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
|
||||||
|
|
||||||
|
"rope-sequence": ["rope-sequence@1.3.4", "", {}, "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="],
|
||||||
|
|
||||||
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||||
|
|
||||||
"safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
|
"safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
|
||||||
@@ -1489,6 +1682,8 @@
|
|||||||
|
|
||||||
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||||
|
|
||||||
|
"signature_pad": ["signature_pad@2.3.2", "", {}, "sha512-peYXLxOsIY6MES2TrRLDiNg2T++8gGbpP2yaC+6Ohtxr+a2dzoaqWosWDY9sWqTAAk6E/TyQO+LJw9zQwyu5kA=="],
|
||||||
|
|
||||||
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
|
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
|
||||||
|
|
||||||
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
|
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
|
||||||
@@ -1505,6 +1700,8 @@
|
|||||||
|
|
||||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||||
|
|
||||||
|
"stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="],
|
||||||
|
|
||||||
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
||||||
|
|
||||||
"stdin-discarder": ["stdin-discarder@0.1.0", "", { "dependencies": { "bl": "^5.0.0" } }, "sha512-xhV7w8S+bUwlPTb4bAOUQhv8/cSS5offJuX8GQGq32ONF0ZtDWKfkdomM3HMRA+LhX6um/FZ0COqlwsjD53LeQ=="],
|
"stdin-discarder": ["stdin-discarder@0.1.0", "", { "dependencies": { "bl": "^5.0.0" } }, "sha512-xhV7w8S+bUwlPTb4bAOUQhv8/cSS5offJuX8GQGq32ONF0ZtDWKfkdomM3HMRA+LhX6um/FZ0COqlwsjD53LeQ=="],
|
||||||
@@ -1549,12 +1746,16 @@
|
|||||||
|
|
||||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||||
|
|
||||||
|
"svg-pathdata": ["svg-pathdata@6.0.3", "", {}, "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw=="],
|
||||||
|
|
||||||
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
||||||
|
|
||||||
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||||
|
|
||||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||||
|
|
||||||
|
"text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="],
|
||||||
|
|
||||||
"through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="],
|
"through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="],
|
||||||
|
|
||||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||||
@@ -1565,8 +1766,12 @@
|
|||||||
|
|
||||||
"tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
|
"tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
|
||||||
|
|
||||||
|
"tiptap-markdown": ["tiptap-markdown@0.9.0", "", { "dependencies": { "@types/markdown-it": "^13.0.7", "markdown-it": "^14.1.0", "markdown-it-task-lists": "^2.1.1", "prosemirror-markdown": "^1.11.1" }, "peerDependencies": { "@tiptap/core": "^3.0.1" } }, "sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ=="],
|
||||||
|
|
||||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||||
|
|
||||||
|
"trim-canvas": ["trim-canvas@0.1.2", "", {}, "sha512-nd4Ga3iLFV94mdhW9JFMLpQbHUyCQuhFOD71PEAt1NjtMD5wbZctzhX8c3agHNybMR5zXD1XTGoIEWk995E6pQ=="],
|
||||||
|
|
||||||
"ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
|
"ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
|
||||||
|
|
||||||
"ts-unused-exports": ["ts-unused-exports@11.0.1", "", { "dependencies": { "chalk": "^4.0.0", "tsconfig-paths": "^3.9.0" }, "peerDependencies": { "typescript": ">=3.8.3" }, "bin": { "ts-unused-exports": "bin/ts-unused-exports" } }, "sha512-b1uIe0B8YfNZjeb+bx62LrB6qaO4CHT8SqMVBkwbwLj7Nh0xQ4J8uV0dS9E6AABId0U4LQ+3yB/HXZBMslGn2A=="],
|
"ts-unused-exports": ["ts-unused-exports@11.0.1", "", { "dependencies": { "chalk": "^4.0.0", "tsconfig-paths": "^3.9.0" }, "peerDependencies": { "typescript": ">=3.8.3" }, "bin": { "ts-unused-exports": "bin/ts-unused-exports" } }, "sha512-b1uIe0B8YfNZjeb+bx62LrB6qaO4CHT8SqMVBkwbwLj7Nh0xQ4J8uV0dS9E6AABId0U4LQ+3yB/HXZBMslGn2A=="],
|
||||||
@@ -1591,6 +1796,8 @@
|
|||||||
|
|
||||||
"typescript-eslint": ["typescript-eslint@8.55.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.55.0", "@typescript-eslint/parser": "8.55.0", "@typescript-eslint/typescript-estree": "8.55.0", "@typescript-eslint/utils": "8.55.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw=="],
|
"typescript-eslint": ["typescript-eslint@8.55.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.55.0", "@typescript-eslint/parser": "8.55.0", "@typescript-eslint/typescript-estree": "8.55.0", "@typescript-eslint/utils": "8.55.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw=="],
|
||||||
|
|
||||||
|
"uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="],
|
||||||
|
|
||||||
"unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
|
"unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||||
@@ -1611,10 +1818,14 @@
|
|||||||
|
|
||||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|
||||||
|
"utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="],
|
||||||
|
|
||||||
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
||||||
|
|
||||||
"vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="],
|
"vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="],
|
||||||
|
|
||||||
|
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
|
||||||
|
|
||||||
"wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
|
"wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
|
||||||
|
|
||||||
"web-encoding": ["web-encoding@1.1.5", "", { "dependencies": { "util": "^0.12.3" }, "optionalDependencies": { "@zxing/text-encoding": "0.9.0" } }, "sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA=="],
|
"web-encoding": ["web-encoding@1.1.5", "", { "dependencies": { "util": "^0.12.3" }, "optionalDependencies": { "@zxing/text-encoding": "0.9.0" } }, "sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA=="],
|
||||||
@@ -1781,6 +1992,8 @@
|
|||||||
|
|
||||||
"ora/chalk": ["chalk@5.2.0", "", {}, "sha512-ree3Gqw/nazQAPuJJEy+avdl7QfZMcUvmHIKgEZkGL+xOBzRvup5Hxo6LHuMceSxOabuJLJm5Yp/92R9eMmMvA=="],
|
"ora/chalk": ["chalk@5.2.0", "", {}, "sha512-ree3Gqw/nazQAPuJJEy+avdl7QfZMcUvmHIKgEZkGL+xOBzRvup5Hxo6LHuMceSxOabuJLJm5Yp/92R9eMmMvA=="],
|
||||||
|
|
||||||
|
"prosemirror-markdown/@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="],
|
||||||
|
|
||||||
"radix-ui/@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g=="],
|
"radix-ui/@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g=="],
|
||||||
|
|
||||||
"radix-ui/@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="],
|
"radix-ui/@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="],
|
||||||
@@ -1879,6 +2092,10 @@
|
|||||||
|
|
||||||
"eslint-import-resolver-typescript/tinyglobby/fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="],
|
"eslint-import-resolver-typescript/tinyglobby/fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="],
|
||||||
|
|
||||||
|
"prosemirror-markdown/@types/markdown-it/@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="],
|
||||||
|
|
||||||
|
"prosemirror-markdown/@types/markdown-it/@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="],
|
||||||
|
|
||||||
"restore-cursor/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
|
"restore-cursor/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
|
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -51,8 +51,16 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@shadcn/ui": "^0.0.4",
|
"@shadcn/ui": "^0.0.4",
|
||||||
"@t3-oss/env-nextjs": "^0.13.10",
|
"@t3-oss/env-nextjs": "^0.13.10",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tiptap/extension-table": "^3.20.0",
|
||||||
|
"@tiptap/extension-table-cell": "^3.20.0",
|
||||||
|
"@tiptap/extension-table-header": "^3.20.0",
|
||||||
|
"@tiptap/extension-table-row": "^3.20.0",
|
||||||
|
"@tiptap/pm": "^3.20.0",
|
||||||
|
"@tiptap/react": "^3.20.0",
|
||||||
|
"@tiptap/starter-kit": "^3.20.0",
|
||||||
"@trpc/client": "^11.10.0",
|
"@trpc/client": "^11.10.0",
|
||||||
"@trpc/react-query": "^11.10.0",
|
"@trpc/react-query": "^11.10.0",
|
||||||
"@trpc/server": "^11.10.0",
|
"@trpc/server": "^11.10.0",
|
||||||
@@ -65,6 +73,7 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"driver.js": "^1.4.0",
|
"driver.js": "^1.4.0",
|
||||||
"drizzle-orm": "^0.41.0",
|
"drizzle-orm": "^0.41.0",
|
||||||
|
"html2pdf.js": "^0.14.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lucide-react": "^0.536.0",
|
"lucide-react": "^0.536.0",
|
||||||
"minio": "^8.0.6",
|
"minio": "^8.0.6",
|
||||||
@@ -78,11 +87,13 @@
|
|||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-hook-form": "^7.71.1",
|
"react-hook-form": "^7.71.1",
|
||||||
"react-resizable-panels": "^3.0.6",
|
"react-resizable-panels": "^3.0.6",
|
||||||
|
"react-signature-canvas": "^1.1.0-alpha.2",
|
||||||
"react-webcam": "^7.2.0",
|
"react-webcam": "^7.2.0",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"superjson": "^2.2.6",
|
"superjson": "^2.2.6",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tiptap-markdown": "^0.9.0",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.19.0",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
"zustand": "^4.5.7"
|
"zustand": "^4.5.7"
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
import * as schema from "../../src/server/db/schema";
|
import * as schema from "../../src/server/db/schema";
|
||||||
@@ -12,14 +11,15 @@ async function main() {
|
|||||||
console.log("🔍 Checking seeded actions...");
|
console.log("🔍 Checking seeded actions...");
|
||||||
|
|
||||||
const actions = await db.query.actions.findMany({
|
const actions = await db.query.actions.findMany({
|
||||||
where: (actions, { or, eq, like }) => or(
|
where: (actions, { or, eq, like }) =>
|
||||||
|
or(
|
||||||
eq(actions.type, "sequence"),
|
eq(actions.type, "sequence"),
|
||||||
eq(actions.type, "parallel"),
|
eq(actions.type, "parallel"),
|
||||||
eq(actions.type, "loop"),
|
eq(actions.type, "loop"),
|
||||||
eq(actions.type, "branch"),
|
eq(actions.type, "branch"),
|
||||||
like(actions.type, "hristudio-core%")
|
like(actions.type, "hristudio-core%"),
|
||||||
),
|
),
|
||||||
limit: 10
|
limit: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Found ${actions.length} control actions.`);
|
console.log(`Found ${actions.length} control actions.`);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
import * as schema from "../../src/server/db/schema";
|
import * as schema from "../../src/server/db/schema";
|
||||||
@@ -27,7 +26,7 @@ async function main() {
|
|||||||
console.log(` - Action Definitions: ${defs?.length ?? 0} found.`);
|
console.log(` - Action Definitions: ${defs?.length ?? 0} found.`);
|
||||||
|
|
||||||
if (defs && meta?.robotId) {
|
if (defs && meta?.robotId) {
|
||||||
defs.forEach(d => {
|
defs.forEach((d) => {
|
||||||
const key = `${meta.robotId}.${d.id}`;
|
const key = `${meta.robotId}.${d.id}`;
|
||||||
expectedKeys.add(key);
|
expectedKeys.add(key);
|
||||||
// console.log(` -> Registers: ${key}`);
|
// console.log(` -> Registers: ${key}`);
|
||||||
@@ -41,13 +40,13 @@ async function main() {
|
|||||||
let errorCount = 0;
|
let errorCount = 0;
|
||||||
for (const a of actions) {
|
for (const a of actions) {
|
||||||
// Only check plugin actions
|
// Only check plugin actions
|
||||||
if (a.sourceKind === 'plugin' || a.type.includes(".")) {
|
if (a.sourceKind === "plugin" || a.type.includes(".")) {
|
||||||
const isRegistered = expectedKeys.has(a.type);
|
const isRegistered = expectedKeys.has(a.type);
|
||||||
const pluginIdMatch = a.pluginId === 'nao6-ros2';
|
const pluginIdMatch = a.pluginId === "nao6-ros2";
|
||||||
|
|
||||||
console.log(`Action [${a.name}] (Type: ${a.type}):`);
|
console.log(`Action [${a.name}] (Type: ${a.type}):`);
|
||||||
console.log(` - PluginId: ${a.pluginId} ${pluginIdMatch ? '✅' : '❌'}`);
|
console.log(` - PluginId: ${a.pluginId} ${pluginIdMatch ? "✅" : "❌"}`);
|
||||||
console.log(` - In Registry: ${isRegistered ? '✅' : '❌'}`);
|
console.log(` - In Registry: ${isRegistered ? "✅" : "❌"}`);
|
||||||
|
|
||||||
if (!isRegistered || !pluginIdMatch) errorCount++;
|
if (!isRegistered || !pluginIdMatch) errorCount++;
|
||||||
}
|
}
|
||||||
@@ -56,7 +55,9 @@ async function main() {
|
|||||||
if (errorCount > 0) {
|
if (errorCount > 0) {
|
||||||
console.log(`\n❌ Found ${errorCount} actions with issues.`);
|
console.log(`\n❌ Found ${errorCount} actions with issues.`);
|
||||||
} else {
|
} else {
|
||||||
console.log("\n✅ All plugin actions validated successfully against registry definitions.");
|
console.log(
|
||||||
|
"\n✅ All plugin actions validated successfully against registry definitions.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await connection.end();
|
await connection.end();
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { steps, experiments, actions } from "~/server/db/schema";
|
import { steps, experiments, actions } from "~/server/db/schema";
|
||||||
import { eq, asc } from "drizzle-orm";
|
import { eq, asc } from "drizzle-orm";
|
||||||
@@ -15,10 +14,10 @@ async function debugExperimentStructure() {
|
|||||||
with: {
|
with: {
|
||||||
actions: {
|
actions: {
|
||||||
orderBy: [asc(actions.orderIndex)],
|
orderBy: [asc(actions.orderIndex)],
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!experiment) {
|
if (!experiment) {
|
||||||
@@ -41,8 +40,11 @@ async function debugExperimentStructure() {
|
|||||||
console.log(` Actions (${step.actions.length}):`);
|
console.log(` Actions (${step.actions.length}):`);
|
||||||
step.actions.forEach((action, actionIndex) => {
|
step.actions.forEach((action, actionIndex) => {
|
||||||
console.log(` ${actionIndex + 1}. [${action.type}] ${action.name}`);
|
console.log(` ${actionIndex + 1}. [${action.type}] ${action.name}`);
|
||||||
if (action.type === 'wizard_wait_for_response') {
|
if (action.type === "wizard_wait_for_response") {
|
||||||
console.log(` Options:`, JSON.stringify((action.parameters as any)?.options, null, 2));
|
console.log(
|
||||||
|
` Options:`,
|
||||||
|
JSON.stringify((action.parameters as any)?.options, null, 2),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { db } from "../../src/server/db";
|
import { db } from "../../src/server/db";
|
||||||
import { experiments, steps } from "../../src/server/db/schema";
|
import { experiments, steps } from "../../src/server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
@@ -14,9 +13,9 @@ async function inspectAllSteps() {
|
|||||||
type: true,
|
type: true,
|
||||||
orderIndex: true,
|
orderIndex: true,
|
||||||
conditions: true,
|
conditions: true,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Found ${result.length} experiments.`);
|
console.log(`Found ${result.length} experiments.`);
|
||||||
@@ -25,12 +24,12 @@ async function inspectAllSteps() {
|
|||||||
console.log(`Experiment: ${exp.name} (${exp.id})`);
|
console.log(`Experiment: ${exp.name} (${exp.id})`);
|
||||||
for (const step of exp.steps) {
|
for (const step of exp.steps) {
|
||||||
// Only print conditional steps or the first step
|
// Only print conditional steps or the first step
|
||||||
if (step.type === 'conditional' || step.orderIndex === 0) {
|
if (step.type === "conditional" || step.orderIndex === 0) {
|
||||||
console.log(` [${step.orderIndex}] ${step.name} (${step.type})`);
|
console.log(` [${step.orderIndex}] ${step.name} (${step.type})`);
|
||||||
console.log(` Conditions: ${JSON.stringify(step.conditions)}`);
|
console.log(` Conditions: ${JSON.stringify(step.conditions)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log('---');
|
console.log("---");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { actions, steps } from "~/server/db/schema";
|
import { actions, steps } from "~/server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
@@ -16,10 +15,10 @@ async function inspectAction() {
|
|||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
type: true,
|
type: true,
|
||||||
conditions: true
|
conditions: true,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!action) {
|
if (!action) {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { steps } from "~/server/db/schema";
|
import { steps } from "~/server/db/schema";
|
||||||
import { eq, inArray } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
@@ -10,10 +9,10 @@ async function inspectBranchSteps() {
|
|||||||
const step5Id = "3ae2fe8a-fc5d-4a04-baa5-699a21f19e30";
|
const step5Id = "3ae2fe8a-fc5d-4a04-baa5-699a21f19e30";
|
||||||
|
|
||||||
const branchSteps = await db.query.steps.findMany({
|
const branchSteps = await db.query.steps.findMany({
|
||||||
where: inArray(steps.id, [step4Id, step5Id])
|
where: inArray(steps.id, [step4Id, step5Id]),
|
||||||
});
|
});
|
||||||
|
|
||||||
branchSteps.forEach(step => {
|
branchSteps.forEach((step) => {
|
||||||
console.log(`Step: ${step.name} (${step.id})`);
|
console.log(`Step: ${step.name} (${step.id})`);
|
||||||
console.log(` Type: ${step.type}`);
|
console.log(` Type: ${step.type}`);
|
||||||
console.log(` Order: ${step.orderIndex}`);
|
console.log(` Order: ${step.orderIndex}`);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
|
||||||
import { db } from "../../src/server/db";
|
import { db } from "../../src/server/db";
|
||||||
import { steps } from "../../src/server/db/schema";
|
import { steps } from "../../src/server/db/schema";
|
||||||
import { eq, like } from "drizzle-orm";
|
import { eq, like } from "drizzle-orm";
|
||||||
|
|
||||||
async function checkSteps() {
|
async function checkSteps() {
|
||||||
const allSteps = await db.select().from(steps).where(like(steps.name, "%Comprehension Check%"));
|
const allSteps = await db
|
||||||
|
.select()
|
||||||
|
.from(steps)
|
||||||
|
.where(like(steps.name, "%Comprehension Check%"));
|
||||||
|
|
||||||
console.log("Found steps:", allSteps.length);
|
console.log("Found steps:", allSteps.length);
|
||||||
|
|
||||||
@@ -12,7 +14,10 @@ async function checkSteps() {
|
|||||||
console.log("Step Name:", step.name);
|
console.log("Step Name:", step.name);
|
||||||
console.log("Type:", step.type);
|
console.log("Type:", step.type);
|
||||||
console.log("Conditions (typeof):", typeof step.conditions);
|
console.log("Conditions (typeof):", typeof step.conditions);
|
||||||
console.log("Conditions (value):", JSON.stringify(step.conditions, null, 2));
|
console.log(
|
||||||
|
"Conditions (value):",
|
||||||
|
JSON.stringify(step.conditions, null, 2),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
|
|
||||||
|
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { steps, experiments } from "~/server/db/schema";
|
import { steps, experiments } from "~/server/db/schema";
|
||||||
import { eq, asc } from "drizzle-orm";
|
import { eq, asc } from "drizzle-orm";
|
||||||
@@ -7,7 +5,7 @@ import { eq, asc } from "drizzle-orm";
|
|||||||
async function inspectExperimentSteps() {
|
async function inspectExperimentSteps() {
|
||||||
// Find experiment by ID
|
// Find experiment by ID
|
||||||
const experiment = await db.query.experiments.findFirst({
|
const experiment = await db.query.experiments.findFirst({
|
||||||
where: eq(experiments.id, "961d0cb1-256d-4951-8387-6d855a0ae603")
|
where: eq(experiments.id, "961d0cb1-256d-4951-8387-6d855a0ae603"),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!experiment) {
|
if (!experiment) {
|
||||||
@@ -22,9 +20,9 @@ async function inspectExperimentSteps() {
|
|||||||
orderBy: [asc(steps.orderIndex)],
|
orderBy: [asc(steps.orderIndex)],
|
||||||
with: {
|
with: {
|
||||||
actions: {
|
actions: {
|
||||||
orderBy: (actions, { asc }) => [asc(actions.orderIndex)]
|
orderBy: (actions, { asc }) => [asc(actions.orderIndex)],
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Found ${experimentSteps.length} steps.`);
|
console.log(`Found ${experimentSteps.length} steps.`);
|
||||||
@@ -35,17 +33,21 @@ async function inspectExperimentSteps() {
|
|||||||
console.log(`Name: ${step.name}`);
|
console.log(`Name: ${step.name}`);
|
||||||
console.log(`Type: ${step.type}`);
|
console.log(`Type: ${step.type}`);
|
||||||
|
|
||||||
|
if (step.type === "conditional") {
|
||||||
if (step.type === 'conditional') {
|
|
||||||
console.log("Conditions:", JSON.stringify(step.conditions, null, 2));
|
console.log("Conditions:", JSON.stringify(step.conditions, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (step.actions.length > 0) {
|
if (step.actions.length > 0) {
|
||||||
console.log("Actions:");
|
console.log("Actions:");
|
||||||
for (const action of step.actions) {
|
for (const action of step.actions) {
|
||||||
console.log(` - [${action.orderIndex}] ${action.name} (${action.type})`);
|
console.log(
|
||||||
if (action.type === 'wizard_wait_for_response') {
|
` - [${action.orderIndex}] ${action.name} (${action.type})`,
|
||||||
console.log(" Parameters:", JSON.stringify(action.parameters, null, 2));
|
);
|
||||||
|
if (action.type === "wizard_wait_for_response") {
|
||||||
|
console.log(
|
||||||
|
" Parameters:",
|
||||||
|
JSON.stringify(action.parameters, null, 2),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,4 +60,3 @@ inspectExperimentSteps()
|
|||||||
console.error(err);
|
console.error(err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { db } from "../../src/server/db";
|
import { db } from "../../src/server/db";
|
||||||
import { experiments } from "../../src/server/db/schema";
|
import { experiments } from "../../src/server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { actions, steps } from "~/server/db/schema";
|
import { actions, steps } from "~/server/db/schema";
|
||||||
import { eq, sql } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
@@ -12,7 +11,7 @@ async function patchActionParams() {
|
|||||||
|
|
||||||
// 1. Get the authoritative conditions from the Step
|
// 1. Get the authoritative conditions from the Step
|
||||||
const step = await db.query.steps.findFirst({
|
const step = await db.query.steps.findFirst({
|
||||||
where: eq(steps.id, step3CondId)
|
where: eq(steps.id, step3CondId),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!step) {
|
if (!step) {
|
||||||
@@ -28,11 +27,14 @@ async function patchActionParams() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Found rich options in Step:", JSON.stringify(richOptions, null, 2));
|
console.log(
|
||||||
|
"Found rich options in Step:",
|
||||||
|
JSON.stringify(richOptions, null, 2),
|
||||||
|
);
|
||||||
|
|
||||||
// 2. Get the Action
|
// 2. Get the Action
|
||||||
const action = await db.query.actions.findFirst({
|
const action = await db.query.actions.findFirst({
|
||||||
where: eq(actions.id, actionId)
|
where: eq(actions.id, actionId),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!action) {
|
if (!action) {
|
||||||
@@ -40,14 +42,17 @@ async function patchActionParams() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Current Action Parameters:", JSON.stringify(action.parameters, null, 2));
|
console.log(
|
||||||
|
"Current Action Parameters:",
|
||||||
|
JSON.stringify(action.parameters, null, 2),
|
||||||
|
);
|
||||||
|
|
||||||
// 3. Patch the Action Parameters
|
// 3. Patch the Action Parameters
|
||||||
// We replace the simple string options with the rich object options
|
// We replace the simple string options with the rich object options
|
||||||
const currentParams = action.parameters as any;
|
const currentParams = action.parameters as any;
|
||||||
const newParams = {
|
const newParams = {
|
||||||
...currentParams,
|
...currentParams,
|
||||||
options: richOptions // Overwrite with rich options from step
|
options: richOptions, // Overwrite with rich options from step
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("New Action Parameters:", JSON.stringify(newParams, null, 2));
|
console.log("New Action Parameters:", JSON.stringify(newParams, null, 2));
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { steps } from "~/server/db/schema";
|
import { steps } from "~/server/db/schema";
|
||||||
import { eq, sql } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
@@ -15,7 +14,7 @@ async function patchBranchSteps() {
|
|||||||
// Update Step 3 (The Conditional Step)
|
// Update Step 3 (The Conditional Step)
|
||||||
console.log("Updating Step 3 (Conditional Step)...");
|
console.log("Updating Step 3 (Conditional Step)...");
|
||||||
const step3Conditional = await db.query.steps.findFirst({
|
const step3Conditional = await db.query.steps.findFirst({
|
||||||
where: eq(steps.id, step3CondId)
|
where: eq(steps.id, step3CondId),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (step3Conditional) {
|
if (step3Conditional) {
|
||||||
@@ -25,7 +24,8 @@ async function patchBranchSteps() {
|
|||||||
// Patch options to point to real step IDs
|
// Patch options to point to real step IDs
|
||||||
const newOptions = options.map((opt: any) => {
|
const newOptions = options.map((opt: any) => {
|
||||||
if (opt.value === "Correct") return { ...opt, nextStepId: stepBranchAId };
|
if (opt.value === "Correct") return { ...opt, nextStepId: stepBranchAId };
|
||||||
if (opt.value === "Incorrect") return { ...opt, nextStepId: stepBranchBId };
|
if (opt.value === "Incorrect")
|
||||||
|
return { ...opt, nextStepId: stepBranchBId };
|
||||||
return opt;
|
return opt;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,12 +50,16 @@ async function patchBranchSteps() {
|
|||||||
It should jump to Conclusion (cc3fbc7f...)
|
It should jump to Conclusion (cc3fbc7f...)
|
||||||
*/
|
*/
|
||||||
const stepBranchA = await db.query.steps.findFirst({
|
const stepBranchA = await db.query.steps.findFirst({
|
||||||
where: eq(steps.id, stepBranchAId)
|
where: eq(steps.id, stepBranchAId),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (stepBranchA) {
|
if (stepBranchA) {
|
||||||
const currentConditions = (stepBranchA.conditions as Record<string, unknown>) || {};
|
const currentConditions =
|
||||||
const newConditions = { ...currentConditions, nextStepId: stepConclusionId };
|
(stepBranchA.conditions as Record<string, unknown>) || {};
|
||||||
|
const newConditions = {
|
||||||
|
...currentConditions,
|
||||||
|
nextStepId: stepConclusionId,
|
||||||
|
};
|
||||||
|
|
||||||
await db.execute(sql`
|
await db.execute(sql`
|
||||||
UPDATE hs_step
|
UPDATE hs_step
|
||||||
@@ -68,12 +72,16 @@ async function patchBranchSteps() {
|
|||||||
// Update Step 5 (Branch B)
|
// Update Step 5 (Branch B)
|
||||||
console.log("Updating Step 5 (Branch B)...");
|
console.log("Updating Step 5 (Branch B)...");
|
||||||
const stepBranchB = await db.query.steps.findFirst({
|
const stepBranchB = await db.query.steps.findFirst({
|
||||||
where: eq(steps.id, stepBranchBId)
|
where: eq(steps.id, stepBranchBId),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (stepBranchB) {
|
if (stepBranchB) {
|
||||||
const currentConditions = (stepBranchB.conditions as Record<string, unknown>) || {};
|
const currentConditions =
|
||||||
const newConditions = { ...currentConditions, nextStepId: stepConclusionId };
|
(stepBranchB.conditions as Record<string, unknown>) || {};
|
||||||
|
const newConditions = {
|
||||||
|
...currentConditions,
|
||||||
|
nextStepId: stepConclusionId,
|
||||||
|
};
|
||||||
|
|
||||||
await db.execute(sql`
|
await db.execute(sql`
|
||||||
UPDATE hs_step
|
UPDATE hs_step
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { convertDatabaseToSteps } from "../../src/lib/experiment-designer/block-converter";
|
import { convertDatabaseToSteps } from "../../src/lib/experiment-designer/block-converter";
|
||||||
import { type ExperimentStep } from "../../src/lib/experiment-designer/types";
|
import { type ExperimentStep } from "../../src/lib/experiment-designer/types";
|
||||||
|
|
||||||
@@ -16,13 +15,23 @@ const mockDbSteps = [
|
|||||||
type: "sequence",
|
type: "sequence",
|
||||||
parameters: {
|
parameters: {
|
||||||
children: [
|
children: [
|
||||||
{ id: "child-1", name: "Child 1", type: "wait", parameters: { duration: 1 } },
|
{
|
||||||
{ id: "child-2", name: "Child 2", type: "wait", parameters: { duration: 2 } }
|
id: "child-1",
|
||||||
]
|
name: "Child 1",
|
||||||
}
|
type: "wait",
|
||||||
}
|
parameters: { duration: 1 },
|
||||||
]
|
},
|
||||||
}
|
{
|
||||||
|
id: "child-2",
|
||||||
|
name: "Child 2",
|
||||||
|
type: "wait",
|
||||||
|
parameters: { duration: 2 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Mock Store Logic (simulating store.ts)
|
// Mock Store Logic (simulating store.ts)
|
||||||
@@ -67,7 +76,9 @@ if (!clonedSeq) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Cloned Children Count: ${clonedSeq.children?.length ?? "undefined"}`);
|
console.log(
|
||||||
|
`Cloned Children Count: ${clonedSeq.children?.length ?? "undefined"}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (clonedSeq.children?.length === 2) {
|
if (clonedSeq.children?.length === 2) {
|
||||||
console.log("✅ SUCCESS: Data hydrated and cloned correctly.");
|
console.log("✅ SUCCESS: Data hydrated and cloned correctly.");
|
||||||
|
|||||||
@@ -14,32 +14,42 @@ async function main() {
|
|||||||
try {
|
try {
|
||||||
// 1. Find Admin User & Study
|
// 1. Find Admin User & Study
|
||||||
const user = await db.query.users.findFirst({
|
const user = await db.query.users.findFirst({
|
||||||
where: (users, { eq }) => eq(users.email, "sean@soconnor.dev")
|
where: (users, { eq }) => eq(users.email, "sean@soconnor.dev"),
|
||||||
});
|
});
|
||||||
if (!user) throw new Error("Admin user 'sean@soconnor.dev' not found. Run seed-dev.ts first.");
|
if (!user)
|
||||||
|
throw new Error(
|
||||||
|
"Admin user 'sean@soconnor.dev' not found. Run seed-dev.ts first.",
|
||||||
|
);
|
||||||
|
|
||||||
const study = await db.query.studies.findFirst({
|
const study = await db.query.studies.findFirst({
|
||||||
where: (studies, { eq }) => eq(studies.name, "Comparative WoZ Study")
|
where: (studies, { eq }) => eq(studies.name, "Comparative WoZ Study"),
|
||||||
});
|
});
|
||||||
if (!study) throw new Error("Study 'Comparative WoZ Study' not found. Run seed-dev.ts first.");
|
if (!study)
|
||||||
|
throw new Error(
|
||||||
|
"Study 'Comparative WoZ Study' not found. Run seed-dev.ts first.",
|
||||||
|
);
|
||||||
|
|
||||||
// Find Robot
|
// Find Robot
|
||||||
const robot = await db.query.robots.findFirst({
|
const robot = await db.query.robots.findFirst({
|
||||||
where: (robots, { eq }) => eq(robots.name, "NAO6")
|
where: (robots, { eq }) => eq(robots.name, "NAO6"),
|
||||||
});
|
});
|
||||||
if (!robot) throw new Error("Robot 'NAO6' not found. Run seed-dev.ts first.");
|
if (!robot)
|
||||||
|
throw new Error("Robot 'NAO6' not found. Run seed-dev.ts first.");
|
||||||
|
|
||||||
// 2. Create Experiment
|
// 2. Create Experiment
|
||||||
const [experiment] = await db.insert(schema.experiments).values({
|
const [experiment] = await db
|
||||||
|
.insert(schema.experiments)
|
||||||
|
.values({
|
||||||
studyId: study.id,
|
studyId: study.id,
|
||||||
name: "Control Flow Demo",
|
name: "Control Flow Demo",
|
||||||
description: "Demonstration of enhanced control flow actions: Sequence, Parallel, Wait, Loop, Branch.",
|
description:
|
||||||
|
"Demonstration of enhanced control flow actions: Sequence, Parallel, Wait, Loop, Branch.",
|
||||||
version: 1,
|
version: 1,
|
||||||
status: "draft",
|
status: "draft",
|
||||||
robotId: robot.id,
|
robotId: robot.id,
|
||||||
createdBy: user.id,
|
createdBy: user.id,
|
||||||
}).returning();
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
if (!experiment) throw new Error("Failed to create experiment");
|
if (!experiment) throw new Error("Failed to create experiment");
|
||||||
console.log(`✅ Created Experiment: ${experiment.id}`);
|
console.log(`✅ Created Experiment: ${experiment.id}`);
|
||||||
@@ -47,26 +57,32 @@ async function main() {
|
|||||||
// 3. Create Steps
|
// 3. Create Steps
|
||||||
|
|
||||||
// Step 1: Sequence & Parallel
|
// Step 1: Sequence & Parallel
|
||||||
const [step1] = await db.insert(schema.steps).values({
|
const [step1] = await db
|
||||||
|
.insert(schema.steps)
|
||||||
|
.values({
|
||||||
experimentId: experiment.id,
|
experimentId: experiment.id,
|
||||||
name: "Complex Action Structures",
|
name: "Complex Action Structures",
|
||||||
description: "Demonstrating Sequence and Parallel groups",
|
description: "Demonstrating Sequence and Parallel groups",
|
||||||
type: "robot",
|
type: "robot",
|
||||||
orderIndex: 0,
|
orderIndex: 0,
|
||||||
required: true,
|
required: true,
|
||||||
durationEstimate: 30
|
durationEstimate: 30,
|
||||||
}).returning();
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
// Step 2: Loops & Waits
|
// Step 2: Loops & Waits
|
||||||
const [step2] = await db.insert(schema.steps).values({
|
const [step2] = await db
|
||||||
|
.insert(schema.steps)
|
||||||
|
.values({
|
||||||
experimentId: experiment.id,
|
experimentId: experiment.id,
|
||||||
name: "Repetition & Delays",
|
name: "Repetition & Delays",
|
||||||
description: "Demonstrating Loop and Wait actions",
|
description: "Demonstrating Loop and Wait actions",
|
||||||
type: "robot",
|
type: "robot",
|
||||||
orderIndex: 1,
|
orderIndex: 1,
|
||||||
required: true,
|
required: true,
|
||||||
durationEstimate: 45
|
durationEstimate: 45,
|
||||||
}).returning();
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
// 4. Create Actions
|
// 4. Create Actions
|
||||||
|
|
||||||
@@ -111,7 +127,6 @@ async function main() {
|
|||||||
// I will insert a Parallel action with embedded children in the `children` column (if it exists) or `parameters`.
|
// I will insert a Parallel action with embedded children in the `children` column (if it exists) or `parameters`.
|
||||||
// Re-reading `scripts/seed-dev.ts`: It doesn't show any nested actions.
|
// Re-reading `scripts/seed-dev.ts`: It doesn't show any nested actions.
|
||||||
// I will read `src/server/db/schema.ts` to be sure.
|
// I will read `src/server/db/schema.ts` to be sure.
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
import * as schema from "../../src/server/db/schema";
|
import * as schema from "../../src/server/db/schema";
|
||||||
@@ -16,32 +15,42 @@ async function main() {
|
|||||||
try {
|
try {
|
||||||
// 1. Find Admin User & Study
|
// 1. Find Admin User & Study
|
||||||
const user = await db.query.users.findFirst({
|
const user = await db.query.users.findFirst({
|
||||||
where: (users, { eq }) => eq(users.email, "sean@soconnor.dev")
|
where: (users, { eq }) => eq(users.email, "sean@soconnor.dev"),
|
||||||
});
|
});
|
||||||
if (!user) throw new Error("Admin user 'sean@soconnor.dev' not found. Run seed-dev.ts first.");
|
if (!user)
|
||||||
|
throw new Error(
|
||||||
|
"Admin user 'sean@soconnor.dev' not found. Run seed-dev.ts first.",
|
||||||
|
);
|
||||||
|
|
||||||
const study = await db.query.studies.findFirst({
|
const study = await db.query.studies.findFirst({
|
||||||
where: (studies, { eq }) => eq(studies.name, "Comparative WoZ Study")
|
where: (studies, { eq }) => eq(studies.name, "Comparative WoZ Study"),
|
||||||
});
|
});
|
||||||
if (!study) throw new Error("Study 'Comparative WoZ Study' not found. Run seed-dev.ts first.");
|
if (!study)
|
||||||
|
throw new Error(
|
||||||
|
"Study 'Comparative WoZ Study' not found. Run seed-dev.ts first.",
|
||||||
|
);
|
||||||
|
|
||||||
// Find Robot
|
// Find Robot
|
||||||
const robot = await db.query.robots.findFirst({
|
const robot = await db.query.robots.findFirst({
|
||||||
where: (robots, { eq }) => eq(robots.name, "NAO6")
|
where: (robots, { eq }) => eq(robots.name, "NAO6"),
|
||||||
});
|
});
|
||||||
if (!robot) throw new Error("Robot 'NAO6' not found. Run seed-dev.ts first.");
|
if (!robot)
|
||||||
|
throw new Error("Robot 'NAO6' not found. Run seed-dev.ts first.");
|
||||||
|
|
||||||
// 2. Create Experiment
|
// 2. Create Experiment
|
||||||
const [experiment] = await db.insert(schema.experiments).values({
|
const [experiment] = await db
|
||||||
|
.insert(schema.experiments)
|
||||||
|
.values({
|
||||||
studyId: study.id,
|
studyId: study.id,
|
||||||
name: "Control Flow Demo",
|
name: "Control Flow Demo",
|
||||||
description: "Demonstration of enhanced control flow actions: Sequence, Parallel, Wait, Loop, Branch.",
|
description:
|
||||||
|
"Demonstration of enhanced control flow actions: Sequence, Parallel, Wait, Loop, Branch.",
|
||||||
version: 1,
|
version: 1,
|
||||||
status: "draft",
|
status: "draft",
|
||||||
robotId: robot.id,
|
robotId: robot.id,
|
||||||
createdBy: user.id,
|
createdBy: user.id,
|
||||||
}).returning();
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
if (!experiment) throw new Error("Failed to create experiment");
|
if (!experiment) throw new Error("Failed to create experiment");
|
||||||
console.log(`✅ Created Experiment: ${experiment.id}`);
|
console.log(`✅ Created Experiment: ${experiment.id}`);
|
||||||
@@ -49,27 +58,33 @@ async function main() {
|
|||||||
// 3. Create Steps
|
// 3. Create Steps
|
||||||
|
|
||||||
// Step 1: Sequence & Parallel
|
// Step 1: Sequence & Parallel
|
||||||
const [step1] = await db.insert(schema.steps).values({
|
const [step1] = await db
|
||||||
|
.insert(schema.steps)
|
||||||
|
.values({
|
||||||
experimentId: experiment.id,
|
experimentId: experiment.id,
|
||||||
name: "Complex Action Structures",
|
name: "Complex Action Structures",
|
||||||
description: "Demonstrating Sequence and Parallel groups",
|
description: "Demonstrating Sequence and Parallel groups",
|
||||||
type: "robot",
|
type: "robot",
|
||||||
orderIndex: 0,
|
orderIndex: 0,
|
||||||
required: true,
|
required: true,
|
||||||
durationEstimate: 30
|
durationEstimate: 30,
|
||||||
}).returning();
|
})
|
||||||
|
.returning();
|
||||||
if (!step1) throw new Error("Failed to create step1");
|
if (!step1) throw new Error("Failed to create step1");
|
||||||
|
|
||||||
// Step 2: Loops & Waits
|
// Step 2: Loops & Waits
|
||||||
const [step2] = await db.insert(schema.steps).values({
|
const [step2] = await db
|
||||||
|
.insert(schema.steps)
|
||||||
|
.values({
|
||||||
experimentId: experiment.id,
|
experimentId: experiment.id,
|
||||||
name: "Repetition & Delays",
|
name: "Repetition & Delays",
|
||||||
description: "Demonstrating Loop and Wait actions",
|
description: "Demonstrating Loop and Wait actions",
|
||||||
type: "robot",
|
type: "robot",
|
||||||
orderIndex: 1,
|
orderIndex: 1,
|
||||||
required: true,
|
required: true,
|
||||||
durationEstimate: 45
|
durationEstimate: 45,
|
||||||
}).returning();
|
})
|
||||||
|
.returning();
|
||||||
if (!step2) throw new Error("Failed to create step2");
|
if (!step2) throw new Error("Failed to create step2");
|
||||||
|
|
||||||
// 4. Create Actions
|
// 4. Create Actions
|
||||||
@@ -108,20 +123,20 @@ async function main() {
|
|||||||
name: "Say Hello",
|
name: "Say Hello",
|
||||||
type: "nao6-ros2.say_text",
|
type: "nao6-ros2.say_text",
|
||||||
parameters: { text: "Hello there!" },
|
parameters: { text: "Hello there!" },
|
||||||
category: "interaction"
|
category: "interaction",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
name: "Wave Hand",
|
name: "Wave Hand",
|
||||||
type: "nao6-ros2.move_arm",
|
type: "nao6-ros2.move_arm",
|
||||||
parameters: { arm: "right", action: "wave" },
|
parameters: { arm: "right", action: "wave" },
|
||||||
category: "movement"
|
category: "movement",
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
pluginId: "hristudio-core",
|
pluginId: "hristudio-core",
|
||||||
category: "control",
|
category: "control",
|
||||||
sourceKind: "core"
|
sourceKind: "core",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parallel
|
// Parallel
|
||||||
@@ -137,23 +152,22 @@ async function main() {
|
|||||||
name: "Say 'Moving'",
|
name: "Say 'Moving'",
|
||||||
type: "nao6-ros2.say_text",
|
type: "nao6-ros2.say_text",
|
||||||
parameters: { text: "I am moving and talking." },
|
parameters: { text: "I am moving and talking." },
|
||||||
category: "interaction"
|
category: "interaction",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
name: "Walk Forward",
|
name: "Walk Forward",
|
||||||
type: "nao6-ros2.move_to",
|
type: "nao6-ros2.move_to",
|
||||||
parameters: { x: 0.5, y: 0 },
|
parameters: { x: 0.5, y: 0 },
|
||||||
category: "movement"
|
category: "movement",
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
pluginId: "hristudio-core",
|
pluginId: "hristudio-core",
|
||||||
category: "control",
|
category: "control",
|
||||||
sourceKind: "core"
|
sourceKind: "core",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// --- Step 2 Actions ---
|
// --- Step 2 Actions ---
|
||||||
|
|
||||||
// Loop
|
// Loop
|
||||||
@@ -170,13 +184,13 @@ async function main() {
|
|||||||
name: "Say 'Echo'",
|
name: "Say 'Echo'",
|
||||||
type: "nao6-ros2.say_text",
|
type: "nao6-ros2.say_text",
|
||||||
parameters: { text: "Echo" },
|
parameters: { text: "Echo" },
|
||||||
category: "interaction"
|
category: "interaction",
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
pluginId: "hristudio-core",
|
pluginId: "hristudio-core",
|
||||||
category: "control",
|
category: "control",
|
||||||
sourceKind: "core"
|
sourceKind: "core",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait
|
// Wait
|
||||||
@@ -188,7 +202,7 @@ async function main() {
|
|||||||
parameters: { duration: 5 },
|
parameters: { duration: 5 },
|
||||||
pluginId: "hristudio-core",
|
pluginId: "hristudio-core",
|
||||||
category: "control",
|
category: "control",
|
||||||
sourceKind: "core"
|
sourceKind: "core",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Branch (Controls step routing, not nested actions)
|
// Branch (Controls step routing, not nested actions)
|
||||||
@@ -205,11 +219,12 @@ async function main() {
|
|||||||
},
|
},
|
||||||
pluginId: "hristudio-core",
|
pluginId: "hristudio-core",
|
||||||
category: "control",
|
category: "control",
|
||||||
sourceKind: "core"
|
sourceKind: "core",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update step2 to have conditional routing
|
// Update step2 to have conditional routing
|
||||||
await db.update(schema.steps)
|
await db
|
||||||
|
.update(schema.steps)
|
||||||
.set({
|
.set({
|
||||||
type: "conditional",
|
type: "conditional",
|
||||||
conditions: {
|
conditions: {
|
||||||
@@ -217,19 +232,17 @@ async function main() {
|
|||||||
{
|
{
|
||||||
label: "High Score Path",
|
label: "High Score Path",
|
||||||
nextStepIndex: 2, // Would go to a hypothetical step 3
|
nextStepIndex: 2, // Would go to a hypothetical step 3
|
||||||
variant: "default"
|
variant: "default",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Low Score Path",
|
label: "Low Score Path",
|
||||||
nextStepIndex: 0, // Loop back to step 1
|
nextStepIndex: 0, // Loop back to step 1
|
||||||
variant: "outline"
|
variant: "outline",
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
.where(sql`id = ${step2.id}`);
|
.where(sql`id = ${step2.id}`);
|
||||||
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -1,32 +1,31 @@
|
|||||||
|
|
||||||
// Mock of the logic in WizardInterface.tsx handleNextStep
|
// Mock of the logic in WizardInterface.tsx handleNextStep
|
||||||
const steps = [
|
const steps = [
|
||||||
{
|
{
|
||||||
id: "b9d43f8c-c40c-4f1c-9fdc-9076338d3c85",
|
id: "b9d43f8c-c40c-4f1c-9fdc-9076338d3c85",
|
||||||
name: "Step 3 (Conditional)",
|
name: "Step 3 (Conditional)",
|
||||||
order: 2
|
order: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "3a2dc0b7-a43e-4236-9b9e-f957abafc1e5",
|
id: "3a2dc0b7-a43e-4236-9b9e-f957abafc1e5",
|
||||||
name: "Step 4 (Branch A)",
|
name: "Step 4 (Branch A)",
|
||||||
order: 3,
|
order: 3,
|
||||||
conditions: {
|
conditions: {
|
||||||
"nextStepId": "cc3fbc7f-29e5-45e0-8d46-e80813c54292"
|
nextStepId: "cc3fbc7f-29e5-45e0-8d46-e80813c54292",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "3ae2fe8a-fc5d-4a04-baa5-699a21f19e30",
|
id: "3ae2fe8a-fc5d-4a04-baa5-699a21f19e30",
|
||||||
name: "Step 5 (Branch B)",
|
name: "Step 5 (Branch B)",
|
||||||
order: 4,
|
order: 4,
|
||||||
conditions: {
|
conditions: {
|
||||||
"nextStepId": "cc3fbc7f-29e5-45e0-8d46-e80813c54292"
|
nextStepId: "cc3fbc7f-29e5-45e0-8d46-e80813c54292",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "cc3fbc7f-29e5-45e0-8d46-e80813c54292",
|
id: "cc3fbc7f-29e5-45e0-8d46-e80813c54292",
|
||||||
name: "Step 6 (Conclusion)",
|
name: "Step 6 (Conclusion)",
|
||||||
order: 5
|
order: 5,
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function simulateNextStep(currentStepIndex: number) {
|
function simulateNextStep(currentStepIndex: number) {
|
||||||
@@ -41,23 +40,32 @@ function simulateNextStep(currentStepIndex: number) {
|
|||||||
console.log("Current Step Data:", JSON.stringify(currentStep, null, 2));
|
console.log("Current Step Data:", JSON.stringify(currentStep, null, 2));
|
||||||
|
|
||||||
// Logic from WizardInterface.tsx
|
// Logic from WizardInterface.tsx
|
||||||
console.log("[WizardInterface] Checking for nextStepId condition:", currentStep?.conditions);
|
console.log(
|
||||||
|
"[WizardInterface] Checking for nextStepId condition:",
|
||||||
|
currentStep?.conditions,
|
||||||
|
);
|
||||||
|
|
||||||
if (currentStep?.conditions?.nextStepId) {
|
if (currentStep?.conditions?.nextStepId) {
|
||||||
const nextId = String(currentStep.conditions.nextStepId);
|
const nextId = String(currentStep.conditions.nextStepId);
|
||||||
const targetIndex = steps.findIndex(s => s.id === nextId);
|
const targetIndex = steps.findIndex((s) => s.id === nextId);
|
||||||
|
|
||||||
console.log(`Target ID: ${nextId}`);
|
console.log(`Target ID: ${nextId}`);
|
||||||
console.log(`Target Index Found: ${targetIndex}`);
|
console.log(`Target Index Found: ${targetIndex}`);
|
||||||
|
|
||||||
if (targetIndex !== -1) {
|
if (targetIndex !== -1) {
|
||||||
console.log(`[WizardInterface] Condition-based jump to step ${targetIndex} (${nextId})`);
|
console.log(
|
||||||
|
`[WizardInterface] Condition-based jump to step ${targetIndex} (${nextId})`,
|
||||||
|
);
|
||||||
return targetIndex;
|
return targetIndex;
|
||||||
} else {
|
} else {
|
||||||
console.warn(`[WizardInterface] Targeted nextStepId ${nextId} not found in steps list.`);
|
console.warn(
|
||||||
|
`[WizardInterface] Targeted nextStepId ${nextId} not found in steps list.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log("[WizardInterface] No nextStepId found in conditions, proceeding linearly.");
|
console.log(
|
||||||
|
"[WizardInterface] No nextStepId found in conditions, proceeding linearly.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: Linear progression
|
// Default: Linear progression
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { convertDatabaseToAction } from "../../src/lib/experiment-designer/block-converter";
|
import { convertDatabaseToAction } from "../../src/lib/experiment-designer/block-converter";
|
||||||
|
|
||||||
const mockDbAction = {
|
const mockDbAction = {
|
||||||
@@ -8,27 +7,27 @@ const mockDbAction = {
|
|||||||
type: "sequence",
|
type: "sequence",
|
||||||
orderIndex: 0,
|
orderIndex: 0,
|
||||||
parameters: {
|
parameters: {
|
||||||
"children": [
|
children: [
|
||||||
{
|
{
|
||||||
"id": "75018b01-a964-41fb-8612-940a29020d4a",
|
id: "75018b01-a964-41fb-8612-940a29020d4a",
|
||||||
"name": "Say Hello",
|
name: "Say Hello",
|
||||||
"type": "nao6-ros2.say_text",
|
type: "nao6-ros2.say_text",
|
||||||
"category": "interaction",
|
category: "interaction",
|
||||||
"parameters": {
|
parameters: {
|
||||||
"text": "Hello there!"
|
text: "Hello there!",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "d7020530-6477-41f3-84a4-5141778c93da",
|
id: "d7020530-6477-41f3-84a4-5141778c93da",
|
||||||
"name": "Wave Hand",
|
name: "Wave Hand",
|
||||||
"type": "nao6-ros2.move_arm",
|
type: "nao6-ros2.move_arm",
|
||||||
"category": "movement",
|
category: "movement",
|
||||||
"parameters": {
|
parameters: {
|
||||||
"arm": "right",
|
arm: "right",
|
||||||
"action": "wave"
|
action: "wave",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
timeout: null,
|
timeout: null,
|
||||||
retryCount: 0,
|
retryCount: 0,
|
||||||
@@ -42,7 +41,7 @@ const mockDbAction = {
|
|||||||
ros2: null,
|
ros2: null,
|
||||||
rest: null,
|
rest: null,
|
||||||
retryable: null,
|
retryable: null,
|
||||||
parameterSchemaRaw: null
|
parameterSchemaRaw: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("Testing convertDatabaseToAction...");
|
console.log("Testing convertDatabaseToAction...");
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { appRouter } from "../../src/server/api/root";
|
import { appRouter } from "../../src/server/api/root";
|
||||||
import { createCallerFactory } from "../../src/server/api/trpc";
|
import { createCallerFactory } from "../../src/server/api/trpc";
|
||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
@@ -16,9 +15,9 @@ const mockSession = {
|
|||||||
user: {
|
user: {
|
||||||
id: "0e830889-ab46-4b48-a8ba-1d4bd3e665ed", // Admin user ID from seed
|
id: "0e830889-ab46-4b48-a8ba-1d4bd3e665ed", // Admin user ID from seed
|
||||||
name: "Sean O'Connor",
|
name: "Sean O'Connor",
|
||||||
email: "sean@soconnor.dev"
|
email: "sean@soconnor.dev",
|
||||||
},
|
},
|
||||||
expires: new Date().toISOString()
|
expires: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 3. Create Caller
|
// 3. Create Caller
|
||||||
@@ -26,7 +25,7 @@ const createCaller = createCallerFactory(appRouter);
|
|||||||
const caller = createCaller({
|
const caller = createCaller({
|
||||||
db,
|
db,
|
||||||
session: mockSession as any,
|
session: mockSession as any,
|
||||||
headers: new Headers()
|
headers: new Headers(),
|
||||||
});
|
});
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
@@ -35,7 +34,7 @@ async function main() {
|
|||||||
// Get ID first
|
// Get ID first
|
||||||
const exp = await db.query.experiments.findFirst({
|
const exp = await db.query.experiments.findFirst({
|
||||||
where: eq(schema.experiments.name, "Control Flow Demo"),
|
where: eq(schema.experiments.name, "Control Flow Demo"),
|
||||||
columns: { id: true }
|
columns: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!exp) {
|
if (!exp) {
|
||||||
@@ -52,12 +51,16 @@ async function main() {
|
|||||||
const actions = result.steps[0]!.actions; // Step 1 actions
|
const actions = result.steps[0]!.actions; // Step 1 actions
|
||||||
console.log(`Step 1 has ${actions.length} actions.`);
|
console.log(`Step 1 has ${actions.length} actions.`);
|
||||||
|
|
||||||
actions.forEach(a => {
|
actions.forEach((a) => {
|
||||||
if (["sequence", "parallel", "loop", "branch"].includes(a.type)) {
|
if (["sequence", "parallel", "loop", "branch"].includes(a.type)) {
|
||||||
console.log(`\nAction: ${a.name} (${a.type})`);
|
console.log(`\nAction: ${a.name} (${a.type})`);
|
||||||
console.log(`Children Count: ${a.children ? a.children.length : 'UNDEFINED'}`);
|
console.log(
|
||||||
|
`Children Count: ${a.children ? a.children.length : "UNDEFINED"}`,
|
||||||
|
);
|
||||||
if (a.children && a.children.length > 0) {
|
if (a.children && a.children.length > 0) {
|
||||||
console.log(`First Child: ${a.children[0]!.name} (${a.children[0]!.type})`);
|
console.log(
|
||||||
|
`First Child: ${a.children[0]!.name} (${a.children[0]!.type})`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { db } from "../../src/server/db";
|
import { db } from "../../src/server/db";
|
||||||
import { experiments } from "../../src/server/db/schema";
|
import { experiments } from "../../src/server/db/schema";
|
||||||
import { eq, asc } from "drizzle-orm";
|
import { eq, asc } from "drizzle-orm";
|
||||||
@@ -12,10 +11,10 @@ async function verifyConversion() {
|
|||||||
with: {
|
with: {
|
||||||
actions: {
|
actions: {
|
||||||
orderBy: (actions, { asc }) => [asc(actions.orderIndex)],
|
orderBy: (actions, { asc }) => [asc(actions.orderIndex)],
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!experiment) {
|
if (!experiment) {
|
||||||
@@ -30,8 +29,11 @@ async function verifyConversion() {
|
|||||||
converted.forEach((s, idx) => {
|
converted.forEach((s, idx) => {
|
||||||
console.log(`[${idx}] ${s.name} (${s.type})`);
|
console.log(`[${idx}] ${s.name} (${s.type})`);
|
||||||
console.log(` Trigger:`, JSON.stringify(s.trigger));
|
console.log(` Trigger:`, JSON.stringify(s.trigger));
|
||||||
if (s.type === 'conditional') {
|
if (s.type === "conditional") {
|
||||||
console.log(` Conditions populated?`, Object.keys(s.trigger.conditions).length > 0);
|
console.log(
|
||||||
|
` Conditions populated?`,
|
||||||
|
Object.keys(s.trigger.conditions).length > 0,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ async function verify() {
|
|||||||
|
|
||||||
// 1. Check Study
|
// 1. Check Study
|
||||||
const study = await db.query.studies.findFirst({
|
const study = await db.query.studies.findFirst({
|
||||||
where: eq(schema.studies.name, "Comparative WoZ Study")
|
where: eq(schema.studies.name, "Comparative WoZ Study"),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!study) {
|
if (!study) {
|
||||||
@@ -23,7 +23,7 @@ async function verify() {
|
|||||||
|
|
||||||
// 2. Check Experiment
|
// 2. Check Experiment
|
||||||
const experiment = await db.query.experiments.findFirst({
|
const experiment = await db.query.experiments.findFirst({
|
||||||
where: eq(schema.experiments.name, "The Interactive Storyteller")
|
where: eq(schema.experiments.name, "The Interactive Storyteller"),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!experiment) {
|
if (!experiment) {
|
||||||
@@ -35,7 +35,7 @@ async function verify() {
|
|||||||
// 3. Check Steps
|
// 3. Check Steps
|
||||||
const steps = await db.query.steps.findMany({
|
const steps = await db.query.steps.findMany({
|
||||||
where: eq(schema.steps.experimentId, experiment.id),
|
where: eq(schema.steps.experimentId, experiment.id),
|
||||||
orderBy: schema.steps.orderIndex
|
orderBy: schema.steps.orderIndex,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`ℹ️ Found ${steps.length} steps.`);
|
console.log(`ℹ️ Found ${steps.length} steps.`);
|
||||||
@@ -45,13 +45,21 @@ async function verify() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify Step Names
|
// Verify Step Names
|
||||||
const expectedSteps = ["The Hook", "The Narrative - Part 1", "Comprehension Check", "Positive Feedback", "Conclusion"];
|
const expectedSteps = [
|
||||||
|
"The Hook",
|
||||||
|
"The Narrative - Part 1",
|
||||||
|
"Comprehension Check",
|
||||||
|
"Positive Feedback",
|
||||||
|
"Conclusion",
|
||||||
|
];
|
||||||
for (let i = 0; i < expectedSteps.length; i++) {
|
for (let i = 0; i < expectedSteps.length; i++) {
|
||||||
const step = steps[i];
|
const step = steps[i];
|
||||||
if (!step) continue;
|
if (!step) continue;
|
||||||
|
|
||||||
if (step.name !== expectedSteps[i]) {
|
if (step.name !== expectedSteps[i]) {
|
||||||
console.error(`❌ Step mismatch at index ${i}. Expected '${expectedSteps[i]}', got '${step.name}'`);
|
console.error(
|
||||||
|
`❌ Step mismatch at index ${i}. Expected '${expectedSteps[i]}', got '${step.name}'`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
console.log(`✅ Step ${i + 1}: ${step.name}`);
|
console.log(`✅ Step ${i + 1}: ${step.name}`);
|
||||||
}
|
}
|
||||||
@@ -60,7 +68,11 @@ async function verify() {
|
|||||||
// 4. Check Plugin Actions
|
// 4. Check Plugin Actions
|
||||||
// Find the NAO6 plugin
|
// Find the NAO6 plugin
|
||||||
const plugin = await db.query.plugins.findFirst({
|
const plugin = await db.query.plugins.findFirst({
|
||||||
where: (plugins, { eq, and }) => and(eq(plugins.name, "NAO6 Robot (Enhanced ROS2 Integration)"), eq(plugins.status, "active"))
|
where: (plugins, { eq, and }) =>
|
||||||
|
and(
|
||||||
|
eq(plugins.name, "NAO6 Robot (Enhanced ROS2 Integration)"),
|
||||||
|
eq(plugins.status, "active"),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!plugin) {
|
if (!plugin) {
|
||||||
@@ -69,10 +81,15 @@ async function verify() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const actions = plugin.actionDefinitions as any[];
|
const actions = plugin.actionDefinitions as any[];
|
||||||
const requiredActions = ["nao_nod", "nao_shake_head", "nao_bow", "nao_open_hand"];
|
const requiredActions = [
|
||||||
|
"nao_nod",
|
||||||
|
"nao_shake_head",
|
||||||
|
"nao_bow",
|
||||||
|
"nao_open_hand",
|
||||||
|
];
|
||||||
|
|
||||||
for (const actionId of requiredActions) {
|
for (const actionId of requiredActions) {
|
||||||
const found = actions.find(a => a.id === actionId);
|
const found = actions.find((a) => a.id === actionId);
|
||||||
if (!found) {
|
if (!found) {
|
||||||
console.error(`❌ Plugin missing action: ${actionId}`);
|
console.error(`❌ Plugin missing action: ${actionId}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { experiments, steps, actions } from "~/server/db/schema";
|
import { experiments, steps, actions } from "~/server/db/schema";
|
||||||
import { eq, asc, desc } from "drizzle-orm";
|
import { eq, asc, desc } from "drizzle-orm";
|
||||||
@@ -61,7 +60,10 @@ async function verifyTrpcLogic() {
|
|||||||
// Check conditions specifically
|
// Check conditions specifically
|
||||||
const conditions = branchAStep.trigger?.conditions as any;
|
const conditions = branchAStep.trigger?.conditions as any;
|
||||||
if (conditions?.nextStepId) {
|
if (conditions?.nextStepId) {
|
||||||
console.log("SUCCESS: nextStepId found in conditions:", conditions.nextStepId);
|
console.log(
|
||||||
|
"SUCCESS: nextStepId found in conditions:",
|
||||||
|
conditions.nextStepId,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
console.error("FAILURE: nextStepId MISSING in conditions!");
|
console.error("FAILURE: nextStepId MISSING in conditions!");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
import * as schema from "../src/server/db/schema";
|
import * as schema from "../src/server/db/schema";
|
||||||
@@ -11,7 +10,7 @@ const db = drizzle(connection, { schema });
|
|||||||
async function main() {
|
async function main() {
|
||||||
const exp = await db.query.experiments.findFirst({
|
const exp = await db.query.experiments.findFirst({
|
||||||
where: eq(schema.experiments.name, "Control Flow Demo"),
|
where: eq(schema.experiments.name, "Control Flow Demo"),
|
||||||
columns: { id: true }
|
columns: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (exp) {
|
if (exp) {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
import * as schema from "../src/server/db/schema";
|
import * as schema from "../src/server/db/schema";
|
||||||
@@ -11,7 +10,7 @@ const db = drizzle(connection, { schema });
|
|||||||
async function main() {
|
async function main() {
|
||||||
const user = await db.query.users.findFirst({
|
const user = await db.query.users.findFirst({
|
||||||
where: eq(schema.users.email, "sean@soconnor.dev"),
|
where: eq(schema.users.email, "sean@soconnor.dev"),
|
||||||
columns: { id: true }
|
columns: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
|
|||||||
@@ -17,17 +17,26 @@ import * as path from "path";
|
|||||||
// Function to load plugin definition (Remote -> Local Fallback)
|
// Function to load plugin definition (Remote -> Local Fallback)
|
||||||
async function loadNaoPluginDef() {
|
async function loadNaoPluginDef() {
|
||||||
const REMOTE_URL = "https://repo.hristudio.com/plugins/nao6-ros2.json";
|
const REMOTE_URL = "https://repo.hristudio.com/plugins/nao6-ros2.json";
|
||||||
const LOCAL_PATH = path.join(__dirname, "../robot-plugins/plugins/nao6-ros2.json");
|
const LOCAL_PATH = path.join(
|
||||||
|
__dirname,
|
||||||
|
"../robot-plugins/plugins/nao6-ros2.json",
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`🌐 Attempting to fetch plugin definition from ${REMOTE_URL}...`);
|
console.log(
|
||||||
const response = await fetch(REMOTE_URL, { signal: AbortSignal.timeout(3000) }); // 3s timeout
|
`🌐 Attempting to fetch plugin definition from ${REMOTE_URL}...`,
|
||||||
|
);
|
||||||
|
const response = await fetch(REMOTE_URL, {
|
||||||
|
signal: AbortSignal.timeout(3000),
|
||||||
|
}); // 3s timeout
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log("✅ Successfully fetched plugin definition from remote.");
|
console.log("✅ Successfully fetched plugin definition from remote.");
|
||||||
return data;
|
return data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`⚠️ Remote fetch failed (${err instanceof Error ? err.message : String(err)}). Falling back to local file.`);
|
console.warn(
|
||||||
|
`⚠️ Remote fetch failed (${err instanceof Error ? err.message : String(err)}). Falling back to local file.`,
|
||||||
|
);
|
||||||
const rawPlugin = fs.readFileSync(LOCAL_PATH, "utf-8");
|
const rawPlugin = fs.readFileSync(LOCAL_PATH, "utf-8");
|
||||||
return JSON.parse(rawPlugin);
|
return JSON.parse(rawPlugin);
|
||||||
}
|
}
|
||||||
@@ -39,7 +48,10 @@ let CORE_PLUGIN_DEF: any;
|
|||||||
let WOZ_PLUGIN_DEF: any;
|
let WOZ_PLUGIN_DEF: any;
|
||||||
|
|
||||||
function loadSystemPlugin(filename: string) {
|
function loadSystemPlugin(filename: string) {
|
||||||
const LOCAL_PATH = path.join(__dirname, `../src/plugins/definitions/${filename}`);
|
const LOCAL_PATH = path.join(
|
||||||
|
__dirname,
|
||||||
|
`../src/plugins/definitions/${filename}`,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
const raw = fs.readFileSync(LOCAL_PATH, "utf-8");
|
const raw = fs.readFileSync(LOCAL_PATH, "utf-8");
|
||||||
return JSON.parse(raw);
|
return JSON.parse(raw);
|
||||||
@@ -52,8 +64,6 @@ function loadSystemPlugin(filename: string) {
|
|||||||
async function main() {
|
async function main() {
|
||||||
console.log("🌱 Starting realistic seed script...");
|
console.log("🌱 Starting realistic seed script...");
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
NAO_PLUGIN_DEF = await loadNaoPluginDef();
|
NAO_PLUGIN_DEF = await loadNaoPluginDef();
|
||||||
CORE_PLUGIN_DEF = loadSystemPlugin("hristudio-core.json");
|
CORE_PLUGIN_DEF = loadSystemPlugin("hristudio-core.json");
|
||||||
@@ -87,40 +97,54 @@ async function main() {
|
|||||||
console.log("👥 Creating users...");
|
console.log("👥 Creating users...");
|
||||||
const hashedPassword = await bcrypt.hash("password123", 12);
|
const hashedPassword = await bcrypt.hash("password123", 12);
|
||||||
|
|
||||||
const gravatarUrl = (email: string) => `https://www.gravatar.com/avatar/${createHash("md5").update(email.toLowerCase().trim()).digest("hex")}?d=identicon`;
|
const gravatarUrl = (email: string) =>
|
||||||
|
`https://www.gravatar.com/avatar/${createHash("md5").update(email.toLowerCase().trim()).digest("hex")}?d=identicon`;
|
||||||
|
|
||||||
const [adminUser] = await db.insert(schema.users).values({
|
const [adminUser] = await db
|
||||||
|
.insert(schema.users)
|
||||||
|
.values({
|
||||||
name: "Sean O'Connor",
|
name: "Sean O'Connor",
|
||||||
email: "sean@soconnor.dev",
|
email: "sean@soconnor.dev",
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
emailVerified: new Date(),
|
emailVerified: new Date(),
|
||||||
image: gravatarUrl("sean@soconnor.dev"),
|
image: gravatarUrl("sean@soconnor.dev"),
|
||||||
}).returning();
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
const [researcherUser] = await db.insert(schema.users).values({
|
const [researcherUser] = await db
|
||||||
|
.insert(schema.users)
|
||||||
|
.values({
|
||||||
name: "Dr. Felipe Perrone",
|
name: "Dr. Felipe Perrone",
|
||||||
email: "felipe.perrone@bucknell.edu",
|
email: "felipe.perrone@bucknell.edu",
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
emailVerified: new Date(),
|
emailVerified: new Date(),
|
||||||
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Felipe",
|
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Felipe",
|
||||||
}).returning();
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
if (!adminUser) throw new Error("Failed to create admin user");
|
if (!adminUser) throw new Error("Failed to create admin user");
|
||||||
|
|
||||||
await db.insert(schema.userSystemRoles).values({ userId: adminUser.id, role: "administrator" });
|
await db
|
||||||
|
.insert(schema.userSystemRoles)
|
||||||
|
.values({ userId: adminUser.id, role: "administrator" });
|
||||||
|
|
||||||
// 3. Create Robots & Plugins
|
// 3. Create Robots & Plugins
|
||||||
console.log("🤖 Creating robots and plugins...");
|
console.log("🤖 Creating robots and plugins...");
|
||||||
const [naoRobot] = await db.insert(schema.robots).values({
|
const [naoRobot] = await db
|
||||||
|
.insert(schema.robots)
|
||||||
|
.values({
|
||||||
name: "NAO6",
|
name: "NAO6",
|
||||||
manufacturer: "SoftBank Robotics",
|
manufacturer: "SoftBank Robotics",
|
||||||
model: "NAO V6",
|
model: "NAO V6",
|
||||||
description: "Humanoid robot for social interaction studies.",
|
description: "Humanoid robot for social interaction studies.",
|
||||||
capabilities: ["speech", "vision", "bipedal_walking", "gestures"],
|
capabilities: ["speech", "vision", "bipedal_walking", "gestures"],
|
||||||
communicationProtocol: "ros2",
|
communicationProtocol: "ros2",
|
||||||
}).returning();
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
const [naoRepo] = await db.insert(schema.pluginRepositories).values({
|
const [naoRepo] = await db
|
||||||
|
.insert(schema.pluginRepositories)
|
||||||
|
.values({
|
||||||
name: "HRIStudio Official Plugins",
|
name: "HRIStudio Official Plugins",
|
||||||
url: "https://github.com/hristudio/plugins",
|
url: "https://github.com/hristudio/plugins",
|
||||||
description: "Official verified plugins",
|
description: "Official verified plugins",
|
||||||
@@ -128,9 +152,12 @@ async function main() {
|
|||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
isOfficial: true,
|
isOfficial: true,
|
||||||
createdBy: adminUser.id,
|
createdBy: adminUser.id,
|
||||||
}).returning();
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
const [naoPlugin] = await db.insert(schema.plugins).values({
|
const [naoPlugin] = await db
|
||||||
|
.insert(schema.plugins)
|
||||||
|
.values({
|
||||||
robotId: naoRobot!.id,
|
robotId: naoRobot!.id,
|
||||||
name: NAO_PLUGIN_DEF.name,
|
name: NAO_PLUGIN_DEF.name,
|
||||||
version: NAO_PLUGIN_DEF.version,
|
version: NAO_PLUGIN_DEF.version,
|
||||||
@@ -142,26 +169,33 @@ async function main() {
|
|||||||
metadata: NAO_PLUGIN_DEF,
|
metadata: NAO_PLUGIN_DEF,
|
||||||
status: "active",
|
status: "active",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
}).returning();
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
// 4. Create Study & Experiment - Comparative WoZ Study
|
// 4. Create Study & Experiment - Comparative WoZ Study
|
||||||
console.log("📚 Creating 'Comparative WoZ Study'...");
|
console.log("📚 Creating 'Comparative WoZ Study'...");
|
||||||
const [study] = await db.insert(schema.studies).values({
|
const [study] = await db
|
||||||
|
.insert(schema.studies)
|
||||||
|
.values({
|
||||||
name: "Comparative WoZ Study",
|
name: "Comparative WoZ Study",
|
||||||
description: "Comparison of HRIStudio vs Choregraphe for The Interactive Storyteller scenario.",
|
description:
|
||||||
|
"Comparison of HRIStudio vs Choregraphe for The Interactive Storyteller scenario.",
|
||||||
institution: "Bucknell University",
|
institution: "Bucknell University",
|
||||||
irbProtocol: "2024-HRI-COMP",
|
irbProtocol: "2024-HRI-COMP",
|
||||||
status: "active",
|
status: "active",
|
||||||
createdBy: adminUser.id,
|
createdBy: adminUser.id,
|
||||||
}).returning();
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
await db.insert(schema.studyMembers).values([
|
await db.insert(schema.studyMembers).values([
|
||||||
{ studyId: study!.id, userId: adminUser.id, role: "owner" },
|
{ studyId: study!.id, userId: adminUser.id, role: "owner" },
|
||||||
{ studyId: study!.id, userId: researcherUser!.id, role: "researcher" }
|
{ studyId: study!.id, userId: researcherUser!.id, role: "researcher" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Insert System Plugins
|
// Insert System Plugins
|
||||||
const [corePlugin] = await db.insert(schema.plugins).values({
|
const [corePlugin] = await db
|
||||||
|
.insert(schema.plugins)
|
||||||
|
.values({
|
||||||
name: CORE_PLUGIN_DEF.name,
|
name: CORE_PLUGIN_DEF.name,
|
||||||
version: CORE_PLUGIN_DEF.version,
|
version: CORE_PLUGIN_DEF.version,
|
||||||
description: CORE_PLUGIN_DEF.description,
|
description: CORE_PLUGIN_DEF.description,
|
||||||
@@ -170,10 +204,13 @@ async function main() {
|
|||||||
actionDefinitions: CORE_PLUGIN_DEF.actionDefinitions,
|
actionDefinitions: CORE_PLUGIN_DEF.actionDefinitions,
|
||||||
robotId: null, // System Plugin
|
robotId: null, // System Plugin
|
||||||
metadata: { ...CORE_PLUGIN_DEF, id: CORE_PLUGIN_DEF.id },
|
metadata: { ...CORE_PLUGIN_DEF, id: CORE_PLUGIN_DEF.id },
|
||||||
status: "active"
|
status: "active",
|
||||||
}).returning();
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
const [wozPlugin] = await db.insert(schema.plugins).values({
|
const [wozPlugin] = await db
|
||||||
|
.insert(schema.plugins)
|
||||||
|
.values({
|
||||||
name: WOZ_PLUGIN_DEF.name,
|
name: WOZ_PLUGIN_DEF.name,
|
||||||
version: WOZ_PLUGIN_DEF.version,
|
version: WOZ_PLUGIN_DEF.version,
|
||||||
description: WOZ_PLUGIN_DEF.description,
|
description: WOZ_PLUGIN_DEF.description,
|
||||||
@@ -182,54 +219,62 @@ async function main() {
|
|||||||
actionDefinitions: WOZ_PLUGIN_DEF.actionDefinitions,
|
actionDefinitions: WOZ_PLUGIN_DEF.actionDefinitions,
|
||||||
robotId: null, // System Plugin
|
robotId: null, // System Plugin
|
||||||
metadata: { ...WOZ_PLUGIN_DEF, id: WOZ_PLUGIN_DEF.id },
|
metadata: { ...WOZ_PLUGIN_DEF, id: WOZ_PLUGIN_DEF.id },
|
||||||
status: "active"
|
status: "active",
|
||||||
}).returning();
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
await db.insert(schema.studyPlugins).values([
|
await db.insert(schema.studyPlugins).values([
|
||||||
{
|
{
|
||||||
studyId: study!.id,
|
studyId: study!.id,
|
||||||
pluginId: naoPlugin!.id,
|
pluginId: naoPlugin!.id,
|
||||||
configuration: { robotIp: "10.0.0.42" },
|
configuration: { robotIp: "10.0.0.42" },
|
||||||
installedBy: adminUser.id
|
installedBy: adminUser.id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
studyId: study!.id,
|
studyId: study!.id,
|
||||||
pluginId: corePlugin!.id,
|
pluginId: corePlugin!.id,
|
||||||
configuration: {},
|
configuration: {},
|
||||||
installedBy: adminUser.id
|
installedBy: adminUser.id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
studyId: study!.id,
|
studyId: study!.id,
|
||||||
pluginId: wozPlugin!.id,
|
pluginId: wozPlugin!.id,
|
||||||
configuration: {},
|
configuration: {},
|
||||||
installedBy: adminUser.id
|
installedBy: adminUser.id,
|
||||||
}
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [experiment] = await db.insert(schema.experiments).values({
|
const [experiment] = await db
|
||||||
|
.insert(schema.experiments)
|
||||||
|
.values({
|
||||||
studyId: study!.id,
|
studyId: study!.id,
|
||||||
name: "The Interactive Storyteller",
|
name: "The Interactive Storyteller",
|
||||||
description: "A storytelling scenario where the robot tells a story and asks questions to the participant.",
|
description:
|
||||||
|
"A storytelling scenario where the robot tells a story and asks questions to the participant.",
|
||||||
version: 1,
|
version: 1,
|
||||||
status: "ready",
|
status: "ready",
|
||||||
robotId: naoRobot!.id,
|
robotId: naoRobot!.id,
|
||||||
createdBy: adminUser.id,
|
createdBy: adminUser.id,
|
||||||
// visualDesign will be auto-generated by designer from DB steps
|
// visualDesign will be auto-generated by designer from DB steps
|
||||||
}).returning();
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
// 5. Create Steps & Actions (The Interactive Storyteller Protocol)
|
// 5. Create Steps & Actions (The Interactive Storyteller Protocol)
|
||||||
console.log("🎬 Creating experiment steps (Interactive Storyteller)...");
|
console.log("🎬 Creating experiment steps (Interactive Storyteller)...");
|
||||||
|
|
||||||
// --- Step 1: The Hook ---
|
// --- Step 1: The Hook ---
|
||||||
const [step1] = await db.insert(schema.steps).values({
|
const [step1] = await db
|
||||||
|
.insert(schema.steps)
|
||||||
|
.values({
|
||||||
experimentId: experiment!.id,
|
experimentId: experiment!.id,
|
||||||
name: "The Hook",
|
name: "The Hook",
|
||||||
description: "Initial greeting and story introduction",
|
description: "Initial greeting and story introduction",
|
||||||
type: "robot",
|
type: "robot",
|
||||||
orderIndex: 0,
|
orderIndex: 0,
|
||||||
required: true,
|
required: true,
|
||||||
durationEstimate: 25
|
durationEstimate: 25,
|
||||||
}).returning();
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
await db.insert(schema.actions).values([
|
await db.insert(schema.actions).values([
|
||||||
{
|
{
|
||||||
@@ -237,11 +282,13 @@ async function main() {
|
|||||||
name: "Say Text",
|
name: "Say Text",
|
||||||
type: "nao6-ros2.say_text",
|
type: "nao6-ros2.say_text",
|
||||||
orderIndex: 0,
|
orderIndex: 0,
|
||||||
parameters: { text: "Hello. I have a story to tell you about a space traveler. Are you ready?" },
|
parameters: {
|
||||||
|
text: "Hello. I have a story to tell you about a space traveler. Are you ready?",
|
||||||
|
},
|
||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.2.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "interaction",
|
category: "interaction",
|
||||||
retryable: true
|
retryable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
stepId: step1!.id,
|
stepId: step1!.id,
|
||||||
@@ -255,25 +302,28 @@ async function main() {
|
|||||||
shoulder_roll: -0.2,
|
shoulder_roll: -0.2,
|
||||||
elbow_yaw: 0.5,
|
elbow_yaw: 0.5,
|
||||||
elbow_roll: -0.4,
|
elbow_roll: -0.4,
|
||||||
speed: 0.4
|
speed: 0.4,
|
||||||
},
|
},
|
||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.2.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "movement",
|
category: "movement",
|
||||||
retryable: true
|
retryable: true,
|
||||||
}
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// --- Step 2: The Narrative ---
|
// --- Step 2: The Narrative ---
|
||||||
const [step2] = await db.insert(schema.steps).values({
|
const [step2] = await db
|
||||||
|
.insert(schema.steps)
|
||||||
|
.values({
|
||||||
experimentId: experiment!.id,
|
experimentId: experiment!.id,
|
||||||
name: "The Narrative",
|
name: "The Narrative",
|
||||||
description: "Robot tells the space traveler story with gaze behavior",
|
description: "Robot tells the space traveler story with gaze behavior",
|
||||||
type: "robot",
|
type: "robot",
|
||||||
orderIndex: 1,
|
orderIndex: 1,
|
||||||
required: true,
|
required: true,
|
||||||
durationEstimate: 45
|
durationEstimate: 45,
|
||||||
}).returning();
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
await db.insert(schema.actions).values([
|
await db.insert(schema.actions).values([
|
||||||
{
|
{
|
||||||
@@ -281,11 +331,13 @@ async function main() {
|
|||||||
name: "Tell Story",
|
name: "Tell Story",
|
||||||
type: "nao6-ros2.say_text",
|
type: "nao6-ros2.say_text",
|
||||||
orderIndex: 0,
|
orderIndex: 0,
|
||||||
parameters: { text: "The traveler flew to Mars. He found a red rock that glowed in the dark. He put it in his pocket." },
|
parameters: {
|
||||||
|
text: "The traveler flew to Mars. He found a red rock that glowed in the dark. He put it in his pocket.",
|
||||||
|
},
|
||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.2.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "interaction",
|
category: "interaction",
|
||||||
retryable: true
|
retryable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
stepId: step2!.id,
|
stepId: step2!.id,
|
||||||
@@ -296,7 +348,7 @@ async function main() {
|
|||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.2.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "movement",
|
category: "movement",
|
||||||
retryable: true
|
retryable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
stepId: step2!.id,
|
stepId: step2!.id,
|
||||||
@@ -307,8 +359,8 @@ async function main() {
|
|||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.2.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "movement",
|
category: "movement",
|
||||||
retryable: true
|
retryable: true,
|
||||||
}
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// --- Step 3: Comprehension Check (Wizard Decision Point) ---
|
// --- Step 3: Comprehension Check (Wizard Decision Point) ---
|
||||||
@@ -316,33 +368,42 @@ async function main() {
|
|||||||
// --- Step 3: Comprehension Check (Wizard Decision Point) ---
|
// --- Step 3: Comprehension Check (Wizard Decision Point) ---
|
||||||
// Note: Wizard will choose to proceed to Step 4a (Correct) or 4b (Incorrect)
|
// Note: Wizard will choose to proceed to Step 4a (Correct) or 4b (Incorrect)
|
||||||
// --- Step 4a: Correct Response Branch ---
|
// --- Step 4a: Correct Response Branch ---
|
||||||
const [step4a] = await db.insert(schema.steps).values({
|
const [step4a] = await db
|
||||||
|
.insert(schema.steps)
|
||||||
|
.values({
|
||||||
experimentId: experiment!.id,
|
experimentId: experiment!.id,
|
||||||
name: "Branch A: Correct Response",
|
name: "Branch A: Correct Response",
|
||||||
description: "Response when participant says 'Red'",
|
description: "Response when participant says 'Red'",
|
||||||
type: "robot",
|
type: "robot",
|
||||||
orderIndex: 3,
|
orderIndex: 3,
|
||||||
required: false,
|
required: false,
|
||||||
durationEstimate: 20
|
durationEstimate: 20,
|
||||||
}).returning();
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
// --- Step 4b: Incorrect Response Branch ---
|
// --- Step 4b: Incorrect Response Branch ---
|
||||||
const [step4b] = await db.insert(schema.steps).values({
|
const [step4b] = await db
|
||||||
|
.insert(schema.steps)
|
||||||
|
.values({
|
||||||
experimentId: experiment!.id,
|
experimentId: experiment!.id,
|
||||||
name: "Branch B: Incorrect Response",
|
name: "Branch B: Incorrect Response",
|
||||||
description: "Response when participant gives wrong answer",
|
description: "Response when participant gives wrong answer",
|
||||||
type: "robot",
|
type: "robot",
|
||||||
orderIndex: 4,
|
orderIndex: 4,
|
||||||
required: false,
|
required: false,
|
||||||
durationEstimate: 20
|
durationEstimate: 20,
|
||||||
}).returning();
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
// --- Step 3: Comprehension Check (Wizard Decision Point) ---
|
// --- Step 3: Comprehension Check (Wizard Decision Point) ---
|
||||||
// Note: Wizard will choose to proceed to Step 4a (Correct) or 4b (Incorrect)
|
// Note: Wizard will choose to proceed to Step 4a (Correct) or 4b (Incorrect)
|
||||||
const [step3] = await db.insert(schema.steps).values({
|
const [step3] = await db
|
||||||
|
.insert(schema.steps)
|
||||||
|
.values({
|
||||||
experimentId: experiment!.id,
|
experimentId: experiment!.id,
|
||||||
name: "Comprehension Check",
|
name: "Comprehension Check",
|
||||||
description: "Ask participant about rock color and wait for wizard input",
|
description:
|
||||||
|
"Ask participant about rock color and wait for wizard input",
|
||||||
type: "conditional",
|
type: "conditional",
|
||||||
orderIndex: 2,
|
orderIndex: 2,
|
||||||
required: true,
|
required: true,
|
||||||
@@ -350,11 +411,22 @@ async function main() {
|
|||||||
conditions: {
|
conditions: {
|
||||||
variable: "last_wizard_response",
|
variable: "last_wizard_response",
|
||||||
options: [
|
options: [
|
||||||
{ label: "Correct Response (Red)", value: "Correct", nextStepId: step4a!.id, variant: "default" },
|
{
|
||||||
{ label: "Incorrect Response", value: "Incorrect", nextStepId: step4b!.id, variant: "destructive" }
|
label: "Correct Response (Red)",
|
||||||
]
|
value: "Correct",
|
||||||
}
|
nextStepId: step4a!.id,
|
||||||
}).returning();
|
variant: "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Incorrect Response",
|
||||||
|
value: "Incorrect",
|
||||||
|
nextStepId: step4b!.id,
|
||||||
|
variant: "destructive",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
await db.insert(schema.actions).values([
|
await db.insert(schema.actions).values([
|
||||||
{
|
{
|
||||||
@@ -366,7 +438,7 @@ async function main() {
|
|||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.2.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "interaction",
|
category: "interaction",
|
||||||
retryable: true
|
retryable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
stepId: step3!.id,
|
stepId: step3!.id,
|
||||||
@@ -376,11 +448,11 @@ async function main() {
|
|||||||
// Define the options that will be presented to the Wizard
|
// Define the options that will be presented to the Wizard
|
||||||
parameters: {
|
parameters: {
|
||||||
prompt_text: "Did participant answer 'Red' correctly?",
|
prompt_text: "Did participant answer 'Red' correctly?",
|
||||||
options: ["Correct", "Incorrect"]
|
options: ["Correct", "Incorrect"],
|
||||||
},
|
},
|
||||||
sourceKind: "core",
|
sourceKind: "core",
|
||||||
pluginId: "hristudio-woz", // Explicit link
|
pluginId: "hristudio-woz", // Explicit link
|
||||||
category: "wizard"
|
category: "wizard",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
stepId: step3!.id,
|
stepId: step3!.id,
|
||||||
@@ -390,8 +462,8 @@ async function main() {
|
|||||||
parameters: {},
|
parameters: {},
|
||||||
sourceKind: "core",
|
sourceKind: "core",
|
||||||
pluginId: "hristudio-core", // Explicit link
|
pluginId: "hristudio-core", // Explicit link
|
||||||
category: "control"
|
category: "control",
|
||||||
}
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await db.insert(schema.actions).values([
|
await db.insert(schema.actions).values([
|
||||||
@@ -400,11 +472,15 @@ async function main() {
|
|||||||
name: "Say Text with Emotion",
|
name: "Say Text with Emotion",
|
||||||
type: "nao6-ros2.say_with_emotion",
|
type: "nao6-ros2.say_with_emotion",
|
||||||
orderIndex: 0,
|
orderIndex: 0,
|
||||||
parameters: { text: "Yes! It was a glowing red rock.", emotion: "happy", speed: 1.0 },
|
parameters: {
|
||||||
|
text: "Yes! It was a glowing red rock.",
|
||||||
|
emotion: "happy",
|
||||||
|
speed: 1.0,
|
||||||
|
},
|
||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.2.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "interaction",
|
category: "interaction",
|
||||||
retryable: true
|
retryable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
stepId: step4a!.id,
|
stepId: step4a!.id,
|
||||||
@@ -415,7 +491,7 @@ async function main() {
|
|||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.2.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "movement",
|
category: "movement",
|
||||||
retryable: true
|
retryable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
stepId: step4a!.id,
|
stepId: step4a!.id,
|
||||||
@@ -426,12 +502,10 @@ async function main() {
|
|||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.2.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "movement",
|
category: "movement",
|
||||||
retryable: true
|
retryable: true,
|
||||||
}
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
await db.insert(schema.actions).values([
|
await db.insert(schema.actions).values([
|
||||||
{
|
{
|
||||||
stepId: step4b!.id,
|
stepId: step4b!.id,
|
||||||
@@ -442,7 +516,7 @@ async function main() {
|
|||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.2.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "interaction",
|
category: "interaction",
|
||||||
retryable: true
|
retryable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
stepId: step4b!.id,
|
stepId: step4b!.id,
|
||||||
@@ -453,7 +527,7 @@ async function main() {
|
|||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.2.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "movement",
|
category: "movement",
|
||||||
retryable: true
|
retryable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
stepId: step4b!.id,
|
stepId: step4b!.id,
|
||||||
@@ -464,7 +538,7 @@ async function main() {
|
|||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.2.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "movement",
|
category: "movement",
|
||||||
retryable: true
|
retryable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
stepId: step4b!.id,
|
stepId: step4b!.id,
|
||||||
@@ -475,20 +549,23 @@ async function main() {
|
|||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.2.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "movement",
|
category: "movement",
|
||||||
retryable: true
|
retryable: true,
|
||||||
}
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// --- Step 5: Conclusion ---
|
// --- Step 5: Conclusion ---
|
||||||
const [step5] = await db.insert(schema.steps).values({
|
const [step5] = await db
|
||||||
|
.insert(schema.steps)
|
||||||
|
.values({
|
||||||
experimentId: experiment!.id,
|
experimentId: experiment!.id,
|
||||||
name: "Conclusion",
|
name: "Conclusion",
|
||||||
description: "End the story and thank participant",
|
description: "End the story and thank participant",
|
||||||
type: "robot",
|
type: "robot",
|
||||||
orderIndex: 5,
|
orderIndex: 5,
|
||||||
required: true,
|
required: true,
|
||||||
durationEstimate: 25
|
durationEstimate: 25,
|
||||||
}).returning();
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
await db.insert(schema.actions).values([
|
await db.insert(schema.actions).values([
|
||||||
{
|
{
|
||||||
@@ -500,7 +577,7 @@ async function main() {
|
|||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.2.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "interaction",
|
category: "interaction",
|
||||||
retryable: true
|
retryable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
stepId: step5!.id,
|
stepId: step5!.id,
|
||||||
@@ -513,62 +590,77 @@ async function main() {
|
|||||||
shoulder_roll: 0.1,
|
shoulder_roll: 0.1,
|
||||||
elbow_yaw: 0.0,
|
elbow_yaw: 0.0,
|
||||||
elbow_roll: -0.3,
|
elbow_roll: -0.3,
|
||||||
speed: 0.3
|
speed: 0.3,
|
||||||
},
|
},
|
||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.2.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "movement",
|
category: "movement",
|
||||||
retryable: true
|
retryable: true,
|
||||||
}
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 5b. Create "Control Flow Demo" Experiment
|
// 5b. Create "Control Flow Demo" Experiment
|
||||||
console.log("🧩 Creating 'Control Flow Demo' experiment...");
|
console.log("🧩 Creating 'Control Flow Demo' experiment...");
|
||||||
const [controlDemoExp] = await db.insert(schema.experiments).values({
|
const [controlDemoExp] = await db
|
||||||
|
.insert(schema.experiments)
|
||||||
|
.values({
|
||||||
studyId: study!.id,
|
studyId: study!.id,
|
||||||
name: "Control Flow Demo",
|
name: "Control Flow Demo",
|
||||||
description: "Demonstration of enhanced control flow actions: Parallel, Wait, Loop, Branch.",
|
description:
|
||||||
|
"Demonstration of enhanced control flow actions: Parallel, Wait, Loop, Branch.",
|
||||||
version: 2,
|
version: 2,
|
||||||
status: "draft",
|
status: "draft",
|
||||||
robotId: naoRobot!.id,
|
robotId: naoRobot!.id,
|
||||||
createdBy: adminUser.id,
|
createdBy: adminUser.id,
|
||||||
}).returning();
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
// Step 1: Introduction (Parallel)
|
// Step 1: Introduction (Parallel)
|
||||||
const [cdStep1] = await db.insert(schema.steps).values({
|
const [cdStep1] = await db
|
||||||
|
.insert(schema.steps)
|
||||||
|
.values({
|
||||||
experimentId: controlDemoExp!.id,
|
experimentId: controlDemoExp!.id,
|
||||||
name: "1. Introduction (Parallel)",
|
name: "1. Introduction (Parallel)",
|
||||||
description: "Parallel execution demonstration",
|
description: "Parallel execution demonstration",
|
||||||
type: "robot",
|
type: "robot",
|
||||||
orderIndex: 0,
|
orderIndex: 0,
|
||||||
required: true,
|
required: true,
|
||||||
durationEstimate: 30
|
durationEstimate: 30,
|
||||||
}).returning();
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
// Step 5: Conclusion - Defined early for ID reference (Convergence point)
|
// Step 5: Conclusion - Defined early for ID reference (Convergence point)
|
||||||
const [cdStep5] = await db.insert(schema.steps).values({
|
const [cdStep5] = await db
|
||||||
|
.insert(schema.steps)
|
||||||
|
.values({
|
||||||
experimentId: controlDemoExp!.id,
|
experimentId: controlDemoExp!.id,
|
||||||
name: "5. Conclusion",
|
name: "5. Conclusion",
|
||||||
description: "Convergence point",
|
description: "Convergence point",
|
||||||
type: "robot",
|
type: "robot",
|
||||||
orderIndex: 4,
|
orderIndex: 4,
|
||||||
required: true,
|
required: true,
|
||||||
durationEstimate: 15
|
durationEstimate: 15,
|
||||||
}).returning();
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
// Step 4: Path B (Wait) - Defined early for ID reference
|
// Step 4: Path B (Wait) - Defined early for ID reference
|
||||||
const [cdStep4] = await db.insert(schema.steps).values({
|
const [cdStep4] = await db
|
||||||
|
.insert(schema.steps)
|
||||||
|
.values({
|
||||||
experimentId: controlDemoExp!.id,
|
experimentId: controlDemoExp!.id,
|
||||||
name: "4. Path B (Wait)",
|
name: "4. Path B (Wait)",
|
||||||
description: "Wait action demonstration",
|
description: "Wait action demonstration",
|
||||||
type: "robot",
|
type: "robot",
|
||||||
orderIndex: 3,
|
orderIndex: 3,
|
||||||
required: true,
|
required: true,
|
||||||
durationEstimate: 10
|
durationEstimate: 10,
|
||||||
}).returning();
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
// Step 3: Path A (Loop) - Defined early for ID reference
|
// Step 3: Path A (Loop) - Defined early for ID reference
|
||||||
const [cdStep3] = await db.insert(schema.steps).values({
|
const [cdStep3] = await db
|
||||||
|
.insert(schema.steps)
|
||||||
|
.values({
|
||||||
experimentId: controlDemoExp!.id,
|
experimentId: controlDemoExp!.id,
|
||||||
name: "3. Path A (Loop)",
|
name: "3. Path A (Loop)",
|
||||||
description: "Looping demonstration",
|
description: "Looping demonstration",
|
||||||
@@ -576,11 +668,14 @@ async function main() {
|
|||||||
orderIndex: 2,
|
orderIndex: 2,
|
||||||
required: true,
|
required: true,
|
||||||
durationEstimate: 45,
|
durationEstimate: 45,
|
||||||
conditions: { nextStepId: cdStep5!.id }
|
conditions: { nextStepId: cdStep5!.id },
|
||||||
}).returning();
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
// Step 2: Branch Decision
|
// Step 2: Branch Decision
|
||||||
const [cdStep2] = await db.insert(schema.steps).values({
|
const [cdStep2] = await db
|
||||||
|
.insert(schema.steps)
|
||||||
|
.values({
|
||||||
experimentId: controlDemoExp!.id,
|
experimentId: controlDemoExp!.id,
|
||||||
name: "2. Branch Decision",
|
name: "2. Branch Decision",
|
||||||
description: "Choose between Loop (3) or Wait (4)",
|
description: "Choose between Loop (3) or Wait (4)",
|
||||||
@@ -591,11 +686,22 @@ async function main() {
|
|||||||
conditions: {
|
conditions: {
|
||||||
variable: "demo_branch_choice",
|
variable: "demo_branch_choice",
|
||||||
options: [
|
options: [
|
||||||
{ label: "Go to Loop (Step 3)", value: "loop", nextStepId: cdStep3!.id, variant: "default" },
|
{
|
||||||
{ label: "Go to Wait (Step 4)", value: "wait", nextStepId: cdStep4!.id, variant: "secondary" }
|
label: "Go to Loop (Step 3)",
|
||||||
]
|
value: "loop",
|
||||||
}
|
nextStepId: cdStep3!.id,
|
||||||
}).returning();
|
variant: "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Go to Wait (Step 4)",
|
||||||
|
value: "wait",
|
||||||
|
nextStepId: cdStep4!.id,
|
||||||
|
variant: "secondary",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
// --- Step 1 Actions (Parallel) ---
|
// --- Step 1 Actions (Parallel) ---
|
||||||
await db.insert(schema.actions).values({
|
await db.insert(schema.actions).values({
|
||||||
@@ -612,7 +718,7 @@ async function main() {
|
|||||||
parameters: { text: "Starting control flow demonstration." },
|
parameters: { text: "Starting control flow demonstration." },
|
||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.2.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "interaction"
|
category: "interaction",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
@@ -621,13 +727,13 @@ async function main() {
|
|||||||
parameters: { arm: "right", shoulder_roll: -0.5 },
|
parameters: { arm: "right", shoulder_roll: -0.5 },
|
||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.2.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "movement"
|
category: "movement",
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
pluginId: "hristudio-core",
|
pluginId: "hristudio-core",
|
||||||
category: "control",
|
category: "control",
|
||||||
sourceKind: "core"
|
sourceKind: "core",
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Step 2 Actions (Branch) ---
|
// --- Step 2 Actions (Branch) ---
|
||||||
@@ -640,7 +746,7 @@ async function main() {
|
|||||||
parameters: { text: "Should I loop or wait?" },
|
parameters: { text: "Should I loop or wait?" },
|
||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.2.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "interaction"
|
category: "interaction",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
stepId: cdStep2!.id,
|
stepId: cdStep2!.id,
|
||||||
@@ -651,12 +757,12 @@ async function main() {
|
|||||||
prompt_text: "Choose the next path:",
|
prompt_text: "Choose the next path:",
|
||||||
options: [
|
options: [
|
||||||
{ label: "Loop Path", value: "loop", nextStepId: cdStep3!.id },
|
{ label: "Loop Path", value: "loop", nextStepId: cdStep3!.id },
|
||||||
{ label: "Wait Path", value: "wait", nextStepId: cdStep4!.id }
|
{ label: "Wait Path", value: "wait", nextStepId: cdStep4!.id },
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
pluginId: "hristudio-woz",
|
pluginId: "hristudio-woz",
|
||||||
category: "wizard",
|
category: "wizard",
|
||||||
sourceKind: "core"
|
sourceKind: "core",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
stepId: cdStep2!.id,
|
stepId: cdStep2!.id,
|
||||||
@@ -666,8 +772,8 @@ async function main() {
|
|||||||
parameters: {},
|
parameters: {},
|
||||||
pluginId: "hristudio-core",
|
pluginId: "hristudio-core",
|
||||||
category: "control",
|
category: "control",
|
||||||
sourceKind: "core"
|
sourceKind: "core",
|
||||||
}
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// --- Step 3 Actions (Loop) ---
|
// --- Step 3 Actions (Loop) ---
|
||||||
@@ -686,13 +792,13 @@ async function main() {
|
|||||||
parameters: { text: "I am looping." },
|
parameters: { text: "I am looping." },
|
||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.2.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "interaction"
|
category: "interaction",
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
pluginId: "hristudio-core",
|
pluginId: "hristudio-core",
|
||||||
category: "control",
|
category: "control",
|
||||||
sourceKind: "core"
|
sourceKind: "core",
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Step 4 Actions (Wait) ---
|
// --- Step 4 Actions (Wait) ---
|
||||||
@@ -704,7 +810,7 @@ async function main() {
|
|||||||
parameters: { duration: 3 },
|
parameters: { duration: 3 },
|
||||||
pluginId: "hristudio-core",
|
pluginId: "hristudio-core",
|
||||||
category: "control",
|
category: "control",
|
||||||
sourceKind: "core"
|
sourceKind: "core",
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Step 5 Actions (Conclusion) ---
|
// --- Step 5 Actions (Conclusion) ---
|
||||||
@@ -716,7 +822,7 @@ async function main() {
|
|||||||
parameters: { text: "Demonstration complete. Returning to start." },
|
parameters: { text: "Demonstration complete. Returning to start." },
|
||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.2.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "interaction"
|
category: "interaction",
|
||||||
});
|
});
|
||||||
|
|
||||||
// 6. Participants (N=20 for study)
|
// 6. Participants (N=20 for study)
|
||||||
@@ -729,21 +835,28 @@ async function main() {
|
|||||||
name: `Participant ${100 + i}`,
|
name: `Participant ${100 + i}`,
|
||||||
consentGiven: true,
|
consentGiven: true,
|
||||||
consentGivenAt: new Date(),
|
consentGivenAt: new Date(),
|
||||||
notes: i % 2 === 0 ? "Condition: HRIStudio" : "Condition: Choregraphe"
|
notes: i % 2 === 0 ? "Condition: HRIStudio" : "Condition: Choregraphe",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const insertedParticipants = await db.insert(schema.participants).values(participants).returning();
|
const insertedParticipants = await db
|
||||||
|
.insert(schema.participants)
|
||||||
|
.values(participants)
|
||||||
|
.returning();
|
||||||
|
|
||||||
console.log("\n✅ Database seeded successfully!");
|
console.log("\n✅ Database seeded successfully!");
|
||||||
console.log(`Summary:`);
|
console.log(`Summary:`);
|
||||||
console.log(`- 1 Admin User (sean@soconnor.dev)`);
|
console.log(`- 1 Admin User (sean@soconnor.dev)`);
|
||||||
console.log(`- Study: 'Comparative WoZ Study'`);
|
console.log(`- Study: 'Comparative WoZ Study'`);
|
||||||
console.log(`- Experiment: 'The Interactive Storyteller' (6 steps created)`);
|
console.log(
|
||||||
|
`- Experiment: 'The Interactive Storyteller' (6 steps created)`,
|
||||||
|
);
|
||||||
console.log(` - Step 1: The Hook (greeting + welcome gesture)`);
|
console.log(` - Step 1: The Hook (greeting + welcome gesture)`);
|
||||||
console.log(` - Step 2: The Narrative (story + gaze sequence)`);
|
console.log(` - Step 2: The Narrative (story + gaze sequence)`);
|
||||||
console.log(` - Step 3: Comprehension Check (question + wizard wait)`);
|
console.log(` - Step 3: Comprehension Check (question + wizard wait)`);
|
||||||
console.log(` - Step 4a: Branch A - Correct Response (affirmation + nod)`);
|
console.log(` - Step 4a: Branch A - Correct Response (affirmation + nod)`);
|
||||||
console.log(` - Step 4b: Branch B - Incorrect Response (correction + head shake)`);
|
console.log(
|
||||||
|
` - Step 4b: Branch B - Incorrect Response (correction + head shake)`,
|
||||||
|
);
|
||||||
console.log(` - Step 5: Conclusion (ending + bow)`);
|
console.log(` - Step 5: Conclusion (ending + bow)`);
|
||||||
console.log(`- ${insertedParticipants.length} Participants`);
|
console.log(`- ${insertedParticipants.length} Participants`);
|
||||||
|
|
||||||
@@ -751,20 +864,23 @@ async function main() {
|
|||||||
console.log("📊 Seeding completed trial with analytics data...");
|
console.log("📊 Seeding completed trial with analytics data...");
|
||||||
|
|
||||||
// Pick participant P101
|
// Pick participant P101
|
||||||
const p101 = insertedParticipants.find(p => p.participantCode === "P101");
|
const p101 = insertedParticipants.find((p) => p.participantCode === "P101");
|
||||||
if (!p101) throw new Error("P101 not found");
|
if (!p101) throw new Error("P101 not found");
|
||||||
|
|
||||||
const startTime = new Date();
|
const startTime = new Date();
|
||||||
startTime.setMinutes(startTime.getMinutes() - 10); // Started 10 mins ago
|
startTime.setMinutes(startTime.getMinutes() - 10); // Started 10 mins ago
|
||||||
const endTime = new Date(); // Ended just now
|
const endTime = new Date(); // Ended just now
|
||||||
|
|
||||||
const [analyticsTrial] = await db.insert(schema.trials).values({
|
const [analyticsTrial] = await db
|
||||||
|
.insert(schema.trials)
|
||||||
|
.values({
|
||||||
experimentId: experiment!.id,
|
experimentId: experiment!.id,
|
||||||
participantId: p101.id,
|
participantId: p101.id,
|
||||||
status: "completed",
|
status: "completed",
|
||||||
startedAt: startTime,
|
startedAt: startTime,
|
||||||
completedAt: endTime,
|
completedAt: endTime,
|
||||||
}).returning();
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
// Create a series of events
|
// Create a series of events
|
||||||
const timelineEvents = [];
|
const timelineEvents = [];
|
||||||
@@ -781,7 +897,7 @@ async function main() {
|
|||||||
trialId: analyticsTrial!.id,
|
trialId: analyticsTrial!.id,
|
||||||
eventType: "trial_started",
|
eventType: "trial_started",
|
||||||
timestamp: new Date(currentTime),
|
timestamp: new Date(currentTime),
|
||||||
data: { experimentId: experiment!.id, participantId: p101.id }
|
data: { experimentId: experiment!.id, participantId: p101.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Step 1: The Hook
|
// 2. Step 1: The Hook
|
||||||
@@ -790,7 +906,7 @@ async function main() {
|
|||||||
trialId: analyticsTrial!.id,
|
trialId: analyticsTrial!.id,
|
||||||
eventType: "step_changed",
|
eventType: "step_changed",
|
||||||
timestamp: new Date(currentTime),
|
timestamp: new Date(currentTime),
|
||||||
data: { stepId: step1!.id, stepName: "The Hook" }
|
data: { stepId: step1!.id, stepName: "The Hook" },
|
||||||
});
|
});
|
||||||
|
|
||||||
advance(1);
|
advance(1);
|
||||||
@@ -798,7 +914,7 @@ async function main() {
|
|||||||
trialId: analyticsTrial!.id,
|
trialId: analyticsTrial!.id,
|
||||||
eventType: "action_executed",
|
eventType: "action_executed",
|
||||||
timestamp: new Date(currentTime),
|
timestamp: new Date(currentTime),
|
||||||
data: { actionName: "Say Text", text: "Hello..." }
|
data: { actionName: "Say Text", text: "Hello..." },
|
||||||
});
|
});
|
||||||
|
|
||||||
advance(5);
|
advance(5);
|
||||||
@@ -806,7 +922,7 @@ async function main() {
|
|||||||
trialId: analyticsTrial!.id,
|
trialId: analyticsTrial!.id,
|
||||||
eventType: "action_executed",
|
eventType: "action_executed",
|
||||||
timestamp: new Date(currentTime),
|
timestamp: new Date(currentTime),
|
||||||
data: { actionName: "Move Arm", arm: "right" }
|
data: { actionName: "Move Arm", arm: "right" },
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Step 2: The Narrative
|
// 3. Step 2: The Narrative
|
||||||
@@ -815,7 +931,7 @@ async function main() {
|
|||||||
trialId: analyticsTrial!.id,
|
trialId: analyticsTrial!.id,
|
||||||
eventType: "step_changed",
|
eventType: "step_changed",
|
||||||
timestamp: new Date(currentTime),
|
timestamp: new Date(currentTime),
|
||||||
data: { stepId: step2!.id, stepName: "The Narrative" }
|
data: { stepId: step2!.id, stepName: "The Narrative" },
|
||||||
});
|
});
|
||||||
|
|
||||||
advance(2);
|
advance(2);
|
||||||
@@ -823,7 +939,7 @@ async function main() {
|
|||||||
trialId: analyticsTrial!.id,
|
trialId: analyticsTrial!.id,
|
||||||
eventType: "action_executed",
|
eventType: "action_executed",
|
||||||
timestamp: new Date(currentTime),
|
timestamp: new Date(currentTime),
|
||||||
data: { actionName: "Tell Story" }
|
data: { actionName: "Tell Story" },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate an intervention/wizard action
|
// Simulate an intervention/wizard action
|
||||||
@@ -832,7 +948,7 @@ async function main() {
|
|||||||
trialId: analyticsTrial!.id,
|
trialId: analyticsTrial!.id,
|
||||||
eventType: "intervention",
|
eventType: "intervention",
|
||||||
timestamp: new Date(currentTime),
|
timestamp: new Date(currentTime),
|
||||||
data: { type: "pause", reason: "participant_distracted" }
|
data: { type: "pause", reason: "participant_distracted" },
|
||||||
});
|
});
|
||||||
|
|
||||||
advance(10); // Paused for 10s
|
advance(10); // Paused for 10s
|
||||||
@@ -840,7 +956,7 @@ async function main() {
|
|||||||
trialId: analyticsTrial!.id,
|
trialId: analyticsTrial!.id,
|
||||||
eventType: "intervention",
|
eventType: "intervention",
|
||||||
timestamp: new Date(currentTime),
|
timestamp: new Date(currentTime),
|
||||||
data: { type: "resume" }
|
data: { type: "resume" },
|
||||||
});
|
});
|
||||||
|
|
||||||
advance(2);
|
advance(2);
|
||||||
@@ -848,7 +964,7 @@ async function main() {
|
|||||||
trialId: analyticsTrial!.id,
|
trialId: analyticsTrial!.id,
|
||||||
eventType: "action_executed",
|
eventType: "action_executed",
|
||||||
timestamp: new Date(currentTime),
|
timestamp: new Date(currentTime),
|
||||||
data: { actionName: "Turn Head", yaw: 1.5 }
|
data: { actionName: "Turn Head", yaw: 1.5 },
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. Step 3: Comprehension Check
|
// 4. Step 3: Comprehension Check
|
||||||
@@ -857,7 +973,7 @@ async function main() {
|
|||||||
trialId: analyticsTrial!.id,
|
trialId: analyticsTrial!.id,
|
||||||
eventType: "step_changed",
|
eventType: "step_changed",
|
||||||
timestamp: new Date(currentTime),
|
timestamp: new Date(currentTime),
|
||||||
data: { stepId: step3!.id, stepName: "Comprehension Check" }
|
data: { stepId: step3!.id, stepName: "Comprehension Check" },
|
||||||
});
|
});
|
||||||
|
|
||||||
advance(1);
|
advance(1);
|
||||||
@@ -865,7 +981,7 @@ async function main() {
|
|||||||
trialId: analyticsTrial!.id,
|
trialId: analyticsTrial!.id,
|
||||||
eventType: "action_executed",
|
eventType: "action_executed",
|
||||||
timestamp: new Date(currentTime),
|
timestamp: new Date(currentTime),
|
||||||
data: { actionName: "Say Text", text: "What color..." }
|
data: { actionName: "Say Text", text: "What color..." },
|
||||||
});
|
});
|
||||||
|
|
||||||
advance(5);
|
advance(5);
|
||||||
@@ -873,7 +989,7 @@ async function main() {
|
|||||||
trialId: analyticsTrial!.id,
|
trialId: analyticsTrial!.id,
|
||||||
eventType: "wizard_action",
|
eventType: "wizard_action",
|
||||||
timestamp: new Date(currentTime),
|
timestamp: new Date(currentTime),
|
||||||
data: { action: "wait_for_response", prompt: "Did they answer Red?" }
|
data: { action: "wait_for_response", prompt: "Did they answer Red?" },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wizard selects "Correct"
|
// Wizard selects "Correct"
|
||||||
@@ -882,7 +998,7 @@ async function main() {
|
|||||||
trialId: analyticsTrial!.id,
|
trialId: analyticsTrial!.id,
|
||||||
eventType: "wizard_response",
|
eventType: "wizard_response",
|
||||||
timestamp: new Date(currentTime),
|
timestamp: new Date(currentTime),
|
||||||
data: { response: "Correct", variable: "last_wizard_response" }
|
data: { response: "Correct", variable: "last_wizard_response" },
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5. Branch A
|
// 5. Branch A
|
||||||
@@ -891,7 +1007,7 @@ async function main() {
|
|||||||
trialId: analyticsTrial!.id,
|
trialId: analyticsTrial!.id,
|
||||||
eventType: "step_changed",
|
eventType: "step_changed",
|
||||||
timestamp: new Date(currentTime),
|
timestamp: new Date(currentTime),
|
||||||
data: { stepId: step4a!.id, stepName: "Branch A: Correct" }
|
data: { stepId: step4a!.id, stepName: "Branch A: Correct" },
|
||||||
});
|
});
|
||||||
|
|
||||||
advance(1);
|
advance(1);
|
||||||
@@ -899,7 +1015,7 @@ async function main() {
|
|||||||
trialId: analyticsTrial!.id,
|
trialId: analyticsTrial!.id,
|
||||||
eventType: "action_executed",
|
eventType: "action_executed",
|
||||||
timestamp: new Date(currentTime),
|
timestamp: new Date(currentTime),
|
||||||
data: { actionName: "Say Text with Emotion", emotion: "happy" }
|
data: { actionName: "Say Text with Emotion", emotion: "happy" },
|
||||||
});
|
});
|
||||||
|
|
||||||
// 6. Conclusion
|
// 6. Conclusion
|
||||||
@@ -908,7 +1024,7 @@ async function main() {
|
|||||||
trialId: analyticsTrial!.id,
|
trialId: analyticsTrial!.id,
|
||||||
eventType: "step_changed",
|
eventType: "step_changed",
|
||||||
timestamp: new Date(currentTime),
|
timestamp: new Date(currentTime),
|
||||||
data: { stepId: step5!.id, stepName: "Conclusion" }
|
data: { stepId: step5!.id, stepName: "Conclusion" },
|
||||||
});
|
});
|
||||||
|
|
||||||
advance(2);
|
advance(2);
|
||||||
@@ -916,7 +1032,7 @@ async function main() {
|
|||||||
trialId: analyticsTrial!.id,
|
trialId: analyticsTrial!.id,
|
||||||
eventType: "action_executed",
|
eventType: "action_executed",
|
||||||
timestamp: new Date(currentTime),
|
timestamp: new Date(currentTime),
|
||||||
data: { actionName: "End Story" }
|
data: { actionName: "End Story" },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Trial Complete
|
// Trial Complete
|
||||||
@@ -925,12 +1041,15 @@ async function main() {
|
|||||||
trialId: analyticsTrial!.id,
|
trialId: analyticsTrial!.id,
|
||||||
eventType: "trial_completed",
|
eventType: "trial_completed",
|
||||||
timestamp: new Date(currentTime),
|
timestamp: new Date(currentTime),
|
||||||
data: { durationSeconds: (currentTime.getTime() - startTime.getTime()) / 1000 }
|
data: {
|
||||||
|
durationSeconds: (currentTime.getTime() - startTime.getTime()) / 1000,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.insert(schema.trialEvents).values(timelineEvents);
|
await db.insert(schema.trialEvents).values(timelineEvents);
|
||||||
console.log("✅ Seeded 1 completed trial with " + timelineEvents.length + " events.");
|
console.log(
|
||||||
|
"✅ Seeded 1 completed trial with " + timelineEvents.length + " events.",
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ Seeding failed:", error);
|
console.error("❌ Seeding failed:", error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ export default function DebugPage() {
|
|||||||
|
|
||||||
const ROS_BRIDGE_URL = "ws://134.82.159.25:9090";
|
const ROS_BRIDGE_URL = "ws://134.82.159.25:9090";
|
||||||
|
|
||||||
const addLog = (message: string, type: "info" | "error" | "success" = "info") => {
|
const addLog = (
|
||||||
|
message: string,
|
||||||
|
type: "info" | "error" | "success" = "info",
|
||||||
|
) => {
|
||||||
const timestamp = new Date().toLocaleTimeString();
|
const timestamp = new Date().toLocaleTimeString();
|
||||||
const logEntry = `[${timestamp}] [${type.toUpperCase()}] ${message}`;
|
const logEntry = `[${timestamp}] [${type.toUpperCase()}] ${message}`;
|
||||||
setLogs((prev) => [...prev.slice(-99), logEntry]);
|
setLogs((prev) => [...prev.slice(-99), logEntry]);
|
||||||
@@ -79,7 +82,9 @@ export default function DebugPage() {
|
|||||||
setConnectionStatus("connecting");
|
setConnectionStatus("connecting");
|
||||||
setConnectionAttempts((prev) => prev + 1);
|
setConnectionAttempts((prev) => prev + 1);
|
||||||
setLastError(null);
|
setLastError(null);
|
||||||
addLog(`Attempting connection #${connectionAttempts + 1} to ${ROS_BRIDGE_URL}`);
|
addLog(
|
||||||
|
`Attempting connection #${connectionAttempts + 1} to ${ROS_BRIDGE_URL}`,
|
||||||
|
);
|
||||||
|
|
||||||
const socket = new WebSocket(ROS_BRIDGE_URL);
|
const socket = new WebSocket(ROS_BRIDGE_URL);
|
||||||
|
|
||||||
@@ -96,7 +101,10 @@ export default function DebugPage() {
|
|||||||
setConnectionStatus("connected");
|
setConnectionStatus("connected");
|
||||||
setRosSocket(socket);
|
setRosSocket(socket);
|
||||||
setLastError(null);
|
setLastError(null);
|
||||||
addLog("✅ WebSocket connection established successfully", "success");
|
addLog(
|
||||||
|
"[SUCCESS] WebSocket connection established successfully",
|
||||||
|
"success",
|
||||||
|
);
|
||||||
|
|
||||||
// Test basic functionality by advertising
|
// Test basic functionality by advertising
|
||||||
const advertiseMsg = {
|
const advertiseMsg = {
|
||||||
@@ -138,16 +146,20 @@ export default function DebugPage() {
|
|||||||
addLog(`Connection closed normally: ${event.reason || reason}`);
|
addLog(`Connection closed normally: ${event.reason || reason}`);
|
||||||
} else if (event.code === 1006) {
|
} else if (event.code === 1006) {
|
||||||
reason = "Connection lost/refused";
|
reason = "Connection lost/refused";
|
||||||
setLastError("ROS Bridge server not responding - check if rosbridge_server is running");
|
setLastError(
|
||||||
addLog(`❌ Connection failed: ${reason} (${event.code})`, "error");
|
"ROS Bridge server not responding - check if rosbridge_server is running",
|
||||||
|
);
|
||||||
|
addLog(`[ERROR] Connection failed: ${reason} (${event.code})`, "error");
|
||||||
} else if (event.code === 1011) {
|
} else if (event.code === 1011) {
|
||||||
reason = "Server error";
|
reason = "Server error";
|
||||||
setLastError("ROS Bridge server encountered an error");
|
setLastError("ROS Bridge server encountered an error");
|
||||||
addLog(`❌ Server error: ${reason} (${event.code})`, "error");
|
addLog(`[ERROR] Server error: ${reason} (${event.code})`, "error");
|
||||||
} else {
|
} else {
|
||||||
reason = `Code ${event.code}`;
|
reason = `Code ${event.code}`;
|
||||||
setLastError(`Connection closed with code ${event.code}: ${event.reason || "No reason given"}`);
|
setLastError(
|
||||||
addLog(`❌ Connection closed: ${reason}`, "error");
|
`Connection closed with code ${event.code}: ${event.reason || "No reason given"}`,
|
||||||
|
);
|
||||||
|
addLog(`[ERROR] Connection closed: ${reason}`, "error");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wasConnected) {
|
if (wasConnected) {
|
||||||
@@ -160,7 +172,7 @@ export default function DebugPage() {
|
|||||||
setConnectionStatus("error");
|
setConnectionStatus("error");
|
||||||
const errorMsg = "WebSocket error occurred";
|
const errorMsg = "WebSocket error occurred";
|
||||||
setLastError(errorMsg);
|
setLastError(errorMsg);
|
||||||
addLog(`❌ ${errorMsg}`, "error");
|
addLog(`[ERROR] ${errorMsg}`, "error");
|
||||||
console.error("WebSocket error details:", error);
|
console.error("WebSocket error details:", error);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -298,7 +310,7 @@ export default function DebugPage() {
|
|||||||
>
|
>
|
||||||
{connectionStatus.toUpperCase()}
|
{connectionStatus.toUpperCase()}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-muted-foreground text-sm">
|
||||||
Attempts: {connectionAttempts}
|
Attempts: {connectionAttempts}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -306,7 +318,9 @@ export default function DebugPage() {
|
|||||||
{lastError && (
|
{lastError && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<AlertDescription className="text-sm">{lastError}</AlertDescription>
|
<AlertDescription className="text-sm">
|
||||||
|
{lastError}
|
||||||
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -318,7 +332,9 @@ export default function DebugPage() {
|
|||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
<Play className="mr-2 h-4 w-4" />
|
<Play className="mr-2 h-4 w-4" />
|
||||||
{connectionStatus === "connecting" ? "Connecting..." : "Connect"}
|
{connectionStatus === "connecting"
|
||||||
|
? "Connecting..."
|
||||||
|
: "Connect"}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
@@ -479,27 +495,32 @@ export default function DebugPage() {
|
|||||||
key={index}
|
key={index}
|
||||||
className={`rounded p-2 text-xs ${
|
className={`rounded p-2 text-xs ${
|
||||||
msg.direction === "sent"
|
msg.direction === "sent"
|
||||||
? "bg-blue-50 border-l-2 border-blue-400"
|
? "border-l-2 border-blue-400 bg-blue-50"
|
||||||
: "bg-green-50 border-l-2 border-green-400"
|
: "border-l-2 border-green-400 bg-green-50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="mb-1 flex items-center justify-between">
|
||||||
<Badge
|
<Badge
|
||||||
variant={msg.direction === "sent" ? "default" : "secondary"}
|
variant={
|
||||||
|
msg.direction === "sent" ? "default" : "secondary"
|
||||||
|
}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
{msg.direction === "sent" ? "SENT" : "RECEIVED"}
|
{msg.direction === "sent" ? "SENT" : "RECEIVED"}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-muted-foreground">{msg.timestamp}</span>
|
<span className="text-muted-foreground">
|
||||||
|
{msg.timestamp}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<pre className="whitespace-pre-wrap text-xs">
|
<pre className="text-xs whitespace-pre-wrap">
|
||||||
{JSON.stringify(msg.data, null, 2)}
|
{JSON.stringify(msg.data, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{messages.length === 0 && (
|
{messages.length === 0 && (
|
||||||
<div className="text-center text-muted-foreground py-8">
|
<div className="text-muted-foreground py-8 text-center">
|
||||||
No messages yet. Connect and send a test message to see data here.
|
No messages yet. Connect and send a test message to see data
|
||||||
|
here.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ export default function HelpCenterPage() {
|
|||||||
<Card key={index}>
|
<Card key={index}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="p-2 bg-primary/10 rounded-lg">
|
<div className="bg-primary/10 rounded-lg p-2">
|
||||||
<guide.icon className="h-5 w-5 text-primary" />
|
<guide.icon className="text-primary h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-xl">{guide.title}</CardTitle>
|
<CardTitle className="text-xl">{guide.title}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,11 +86,11 @@ export default function HelpCenterPage() {
|
|||||||
<li key={i}>
|
<li key={i}>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
className="h-auto p-0 text-foreground hover:text-primary justify-start font-normal"
|
className="text-foreground hover:text-primary h-auto justify-start p-0 font-normal"
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<Link href={item.href}>
|
<Link href={item.href}>
|
||||||
<FileText className="mr-2 h-4 w-4 text-muted-foreground" />
|
<FileText className="text-muted-foreground mr-2 h-4 w-4" />
|
||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -103,7 +103,7 @@ export default function HelpCenterPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-2xl font-bold tracking-tight mb-4">
|
<h2 className="mb-4 text-2xl font-bold tracking-tight">
|
||||||
Video Tutorials
|
Video Tutorials
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid gap-6 md:grid-cols-3">
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
@@ -113,8 +113,8 @@ export default function HelpCenterPage() {
|
|||||||
"ROS2 Integration Deep Dive",
|
"ROS2 Integration Deep Dive",
|
||||||
].map((title, i) => (
|
].map((title, i) => (
|
||||||
<Card key={i} className="overflow-hidden">
|
<Card key={i} className="overflow-hidden">
|
||||||
<div className="aspect-video bg-muted flex items-center justify-center relative group cursor-pointer hover:bg-muted/80 transition-colors">
|
<div className="bg-muted group hover:bg-muted/80 relative flex aspect-video cursor-pointer items-center justify-center transition-colors">
|
||||||
<PlayCircle className="h-12 w-12 text-muted-foreground group-hover:text-primary transition-colors" />
|
<PlayCircle className="text-muted-foreground group-hover:text-primary h-12 w-12 transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
<CardHeader className="p-4">
|
<CardHeader className="p-4">
|
||||||
<CardTitle className="text-base">{title}</CardTitle>
|
<CardTitle className="text-base">{title}</CardTitle>
|
||||||
@@ -124,13 +124,14 @@ export default function HelpCenterPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 bg-muted/50 rounded-xl p-8 text-center border">
|
<div className="bg-muted/50 mt-8 rounded-xl border p-8 text-center">
|
||||||
<div className="mx-auto w-12 h-12 bg-background rounded-full flex items-center justify-center mb-4 shadow-sm">
|
<div className="bg-background mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full shadow-sm">
|
||||||
<HelpCircle className="h-6 w-6 text-primary" />
|
<HelpCircle className="text-primary h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-semibold mb-2">Still need help?</h2>
|
<h2 className="mb-2 text-xl font-semibold">Still need help?</h2>
|
||||||
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
|
<p className="text-muted-foreground mx-auto mb-6 max-w-md">
|
||||||
Contact your system administrator or check the official documentation for technical support.
|
Contact your system administrator or check the official documentation
|
||||||
|
for technical support.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-center gap-4">
|
<div className="flex justify-center gap-4">
|
||||||
<Button variant="outline" className="gap-2">
|
<Button variant="outline" className="gap-2">
|
||||||
|
|||||||
@@ -365,7 +365,9 @@ export default function NaoTestPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Walk Speed: {(walkSpeed[0] ?? 0).toFixed(2)} m/s</Label>
|
<Label>
|
||||||
|
Walk Speed: {(walkSpeed[0] ?? 0).toFixed(2)} m/s
|
||||||
|
</Label>
|
||||||
<Slider
|
<Slider
|
||||||
value={walkSpeed}
|
value={walkSpeed}
|
||||||
onValueChange={setWalkSpeed}
|
onValueChange={setWalkSpeed}
|
||||||
@@ -375,7 +377,9 @@ export default function NaoTestPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Turn Speed: {(turnSpeed[0] ?? 0).toFixed(2)} rad/s</Label>
|
<Label>
|
||||||
|
Turn Speed: {(turnSpeed[0] ?? 0).toFixed(2)} rad/s
|
||||||
|
</Label>
|
||||||
<Slider
|
<Slider
|
||||||
value={turnSpeed}
|
value={turnSpeed}
|
||||||
onValueChange={setTurnSpeed}
|
onValueChange={setTurnSpeed}
|
||||||
@@ -415,7 +419,9 @@ export default function NaoTestPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Head Yaw: {(headYaw[0] ?? 0).toFixed(2)} rad</Label>
|
<Label>
|
||||||
|
Head Yaw: {(headYaw[0] ?? 0).toFixed(2)} rad
|
||||||
|
</Label>
|
||||||
<Slider
|
<Slider
|
||||||
value={headYaw}
|
value={headYaw}
|
||||||
onValueChange={setHeadYaw}
|
onValueChange={setHeadYaw}
|
||||||
@@ -425,7 +431,9 @@ export default function NaoTestPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Head Pitch: {(headPitch[0] ?? 0).toFixed(2)} rad</Label>
|
<Label>
|
||||||
|
Head Pitch: {(headPitch[0] ?? 0).toFixed(2)} rad
|
||||||
|
</Label>
|
||||||
<Slider
|
<Slider
|
||||||
value={headPitch}
|
value={headPitch}
|
||||||
onValueChange={setHeadPitch}
|
onValueChange={setHeadPitch}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
Lock,
|
Lock,
|
||||||
UserCog,
|
UserCog,
|
||||||
Mail,
|
Mail,
|
||||||
Fingerprint
|
Fingerprint,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
@@ -43,7 +43,7 @@ interface ProfileUser {
|
|||||||
|
|
||||||
function ProfileContent({ user }: { user: ProfileUser }) {
|
function ProfileContent({ user }: { user: ProfileUser }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 animate-in fade-in duration-500">
|
<div className="animate-in fade-in space-y-8 duration-500">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={user.name ?? "User"}
|
title={user.name ?? "User"}
|
||||||
description={user.email}
|
description={user.email}
|
||||||
@@ -60,17 +60,18 @@ function ProfileContent({ user }: { user: ProfileUser }) {
|
|||||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
||||||
{/* Main Content (Left Column) */}
|
{/* Main Content (Left Column) */}
|
||||||
<div className="space-y-8 lg:col-span-2">
|
<div className="space-y-8 lg:col-span-2">
|
||||||
|
|
||||||
{/* Personal Information */}
|
{/* Personal Information */}
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="flex items-center gap-2 pb-2 border-b">
|
<div className="flex items-center gap-2 border-b pb-2">
|
||||||
<User className="h-5 w-5 text-primary" />
|
<User className="text-primary h-5 w-5" />
|
||||||
<h3 className="text-lg font-semibold">Personal Information</h3>
|
<h3 className="text-lg font-semibold">Personal Information</h3>
|
||||||
</div>
|
</div>
|
||||||
<Card className="border-border/60 hover:border-border transition-colors">
|
<Card className="border-border/60 hover:border-border transition-colors">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Contact Details</CardTitle>
|
<CardTitle className="text-base">Contact Details</CardTitle>
|
||||||
<CardDescription>Update your public profile information</CardDescription>
|
<CardDescription>
|
||||||
|
Update your public profile information
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ProfileEditForm
|
<ProfileEditForm
|
||||||
@@ -87,14 +88,16 @@ function ProfileContent({ user }: { user: ProfileUser }) {
|
|||||||
|
|
||||||
{/* Security */}
|
{/* Security */}
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="flex items-center gap-2 pb-2 border-b">
|
<div className="flex items-center gap-2 border-b pb-2">
|
||||||
<Lock className="h-5 w-5 text-primary" />
|
<Lock className="text-primary h-5 w-5" />
|
||||||
<h3 className="text-lg font-semibold">Security</h3>
|
<h3 className="text-lg font-semibold">Security</h3>
|
||||||
</div>
|
</div>
|
||||||
<Card className="border-border/60 hover:border-border transition-colors">
|
<Card className="border-border/60 hover:border-border transition-colors">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Password</CardTitle>
|
<CardTitle className="text-base">Password</CardTitle>
|
||||||
<CardDescription>Ensure your account stays secure</CardDescription>
|
<CardDescription>
|
||||||
|
Ensure your account stays secure
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<PasswordChangeForm />
|
<PasswordChangeForm />
|
||||||
@@ -105,11 +108,10 @@ function ProfileContent({ user }: { user: ProfileUser }) {
|
|||||||
|
|
||||||
{/* Sidebar (Right Column) */}
|
{/* Sidebar (Right Column) */}
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
|
|
||||||
{/* Permissions */}
|
{/* Permissions */}
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="flex items-center gap-2 pb-2 border-b">
|
<div className="flex items-center gap-2 border-b pb-2">
|
||||||
<Shield className="h-5 w-5 text-primary" />
|
<Shield className="text-primary h-5 w-5" />
|
||||||
<h3 className="text-lg font-semibold">Permissions</h3>
|
<h3 className="text-lg font-semibold">Permissions</h3>
|
||||||
</div>
|
</div>
|
||||||
<Card>
|
<Card>
|
||||||
@@ -119,30 +121,40 @@ function ProfileContent({ user }: { user: ProfileUser }) {
|
|||||||
{user.roles.map((roleInfo, index) => (
|
{user.roles.map((roleInfo, index) => (
|
||||||
<div key={index} className="space-y-2">
|
<div key={index} className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-medium text-sm">{formatRole(roleInfo.role)}</span>
|
<span className="text-sm font-medium">
|
||||||
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
{formatRole(roleInfo.role)}
|
||||||
Since {new Date(roleInfo.grantedAt).toLocaleDateString()}
|
</span>
|
||||||
|
<span className="text-muted-foreground bg-muted rounded px-1.5 py-0.5 text-[10px]">
|
||||||
|
Since{" "}
|
||||||
|
{new Date(roleInfo.grantedAt).toLocaleDateString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||||
{getRoleDescription(roleInfo.role)}
|
{getRoleDescription(roleInfo.role)}
|
||||||
</p>
|
</p>
|
||||||
{index < (user.roles?.length || 0) - 1 && <Separator className="my-2" />}
|
{index < (user.roles?.length || 0) - 1 && (
|
||||||
|
<Separator className="my-2" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="bg-blue-50/50 dark:bg-blue-900/10 p-3 rounded-lg border border-blue-100 dark:border-blue-900/30 text-xs text-muted-foreground mt-4">
|
<div className="text-muted-foreground mt-4 rounded-lg border border-blue-100 bg-blue-50/50 p-3 text-xs dark:border-blue-900/30 dark:bg-blue-900/10">
|
||||||
<div className="flex items-center gap-2 mb-1 text-primary font-medium">
|
<div className="text-primary mb-1 flex items-center gap-2 font-medium">
|
||||||
<Shield className="h-3 w-3" />
|
<Shield className="h-3 w-3" />
|
||||||
<span>Role Management</span>
|
<span>Role Management</span>
|
||||||
</div>
|
</div>
|
||||||
System roles are managed by administrators. Contact support if you need access adjustments.
|
System roles are managed by administrators. Contact
|
||||||
|
support if you need access adjustments.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-4">
|
<div className="py-4 text-center">
|
||||||
<p className="text-sm font-medium">No Roles Assigned</p>
|
<p className="text-sm font-medium">No Roles Assigned</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">Contact an admin to request access.</p>
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
<Button size="sm" variant="outline" className="mt-3 w-full">Request Access</Button>
|
Contact an admin to request access.
|
||||||
|
</p>
|
||||||
|
<Button size="sm" variant="outline" className="mt-3 w-full">
|
||||||
|
Request Access
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -151,26 +163,42 @@ function ProfileContent({ user }: { user: ProfileUser }) {
|
|||||||
|
|
||||||
{/* Data & Privacy */}
|
{/* Data & Privacy */}
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="flex items-center gap-2 pb-2 border-b">
|
<div className="flex items-center gap-2 border-b pb-2">
|
||||||
<Download className="h-5 w-5 text-primary" />
|
<Download className="text-primary h-5 w-5" />
|
||||||
<h3 className="text-lg font-semibold">Data & Privacy</h3>
|
<h3 className="text-lg font-semibold">Data & Privacy</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border-destructive/10 bg-destructive/5 overflow-hidden">
|
<Card className="border-destructive/10 bg-destructive/5 overflow-hidden">
|
||||||
<CardContent className="pt-6 space-y-4">
|
<CardContent className="space-y-4 pt-6">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-semibold mb-1">Export Data</h4>
|
<h4 className="mb-1 text-sm font-semibold">Export Data</h4>
|
||||||
<p className="text-xs text-muted-foreground mb-3">Download a copy of your personal data.</p>
|
<p className="text-muted-foreground mb-3 text-xs">
|
||||||
<Button variant="outline" size="sm" className="w-full bg-background" disabled>
|
Download a copy of your personal data.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="bg-background w-full"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
<Download className="mr-2 h-3 w-3" />
|
<Download className="mr-2 h-3 w-3" />
|
||||||
Download Archive
|
Download Archive
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Separator className="bg-destructive/10" />
|
<Separator className="bg-destructive/10" />
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-semibold text-destructive mb-1">Delete Account</h4>
|
<h4 className="text-destructive mb-1 text-sm font-semibold">
|
||||||
<p className="text-xs text-muted-foreground mb-3">This action is irreversible.</p>
|
Delete Account
|
||||||
<Button variant="destructive" size="sm" className="w-full" disabled>
|
</h4>
|
||||||
|
<p className="text-muted-foreground mb-3 text-xs">
|
||||||
|
This action is irreversible.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
<Trash2 className="mr-2 h-3 w-3" />
|
<Trash2 className="mr-2 h-3 w-3" />
|
||||||
Delete Account
|
Delete Account
|
||||||
</Button>
|
</Button>
|
||||||
@@ -193,7 +221,11 @@ export default function ProfilePage() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (status === "loading") {
|
if (status === "loading") {
|
||||||
return <div className="p-8 text-muted-foreground animate-pulse">Loading profile...</div>;
|
return (
|
||||||
|
<div className="text-muted-foreground animate-pulse p-8">
|
||||||
|
Loading profile...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default function StudyAnalyticsPage() {
|
|||||||
// Fetch list of trials
|
// Fetch list of trials
|
||||||
const { data: trialsList, isLoading } = api.trials.list.useQuery(
|
const { data: trialsList, isLoading } = api.trials.list.useQuery(
|
||||||
{ studyId, limit: 100 },
|
{ studyId, limit: 100 },
|
||||||
{ enabled: !!studyId }
|
{ enabled: !!studyId },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set breadcrumbs
|
// Set breadcrumbs
|
||||||
@@ -49,19 +49,23 @@ export default function StudyAnalyticsPage() {
|
|||||||
<div className="bg-transparent">
|
<div className="bg-transparent">
|
||||||
<Suspense fallback={<div>Loading analytics...</div>}>
|
<Suspense fallback={<div>Loading analytics...</div>}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex h-64 items-center justify-center">
|
||||||
<div className="flex flex-col items-center gap-2 animate-pulse">
|
<div className="flex animate-pulse flex-col items-center gap-2">
|
||||||
<div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
<div className="border-primary h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
|
||||||
<span className="text-muted-foreground text-sm">Loading session data...</span>
|
<span className="text-muted-foreground text-sm">
|
||||||
|
Loading session data...
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<StudyAnalyticsDataTable data={(trialsList ?? []).map(t => ({
|
<StudyAnalyticsDataTable
|
||||||
|
data={(trialsList ?? []).map((t) => ({
|
||||||
...t,
|
...t,
|
||||||
startedAt: t.startedAt ? new Date(t.startedAt) : null,
|
startedAt: t.startedAt ? new Date(t.startedAt) : null,
|
||||||
completedAt: t.completedAt ? new Date(t.completedAt) : null,
|
completedAt: t.completedAt ? new Date(t.completedAt) : null,
|
||||||
createdAt: new Date(t.createdAt),
|
createdAt: new Date(t.createdAt),
|
||||||
}))} />
|
}))}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function DesignerPageClient({
|
|||||||
const stepCount = initialDesign.steps.length;
|
const stepCount = initialDesign.steps.length;
|
||||||
const actionCount = initialDesign.steps.reduce(
|
const actionCount = initialDesign.steps.reduce(
|
||||||
(sum, step) => sum + step.actions.length,
|
(sum, step) => sum + step.actions.length,
|
||||||
0
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { stepCount, actionCount };
|
return { stepCount, actionCount };
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ export default async function ExperimentDesignerPage({
|
|||||||
}: ExperimentDesignerPageProps) {
|
}: ExperimentDesignerPageProps) {
|
||||||
try {
|
try {
|
||||||
const resolvedParams = await params;
|
const resolvedParams = await params;
|
||||||
const experiment = await api.experiments.get({ id: resolvedParams.experimentId });
|
const experiment = await api.experiments.get({
|
||||||
|
id: resolvedParams.experimentId,
|
||||||
|
});
|
||||||
|
|
||||||
if (!experiment) {
|
if (!experiment) {
|
||||||
notFound();
|
notFound();
|
||||||
@@ -220,7 +222,9 @@ export default async function ExperimentDesignerPage({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const actions: ExperimentAction[] = s.actions.map((a) => hydrateAction(a));
|
const actions: ExperimentAction[] = s.actions.map((a) =>
|
||||||
|
hydrateAction(a),
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
@@ -278,7 +282,9 @@ export async function generateMetadata({
|
|||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const resolvedParams = await params;
|
const resolvedParams = await params;
|
||||||
const experiment = await api.experiments.get({ id: resolvedParams.experimentId });
|
const experiment = await api.experiments.get({
|
||||||
|
id: resolvedParams.experimentId,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: `${experiment?.name} - Designer | HRIStudio`,
|
title: `${experiment?.name} - Designer | HRIStudio`,
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
import { Calendar, Clock, Edit, Play, Settings, Users, TestTube } from "lucide-react";
|
import {
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
Edit,
|
||||||
|
Play,
|
||||||
|
Settings,
|
||||||
|
Users,
|
||||||
|
TestTube,
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -94,9 +102,10 @@ export default function ExperimentDetailPage({
|
|||||||
const [experiment, setExperiment] = useState<Experiment | null>(null);
|
const [experiment, setExperiment] = useState<Experiment | null>(null);
|
||||||
const [trials, setTrials] = useState<Trial[]>([]);
|
const [trials, setTrials] = useState<Trial[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [resolvedParams, setResolvedParams] = useState<{ id: string; experimentId: string } | null>(
|
const [resolvedParams, setResolvedParams] = useState<{
|
||||||
null,
|
id: string;
|
||||||
);
|
experimentId: string;
|
||||||
|
} | null>(null);
|
||||||
const { selectStudy } = useStudyManagement();
|
const { selectStudy } = useStudyManagement();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -192,13 +201,15 @@ export default function ExperimentDetailPage({
|
|||||||
{
|
{
|
||||||
label: statusInfo?.label ?? "Unknown",
|
label: statusInfo?.label ?? "Unknown",
|
||||||
variant: statusInfo?.variant ?? "secondary",
|
variant: statusInfo?.variant ?? "secondary",
|
||||||
}
|
},
|
||||||
]}
|
]}
|
||||||
actions={
|
actions={
|
||||||
canEdit ? (
|
canEdit ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}>
|
<Link
|
||||||
|
href={`/studies/${studyId}/experiments/${experimentId}/designer`}
|
||||||
|
>
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
Designer
|
Designer
|
||||||
</Link>
|
</Link>
|
||||||
@@ -263,7 +274,9 @@ export default function ExperimentDetailPage({
|
|||||||
actions={
|
actions={
|
||||||
canEdit && (
|
canEdit && (
|
||||||
<Button asChild variant="outline" size="sm">
|
<Button asChild variant="outline" size="sm">
|
||||||
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}>
|
<Link
|
||||||
|
href={`/studies/${studyId}/experiments/${experimentId}/designer`}
|
||||||
|
>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Edit Protocol
|
Edit Protocol
|
||||||
</Link>
|
</Link>
|
||||||
@@ -294,7 +307,9 @@ export default function ExperimentDetailPage({
|
|||||||
action={
|
action={
|
||||||
canEdit && (
|
canEdit && (
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}>
|
<Link
|
||||||
|
href={`/studies/${studyId}/experiments/${experimentId}/designer`}
|
||||||
|
>
|
||||||
Open Designer
|
Open Designer
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
317
src/app/(dashboard)/studies/[id]/forms/page.tsx
Normal file
317
src/app/(dashboard)/studies/[id]/forms/page.tsx
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { FileText, Loader2, Plus, Download, Edit2, Eye, Save } from "lucide-react";
|
||||||
|
import {
|
||||||
|
EntityView,
|
||||||
|
EntityViewHeader,
|
||||||
|
EntityViewSection,
|
||||||
|
EmptyState,
|
||||||
|
} from "~/components/ui/entity-view";
|
||||||
|
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { PageHeader } from "~/components/ui/page-header";
|
||||||
|
import { useEditor, EditorContent } from '@tiptap/react';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import { Markdown } from 'tiptap-markdown';
|
||||||
|
import { Table } from '@tiptap/extension-table';
|
||||||
|
import { TableRow } from '@tiptap/extension-table-row';
|
||||||
|
import { TableCell } from '@tiptap/extension-table-cell';
|
||||||
|
import { TableHeader } from '@tiptap/extension-table-header';
|
||||||
|
import { Bold, Italic, List, ListOrdered, Heading1, Heading2, Quote, Table as TableIcon } from "lucide-react";
|
||||||
|
import { downloadPdfFromHtml } from "~/lib/pdf-generator";
|
||||||
|
|
||||||
|
const Toolbar = ({ editor }: { editor: any }) => {
|
||||||
|
if (!editor) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-input bg-transparent rounded-tr-md rounded-tl-md p-1 flex items-center gap-1 flex-wrap">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||||
|
disabled={!editor.can().chain().focus().toggleBold().run()}
|
||||||
|
className={editor.isActive('bold') ? 'bg-muted' : ''}
|
||||||
|
>
|
||||||
|
<Bold className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||||
|
disabled={!editor.can().chain().focus().toggleItalic().run()}
|
||||||
|
className={editor.isActive('italic') ? 'bg-muted' : ''}
|
||||||
|
>
|
||||||
|
<Italic className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="w-[1px] h-6 bg-border mx-1" />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||||
|
className={editor.isActive('heading', { level: 1 }) ? 'bg-muted' : ''}
|
||||||
|
>
|
||||||
|
<Heading1 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||||
|
className={editor.isActive('heading', { level: 2 }) ? 'bg-muted' : ''}
|
||||||
|
>
|
||||||
|
<Heading2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="w-[1px] h-6 bg-border mx-1" />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
|
className={editor.isActive('bulletList') ? 'bg-muted' : ''}
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||||
|
className={editor.isActive('orderedList') ? 'bg-muted' : ''}
|
||||||
|
>
|
||||||
|
<ListOrdered className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||||
|
className={editor.isActive('blockquote') ? 'bg-muted' : ''}
|
||||||
|
>
|
||||||
|
<Quote className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="w-[1px] h-6 bg-border mx-1" />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()}
|
||||||
|
>
|
||||||
|
<TableIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StudyFormsPageProps {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StudyFormsPage({ params }: StudyFormsPageProps) {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(null);
|
||||||
|
const [editorTarget, setEditorTarget] = useState<string>("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const resolveParams = async () => {
|
||||||
|
const resolved = await params;
|
||||||
|
setResolvedParams(resolved);
|
||||||
|
};
|
||||||
|
void resolveParams();
|
||||||
|
}, [params]);
|
||||||
|
|
||||||
|
const { data: study } = api.studies.get.useQuery(
|
||||||
|
{ id: resolvedParams?.id ?? "" },
|
||||||
|
{ enabled: !!resolvedParams?.id },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: activeConsentForm, refetch: refetchConsentForm } =
|
||||||
|
api.studies.getActiveConsentForm.useQuery(
|
||||||
|
{ studyId: resolvedParams?.id ?? "" },
|
||||||
|
{ enabled: !!resolvedParams?.id },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only sync once when form loads to avoid resetting user edits
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeConsentForm && !editorTarget) {
|
||||||
|
setEditorTarget(activeConsentForm.content);
|
||||||
|
}
|
||||||
|
}, [activeConsentForm, editorTarget]);
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit,
|
||||||
|
Table.configure({
|
||||||
|
resizable: true,
|
||||||
|
}),
|
||||||
|
TableRow,
|
||||||
|
TableHeader,
|
||||||
|
TableCell,
|
||||||
|
Markdown.configure({
|
||||||
|
transformPastedText: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
content: editorTarget || '',
|
||||||
|
immediatelyRender: false,
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
// @ts-ignore
|
||||||
|
setEditorTarget(editor.storage.markdown.getMarkdown());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync Tiptap when editorTarget is set (e.g., from DB) but make sure not to overwrite active edits
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor && editorTarget && editor.isEmpty) {
|
||||||
|
editor.commands.setContent(editorTarget);
|
||||||
|
}
|
||||||
|
}, [editorTarget, editor]);
|
||||||
|
|
||||||
|
const generateConsentMutation = api.studies.generateConsentForm.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast.success("Default Consent Form Generated!");
|
||||||
|
setEditorTarget(data.content);
|
||||||
|
editor?.commands.setContent(data.content);
|
||||||
|
void refetchConsentForm();
|
||||||
|
void utils.studies.getActivity.invalidate({ studyId: resolvedParams?.id ?? "" });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Error generating consent form", { description: error.message });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateConsentMutation = api.studies.updateConsentForm.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Consent Form Saved Successfully!");
|
||||||
|
void refetchConsentForm();
|
||||||
|
void utils.studies.getActivity.invalidate({ studyId: resolvedParams?.id ?? "" });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Error saving consent form", { description: error.message });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDownloadConsent = async () => {
|
||||||
|
if (!activeConsentForm || !study || !editor) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
toast.loading("Generating Document...", { id: "pdf-gen" });
|
||||||
|
await downloadPdfFromHtml(editor.getHTML(), {
|
||||||
|
filename: `Consent_Form_${study.name.replace(/\s+/g, "_")}_v${activeConsentForm.version}.pdf`
|
||||||
|
});
|
||||||
|
toast.success("Document Downloaded Successfully!", { id: "pdf-gen" });
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Error generating PDF", { id: "pdf-gen" });
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useBreadcrumbsEffect([
|
||||||
|
{ label: "Dashboard", href: "/dashboard" },
|
||||||
|
{ label: "Studies", href: "/studies" },
|
||||||
|
{ label: study?.name ?? "Study", href: `/studies/${resolvedParams?.id}` },
|
||||||
|
{ label: "Forms" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!study) return <div>Loading...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EntityView>
|
||||||
|
<PageHeader
|
||||||
|
title="Study Forms"
|
||||||
|
description="Manage consent forms and future questionnaires for this study"
|
||||||
|
icon={FileText}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-8">
|
||||||
|
<EntityViewSection
|
||||||
|
title="Consent Document"
|
||||||
|
icon="FileText"
|
||||||
|
description="Design and manage the consent form that participants must sign before participating in your trials."
|
||||||
|
actions={
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => generateConsentMutation.mutate({ studyId: study.id })}
|
||||||
|
disabled={generateConsentMutation.isPending || updateConsentMutation.isPending}
|
||||||
|
>
|
||||||
|
{generateConsentMutation.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Generate Default Template
|
||||||
|
</Button>
|
||||||
|
{activeConsentForm && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => updateConsentMutation.mutate({ studyId: study.id, content: editorTarget })}
|
||||||
|
disabled={updateConsentMutation.isPending || editorTarget === activeConsentForm.content}
|
||||||
|
>
|
||||||
|
{updateConsentMutation.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{activeConsentForm ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium leading-none">
|
||||||
|
{activeConsentForm.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
v{activeConsentForm.version} • Status: Active
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleDownloadConsent}
|
||||||
|
>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Download PDF
|
||||||
|
</Button>
|
||||||
|
<Badge variant="outline" className="bg-green-50 text-green-700 hover:bg-green-50">Active</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full flex justify-center bg-muted/30 p-8 rounded-md border border-border overflow-hidden">
|
||||||
|
<div className="max-w-4xl w-full bg-white dark:bg-card shadow-xl ring-1 ring-border rounded-sm flex flex-col">
|
||||||
|
<div className="border-b border-border bg-muted/50 dark:bg-muted/10">
|
||||||
|
<Toolbar editor={editor} />
|
||||||
|
</div>
|
||||||
|
<div className="min-h-[850px] px-16 py-20 text-sm editor-container bg-white dark:bg-card">
|
||||||
|
<EditorContent editor={editor} className="prose prose-sm dark:prose-invert max-w-none h-full outline-none focus:outline-none focus-visible:outline-none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon="FileText"
|
||||||
|
title="No Consent Form"
|
||||||
|
description="Generate a boilerplate consent form for this study to download and collect signatures."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</EntityViewSection>
|
||||||
|
</div>
|
||||||
|
</EntityView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -71,6 +71,7 @@ type Member = {
|
|||||||
|
|
||||||
export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
const utils = api.useUtils();
|
||||||
const [study, setStudy] = useState<Study | null>(null);
|
const [study, setStudy] = useState<Study | null>(null);
|
||||||
const [members, setMembers] = useState<Member[]>([]);
|
const [members, setMembers] = useState<Member[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -176,7 +177,7 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
|||||||
{
|
{
|
||||||
label: statusInfo?.label ?? "Unknown",
|
label: statusInfo?.label ?? "Unknown",
|
||||||
variant: statusInfo?.variant ?? "secondary",
|
variant: statusInfo?.variant ?? "secondary",
|
||||||
}
|
},
|
||||||
]}
|
]}
|
||||||
actions={
|
actions={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -301,12 +302,18 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Button asChild variant="outline" size="sm">
|
<Button asChild variant="outline" size="sm">
|
||||||
<Link href={`/studies/${study.id}/experiments/${experiment.id}/designer`}>
|
<Link
|
||||||
|
href={`/studies/${study.id}/experiments/${experiment.id}/designer`}
|
||||||
|
>
|
||||||
Design
|
Design
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild variant="outline" size="sm">
|
<Button asChild variant="outline" size="sm">
|
||||||
<Link href={`/studies/${study.id}/experiments/${experiment.id}`}>View</Link>
|
<Link
|
||||||
|
href={`/studies/${study.id}/experiments/${experiment.id}`}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ import {
|
|||||||
EntityViewSection,
|
EntityViewSection,
|
||||||
} from "~/components/ui/entity-view";
|
} from "~/components/ui/entity-view";
|
||||||
import { ParticipantDocuments } from "./participant-documents";
|
import { ParticipantDocuments } from "./participant-documents";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "~/components/ui/card";
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
} from "~/components/ui/card";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
@@ -45,12 +51,14 @@ export default async function ParticipantDetailPage({
|
|||||||
badges={[
|
badges={[
|
||||||
{
|
{
|
||||||
label: participant.consentGiven ? "Consent Given" : "No Consent",
|
label: participant.consentGiven ? "Consent Given" : "No Consent",
|
||||||
variant: participant.consentGiven ? "default" : "secondary"
|
variant: participant.consentGiven ? "default" : "secondary",
|
||||||
}
|
},
|
||||||
]}
|
]}
|
||||||
actions={
|
actions={
|
||||||
<Button asChild variant="outline" size="sm">
|
<Button asChild variant="outline" size="sm">
|
||||||
<Link href={`/studies/${studyId}/participants/${participantId}/edit`}>
|
<Link
|
||||||
|
href={`/studies/${studyId}/participants/${participantId}/edit`}
|
||||||
|
>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Edit Participant
|
Edit Participant
|
||||||
</Link>
|
</Link>
|
||||||
@@ -65,10 +73,12 @@ export default async function ParticipantDetailPage({
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="overview">
|
<TabsContent value="overview">
|
||||||
<div className="grid gap-6 grid-cols-1">
|
<div className="grid grid-cols-1 gap-6">
|
||||||
<ParticipantConsentManager
|
<ParticipantConsentManager
|
||||||
studyId={studyId}
|
studyId={studyId}
|
||||||
participantId={participantId}
|
participantId={participantId}
|
||||||
|
participantName={participant.name}
|
||||||
|
participantCode={participant.participantCode}
|
||||||
consentGiven={participant.consentGiven}
|
consentGiven={participant.consentGiven}
|
||||||
consentDate={participant.consentDate}
|
consentDate={participant.consentDate}
|
||||||
existingConsent={participant.consents[0] ?? null}
|
existingConsent={participant.consents[0] ?? null}
|
||||||
@@ -76,33 +86,54 @@ export default async function ParticipantDetailPage({
|
|||||||
<EntityViewSection title="Participant Information" icon="Info">
|
<EntityViewSection title="Participant Information" icon="Info">
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm md:grid-cols-4">
|
<div className="grid grid-cols-2 gap-4 text-sm md:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground block mb-1">Code</span>
|
<span className="text-muted-foreground mb-1 block">Code</span>
|
||||||
<span className="font-medium text-base">{participant.participantCode}</span>
|
<span className="text-base font-medium">
|
||||||
|
{participant.participantCode}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground block mb-1">Name</span>
|
<span className="text-muted-foreground mb-1 block">Name</span>
|
||||||
<span className="font-medium text-base">{participant.name || "-"}</span>
|
<span className="text-base font-medium">
|
||||||
|
{participant.name || "-"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground block mb-1">Email</span>
|
<span className="text-muted-foreground mb-1 block">
|
||||||
<span className="font-medium text-base">{participant.email || "-"}</span>
|
Email
|
||||||
|
</span>
|
||||||
|
<span className="text-base font-medium">
|
||||||
|
{participant.email || "-"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground block mb-1">Added</span>
|
<span className="text-muted-foreground mb-1 block">
|
||||||
<span className="font-medium text-base">{new Date(participant.createdAt).toLocaleDateString()}</span>
|
Added
|
||||||
|
</span>
|
||||||
|
<span className="text-base font-medium">
|
||||||
|
{new Date(participant.createdAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground block mb-1">Age</span>
|
<span className="text-muted-foreground mb-1 block">Age</span>
|
||||||
<span className="font-medium text-base">{(participant.demographics as any)?.age || "-"}</span>
|
<span className="text-base font-medium">
|
||||||
|
{(participant.demographics as any)?.age || "-"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground block mb-1">Gender</span>
|
<span className="text-muted-foreground mb-1 block">
|
||||||
<span className="font-medium capitalize text-base">{(participant.demographics as any)?.gender?.replace("_", " ") || "-"}</span>
|
Gender
|
||||||
|
</span>
|
||||||
|
<span className="text-base font-medium capitalize">
|
||||||
|
{(participant.demographics as any)?.gender?.replace(
|
||||||
|
"_",
|
||||||
|
" ",
|
||||||
|
) || "-"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</EntityViewSection>
|
</EntityViewSection>
|
||||||
|
|||||||
@@ -18,11 +18,14 @@ interface ParticipantDocumentsProps {
|
|||||||
participantId: string;
|
participantId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ParticipantDocuments({ participantId }: ParticipantDocumentsProps) {
|
export function ParticipantDocuments({
|
||||||
|
participantId,
|
||||||
|
}: ParticipantDocumentsProps) {
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
const { data: documents, isLoading } = api.files.listParticipantDocuments.useQuery({
|
const { data: documents, isLoading } =
|
||||||
|
api.files.listParticipantDocuments.useQuery({
|
||||||
participantId,
|
participantId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,7 +93,9 @@ export function ParticipantDocuments({ participantId }: ParticipantDocumentsProp
|
|||||||
// Let's implement a quick procedure call right here via client or assume the server router has it.
|
// 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.
|
// I added getDownloadUrl to the router in previous steps.
|
||||||
try {
|
try {
|
||||||
const { url } = await utils.client.files.getDownloadUrl.query({ storagePath });
|
const { url } = await utils.client.files.getDownloadUrl.query({
|
||||||
|
storagePath,
|
||||||
|
});
|
||||||
window.open(url, "_blank");
|
window.open(url, "_blank");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error("Could not get download URL");
|
toast.error("Could not get download URL");
|
||||||
@@ -131,10 +136,10 @@ export function ParticipantDocuments({ participantId }: ParticipantDocumentsProp
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex justify-center p-4">
|
<div className="flex justify-center p-4">
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : documents?.length === 0 ? (
|
) : documents?.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-8 text-center">
|
||||||
<FileText className="mb-2 h-8 w-8 opacity-50" />
|
<FileText className="mb-2 h-8 w-8 opacity-50" />
|
||||||
<p>No documents uploaded yet.</p>
|
<p>No documents uploaded yet.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,7 +148,7 @@ export function ParticipantDocuments({ participantId }: ParticipantDocumentsProp
|
|||||||
{documents?.map((doc) => (
|
{documents?.map((doc) => (
|
||||||
<div
|
<div
|
||||||
key={doc.id}
|
key={doc.id}
|
||||||
className="flex items-center justify-between rounded-lg border p-3 hover:bg-muted/50"
|
className="hover:bg-muted/50 flex items-center justify-between rounded-lg border p-3"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="rounded-md bg-blue-50 p-2">
|
<div className="rounded-md bg-blue-50 p-2">
|
||||||
@@ -151,8 +156,9 @@ export function ParticipantDocuments({ participantId }: ParticipantDocumentsProp
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{doc.name}</p>
|
<p className="font-medium">{doc.name}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-muted-foreground text-xs">
|
||||||
{formatBytes(doc.fileSize ?? 0)} • {new Date(doc.createdAt).toLocaleDateString()}
|
{formatBytes(doc.fileSize ?? 0)} •{" "}
|
||||||
|
{new Date(doc.createdAt).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -169,7 +175,9 @@ export function ParticipantDocuments({ participantId }: ParticipantDocumentsProp
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm("Are you sure you want to delete this file?")) {
|
if (
|
||||||
|
confirm("Are you sure you want to delete this file?")
|
||||||
|
) {
|
||||||
deleteDocument.mutate({ id: doc.id });
|
deleteDocument.mutate({ id: doc.id });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -92,11 +92,12 @@ function AnalysisPageContent() {
|
|||||||
completedAt: trial.completedAt ? new Date(trial.completedAt) : null,
|
completedAt: trial.completedAt ? new Date(trial.completedAt) : null,
|
||||||
eventCount: (trial as any).eventCount,
|
eventCount: (trial as any).eventCount,
|
||||||
mediaCount: (trial as any).mediaCount,
|
mediaCount: (trial as any).mediaCount,
|
||||||
media: trial.media?.map(m => ({
|
media:
|
||||||
|
trial.media?.map((m) => ({
|
||||||
...m,
|
...m,
|
||||||
mediaType: m.mediaType ?? "video",
|
mediaType: m.mediaType ?? "video",
|
||||||
format: m.format ?? undefined,
|
format: m.format ?? undefined,
|
||||||
contentType: m.contentType ?? undefined
|
contentType: m.contentType ?? undefined,
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,14 @@
|
|||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { Suspense, useEffect } from "react";
|
import { Suspense, useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Play, Zap, ArrowLeft, User, FlaskConical, LineChart } from "lucide-react";
|
import {
|
||||||
|
Play,
|
||||||
|
Zap,
|
||||||
|
ArrowLeft,
|
||||||
|
User,
|
||||||
|
FlaskConical,
|
||||||
|
LineChart,
|
||||||
|
} from "lucide-react";
|
||||||
import { PageHeader } from "~/components/ui/page-header";
|
import { PageHeader } from "~/components/ui/page-header";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
@@ -144,7 +151,7 @@ function TrialDetailContent() {
|
|||||||
{
|
{
|
||||||
label: trial.status.replace("_", " ").toUpperCase(),
|
label: trial.status.replace("_", " ").toUpperCase(),
|
||||||
variant: getStatusBadgeVariant(trial.status),
|
variant: getStatusBadgeVariant(trial.status),
|
||||||
}
|
},
|
||||||
]}
|
]}
|
||||||
actions={
|
actions={
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|||||||
@@ -211,11 +211,7 @@ function WizardPageContent() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return <div>{renderView()}</div>;
|
||||||
<div>
|
|
||||||
{renderView()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TrialWizardPage() {
|
export default function TrialWizardPage() {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const handler = (req: NextRequest) =>
|
|||||||
env.NODE_ENV === "development"
|
env.NODE_ENV === "development"
|
||||||
? ({ path, error }) => {
|
? ({ path, error }) => {
|
||||||
console.error(
|
console.error(
|
||||||
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
|
`[tRPC Error] tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle
|
CardTitle,
|
||||||
} from "~/components/ui/card";
|
} from "~/components/ui/card";
|
||||||
import { Checkbox } from "~/components/ui/checkbox";
|
import { Checkbox } from "~/components/ui/checkbox";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
@@ -61,25 +61,30 @@ export default function SignInPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4">
|
<div className="bg-background relative flex min-h-screen items-center justify-center overflow-hidden px-4">
|
||||||
{/* Background Gradients */}
|
{/* Background Gradients */}
|
||||||
<div className="absolute top-0 left-1/2 -z-10 h-[500px] w-[1000px] -translate-x-1/2 rounded-full bg-primary/20 blur-3xl opacity-30 dark:opacity-20" />
|
<div className="bg-primary/20 absolute top-0 left-1/2 -z-10 h-[500px] w-[1000px] -translate-x-1/2 rounded-full opacity-30 blur-3xl dark:opacity-20" />
|
||||||
<div className="absolute bottom-0 right-0 -z-10 h-[300px] w-[300px] rounded-full bg-violet-500/10 blur-3xl" />
|
<div className="absolute right-0 bottom-0 -z-10 h-[300px] w-[300px] rounded-full bg-violet-500/10 blur-3xl" />
|
||||||
|
|
||||||
<div className="w-full max-w-md animate-in fade-in zoom-in-95 duration-500">
|
<div className="animate-in fade-in zoom-in-95 w-full max-w-md duration-500">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
<Link href="/" className="inline-flex items-center justify-center transition-opacity hover:opacity-80">
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center justify-center transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
<Logo iconSize="lg" showText={true} />
|
<Logo iconSize="lg" showText={true} />
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="mt-6 text-2xl font-bold tracking-tight text-foreground">Welcome back</h1>
|
<h1 className="text-foreground mt-6 text-2xl font-bold tracking-tight">
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
Welcome back
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
Sign in to your research account
|
Sign in to your research account
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sign In Card */}
|
{/* Sign In Card */}
|
||||||
<Card className="border-muted/40 bg-card/50 backdrop-blur-sm shadow-xl">
|
<Card className="border-muted/40 bg-card/50 shadow-xl backdrop-blur-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Sign In</CardTitle>
|
<CardTitle>Sign In</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -89,7 +94,7 @@ export default function SignInPage() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive font-medium border border-destructive/20">
|
<div className="bg-destructive/15 text-destructive border-destructive/20 rounded-md border p-3 text-sm font-medium">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -111,7 +116,12 @@ export default function SignInPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">Password</Label>
|
||||||
<Link href="#" className="text-xs text-primary hover:underline">Forgot password?</Link>
|
<Link
|
||||||
|
href="#"
|
||||||
|
className="text-primary text-xs hover:underline"
|
||||||
|
>
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
@@ -133,23 +143,30 @@ export default function SignInPage() {
|
|||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
htmlFor="not-robot"
|
htmlFor="not-robot"
|
||||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
className="cursor-pointer text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
>
|
>
|
||||||
I'm not a robot{" "}
|
I'm not a robot{" "}
|
||||||
<span className="text-muted-foreground text-xs italic">(ironic, isn't it?)</span>
|
<span className="text-muted-foreground text-xs italic">
|
||||||
|
(ironic, isn't it?)
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={isLoading} size="lg">
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isLoading}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
{isLoading ? "Signing in..." : "Sign In"}
|
{isLoading ? "Signing in..." : "Sign In"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-6 text-center text-sm text-muted-foreground">
|
<div className="text-muted-foreground mt-6 text-center text-sm">
|
||||||
Don't have an account?{" "}
|
Don't have an account?{" "}
|
||||||
<Link
|
<Link
|
||||||
href="/auth/signup"
|
href="/auth/signup"
|
||||||
className="font-medium text-primary hover:text-primary/80"
|
className="text-primary hover:text-primary/80 font-medium"
|
||||||
>
|
>
|
||||||
Sign up
|
Sign up
|
||||||
</Link>
|
</Link>
|
||||||
@@ -158,7 +175,7 @@ export default function SignInPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="mt-8 text-center text-xs text-muted-foreground">
|
<div className="text-muted-foreground mt-8 text-center text-xs">
|
||||||
<p>
|
<p>
|
||||||
© {new Date().getFullYear()} HRIStudio. All rights reserved.
|
© {new Date().getFullYear()} HRIStudio. All rights reserved.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle
|
CardTitle,
|
||||||
} from "~/components/ui/card";
|
} from "~/components/ui/card";
|
||||||
|
|
||||||
export default function SignOutPage() {
|
export default function SignOutPage() {
|
||||||
@@ -44,7 +44,7 @@ export default function SignOutPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100">
|
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
<div className="mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-b-2 border-blue-600"></div>
|
||||||
<p className="text-slate-600">Loading...</p>
|
<p className="text-slate-600">Loading...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,7 +79,8 @@ export default function SignOutPage() {
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="rounded-md bg-blue-50 p-3 text-sm text-blue-700">
|
<div className="rounded-md bg-blue-50 p-3 text-sm text-blue-700">
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
Currently signed in as: {session.user.name ?? session.user.email}
|
Currently signed in as:{" "}
|
||||||
|
{session.user.name ?? session.user.email}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle
|
CardTitle,
|
||||||
} from "~/components/ui/card";
|
} from "~/components/ui/card";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
@@ -56,25 +56,30 @@ export default function SignUpPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4">
|
<div className="bg-background relative flex min-h-screen items-center justify-center overflow-hidden px-4">
|
||||||
{/* Background Gradients */}
|
{/* Background Gradients */}
|
||||||
<div className="absolute top-0 left-1/2 -z-10 h-[500px] w-[1000px] -translate-x-1/2 rounded-full bg-primary/20 blur-3xl opacity-30 dark:opacity-20" />
|
<div className="bg-primary/20 absolute top-0 left-1/2 -z-10 h-[500px] w-[1000px] -translate-x-1/2 rounded-full opacity-30 blur-3xl dark:opacity-20" />
|
||||||
<div className="absolute bottom-0 left-0 -z-10 h-[300px] w-[300px] rounded-full bg-blue-500/10 blur-3xl" />
|
<div className="absolute bottom-0 left-0 -z-10 h-[300px] w-[300px] rounded-full bg-blue-500/10 blur-3xl" />
|
||||||
|
|
||||||
<div className="w-full max-w-md animate-in fade-in zoom-in-95 duration-500">
|
<div className="animate-in fade-in zoom-in-95 w-full max-w-md duration-500">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
<Link href="/" className="inline-flex items-center justify-center transition-opacity hover:opacity-80">
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center justify-center transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
<Logo iconSize="lg" showText={false} />
|
<Logo iconSize="lg" showText={false} />
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="mt-6 text-2xl font-bold tracking-tight text-foreground">Create an account</h1>
|
<h1 className="text-foreground mt-6 text-2xl font-bold tracking-tight">
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
Create an account
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
Start your journey in HRI research
|
Start your journey in HRI research
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sign Up Card */}
|
{/* Sign Up Card */}
|
||||||
<Card className="border-muted/40 bg-card/50 backdrop-blur-sm shadow-xl">
|
<Card className="border-muted/40 bg-card/50 shadow-xl backdrop-blur-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Sign Up</CardTitle>
|
<CardTitle>Sign Up</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -84,7 +89,7 @@ export default function SignUpPage() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive font-medium border border-destructive/20">
|
<div className="bg-destructive/15 text-destructive border-destructive/20 rounded-md border p-3 text-sm font-medium">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -155,15 +160,17 @@ export default function SignUpPage() {
|
|||||||
disabled={createUser.isPending}
|
disabled={createUser.isPending}
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
{createUser.isPending ? "Creating account..." : "Create Account"}
|
{createUser.isPending
|
||||||
|
? "Creating account..."
|
||||||
|
: "Create Account"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-6 text-center text-sm text-muted-foreground">
|
<div className="text-muted-foreground mt-6 text-center text-sm">
|
||||||
Already have an account?{" "}
|
Already have an account?{" "}
|
||||||
<Link
|
<Link
|
||||||
href="/auth/signin"
|
href="/auth/signin"
|
||||||
className="font-medium text-primary hover:text-primary/80"
|
className="text-primary hover:text-primary/80 font-medium"
|
||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
@@ -172,7 +179,7 @@ export default function SignUpPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="mt-8 text-center text-xs text-muted-foreground">
|
<div className="text-muted-foreground mt-8 text-center text-xs">
|
||||||
<p>
|
<p>
|
||||||
© {new Date().getFullYear()} HRIStudio. All rights reserved.
|
© {new Date().getFullYear()} HRIStudio. All rights reserved.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
const { data: liveTrials } = api.dashboard.getLiveTrials.useQuery(
|
const { data: liveTrials } = api.dashboard.getLiveTrials.useQuery(
|
||||||
{ studyId: studyFilter ?? undefined },
|
{ studyId: studyFilter ?? undefined },
|
||||||
{ refetchInterval: 5000 }
|
{ refetchInterval: 5000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: recentActivity } = api.dashboard.getRecentActivity.useQuery({
|
const { data: recentActivity } = api.dashboard.getRecentActivity.useQuery({
|
||||||
@@ -102,11 +102,14 @@ export default function DashboardPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 animate-in fade-in duration-500">
|
<div className="animate-in fade-in space-y-8 duration-500">
|
||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
<div id="dashboard-header" className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div
|
||||||
|
id="dashboard-header"
|
||||||
|
className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-foreground">
|
<h1 className="text-foreground text-3xl font-bold tracking-tight">
|
||||||
{getWelcomeMessage()}
|
{getWelcomeMessage()}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
@@ -115,7 +118,12 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="ghost" size="icon" onClick={() => startTour("dashboard")} title="Start Tour">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => startTour("dashboard")}
|
||||||
|
title="Start Tour"
|
||||||
|
>
|
||||||
<HelpCircle className="h-5 w-5" />
|
<HelpCircle className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Select
|
<Select
|
||||||
@@ -124,7 +132,7 @@ export default function DashboardPage() {
|
|||||||
setStudyFilter(value === "all" ? null : value)
|
setStudyFilter(value === "all" ? null : value)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[200px] bg-background">
|
<SelectTrigger className="bg-background w-[200px]">
|
||||||
<SelectValue placeholder="All Studies" />
|
<SelectValue placeholder="All Studies" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -146,7 +154,10 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Stats Grid */}
|
{/* Main Stats Grid */}
|
||||||
<div id="tour-dashboard-stats" className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div
|
||||||
|
id="tour-dashboard-stats"
|
||||||
|
className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"
|
||||||
|
>
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title="Active Trials"
|
title="Active Trials"
|
||||||
value={stats?.activeTrials ?? 0}
|
value={stats?.activeTrials ?? 0}
|
||||||
@@ -179,9 +190,8 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
{/* Action Center & Recent Activity */}
|
{/* Action Center & Recent Activity */}
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||||
|
|
||||||
{/* Quick Actions Card */}
|
{/* Quick Actions Card */}
|
||||||
<Card className="col-span-3 bg-gradient-to-br from-primary/5 to-background border-primary/20 h-fit">
|
<Card className="from-primary/5 to-background border-primary/20 col-span-3 h-fit bg-gradient-to-br">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Quick Actions</CardTitle>
|
<CardTitle>Quick Actions</CardTitle>
|
||||||
<CardDescription>Common tasks to get you started</CardDescription>
|
<CardDescription>Common tasks to get you started</CardDescription>
|
||||||
@@ -189,35 +199,35 @@ export default function DashboardPage() {
|
|||||||
<CardContent className="grid gap-4">
|
<CardContent className="grid gap-4">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="justify-start h-auto py-4 px-4 border-primary/20 hover:border-primary/50 hover:bg-primary/5 group"
|
className="border-primary/20 hover:border-primary/50 hover:bg-primary/5 group h-auto justify-start px-4 py-4"
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<Link href="/studies/new">
|
<Link href="/studies/new">
|
||||||
<div className="p-2 bg-primary/10 rounded-full mr-4 group-hover:bg-primary/20 transition-colors">
|
<div className="bg-primary/10 group-hover:bg-primary/20 mr-4 rounded-full p-2 transition-colors">
|
||||||
<FlaskConical className="h-5 w-5 text-primary" />
|
<FlaskConical className="text-primary h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="font-semibold">Create New Study</div>
|
<div className="font-semibold">Create New Study</div>
|
||||||
<div className="text-xs text-muted-foreground font-normal">
|
<div className="text-muted-foreground text-xs font-normal">
|
||||||
Design a new experiment protocol
|
Design a new experiment protocol
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ArrowRight className="ml-auto h-4 w-4 text-muted-foreground group-hover:text-primary opacity-0 group-hover:opacity-100 transition-all" />
|
<ArrowRight className="text-muted-foreground group-hover:text-primary ml-auto h-4 w-4 opacity-0 transition-all group-hover:opacity-100" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="justify-start h-auto py-4 px-4 group"
|
className="group h-auto justify-start px-4 py-4"
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<Link href="/studies">
|
<Link href="/studies">
|
||||||
<div className="p-2 bg-secondary rounded-full mr-4">
|
<div className="bg-secondary mr-4 rounded-full p-2">
|
||||||
<Search className="h-5 w-5 text-foreground" />
|
<Search className="text-foreground h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="font-semibold">Browse Studies</div>
|
<div className="font-semibold">Browse Studies</div>
|
||||||
<div className="text-xs text-muted-foreground font-normal">
|
<div className="text-muted-foreground text-xs font-normal">
|
||||||
Find and manage existing studies
|
Find and manage existing studies
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -226,16 +236,16 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="justify-start h-auto py-4 px-4 group"
|
className="group h-auto justify-start px-4 py-4"
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<Link href="/trials">
|
<Link href="/trials">
|
||||||
<div className="p-2 bg-emerald-500/10 rounded-full mr-4">
|
<div className="mr-4 rounded-full bg-emerald-500/10 p-2">
|
||||||
<Activity className="h-5 w-5 text-emerald-600" />
|
<Activity className="h-5 w-5 text-emerald-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="font-semibold">Monitor Active Trials</div>
|
<div className="font-semibold">Monitor Active Trials</div>
|
||||||
<div className="text-xs text-muted-foreground font-normal">
|
<div className="text-muted-foreground text-xs font-normal">
|
||||||
Jump into the Wizard Interface
|
Jump into the Wizard Interface
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,7 +255,10 @@ export default function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Recent Activity Card */}
|
{/* Recent Activity Card */}
|
||||||
<Card id="tour-recent-activity" className="col-span-4 border-muted/40 shadow-sm">
|
<Card
|
||||||
|
id="tour-recent-activity"
|
||||||
|
className="border-muted/40 col-span-4 shadow-sm"
|
||||||
|
>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Recent Activity</CardTitle>
|
<CardTitle>Recent Activity</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -262,37 +275,53 @@ export default function DashboardPage() {
|
|||||||
eventColor = "bg-blue-500 ring-blue-100 dark:ring-blue-900";
|
eventColor = "bg-blue-500 ring-blue-100 dark:ring-blue-900";
|
||||||
Icon = PlayCircle;
|
Icon = PlayCircle;
|
||||||
} else if (activity.type === "trial_completed") {
|
} else if (activity.type === "trial_completed") {
|
||||||
eventColor = "bg-green-500 ring-green-100 dark:ring-green-900";
|
eventColor =
|
||||||
|
"bg-green-500 ring-green-100 dark:ring-green-900";
|
||||||
Icon = CheckCircle;
|
Icon = CheckCircle;
|
||||||
} else if (activity.type === "error") {
|
} else if (activity.type === "error") {
|
||||||
eventColor = "bg-red-500 ring-red-100 dark:ring-red-900";
|
eventColor = "bg-red-500 ring-red-100 dark:ring-red-900";
|
||||||
Icon = AlertTriangle;
|
Icon = AlertTriangle;
|
||||||
} else if (activity.type === "intervention") {
|
} else if (activity.type === "intervention") {
|
||||||
eventColor = "bg-orange-500 ring-orange-100 dark:ring-orange-900";
|
eventColor =
|
||||||
|
"bg-orange-500 ring-orange-100 dark:ring-orange-900";
|
||||||
Icon = Gamepad2;
|
Icon = Gamepad2;
|
||||||
} else if (activity.type === "annotation") {
|
} else if (activity.type === "annotation") {
|
||||||
eventColor = "bg-yellow-500 ring-yellow-100 dark:ring-yellow-900";
|
eventColor =
|
||||||
|
"bg-yellow-500 ring-yellow-100 dark:ring-yellow-900";
|
||||||
Icon = MessageSquare;
|
Icon = MessageSquare;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={activity.id} className="relative pl-6 pb-4 border-l last:border-0 border-muted-foreground/20">
|
<div
|
||||||
<span className={`absolute left-[-9px] top-0 h-4 w-4 rounded-full flex items-center justify-center ring-4 ${eventColor}`}>
|
key={activity.id}
|
||||||
|
className="border-muted-foreground/20 relative border-l pb-4 pl-6 last:border-0"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0 left-[-9px] flex h-4 w-4 items-center justify-center rounded-full ring-4 ${eventColor}`}
|
||||||
|
>
|
||||||
<Icon className="h-2.5 w-2.5 text-white" />
|
<Icon className="h-2.5 w-2.5 text-white" />
|
||||||
</span>
|
</span>
|
||||||
<div className="mb-0.5 text-sm font-medium leading-none">{activity.title}</div>
|
<div className="mb-0.5 text-sm leading-none font-medium">
|
||||||
<div className="text-xs text-muted-foreground mb-1">{activity.description}</div>
|
{activity.title}
|
||||||
<div className="text-[10px] text-muted-foreground/70 uppercase font-mono">
|
</div>
|
||||||
{formatDistanceToNow(new Date(activity.time), { addSuffix: true })}
|
<div className="text-muted-foreground mb-1 text-xs">
|
||||||
|
{activity.description}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground/70 font-mono text-[10px] uppercase">
|
||||||
|
{formatDistanceToNow(new Date(activity.time), {
|
||||||
|
addSuffix: true,
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
{!recentActivity?.length && (
|
{!recentActivity?.length && (
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-8 text-center">
|
||||||
<Clock className="h-10 w-10 mb-3 opacity-20" />
|
<Clock className="mb-3 h-10 w-10 opacity-20" />
|
||||||
<p>No recent activity recorded.</p>
|
<p>No recent activity recorded.</p>
|
||||||
<p className="text-xs mt-1">Start a trial to see experiment events stream here.</p>
|
<p className="mt-1 text-xs">
|
||||||
|
Start a trial to see experiment events stream here.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -303,31 +332,40 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||||
{/* Live Trials */}
|
{/* Live Trials */}
|
||||||
<Card id="tour-live-trials" className={`${liveTrials && liveTrials.length > 0 ? 'border-primary shadow-sm bg-primary/5' : 'border-muted/40'} col-span-4 transition-colors duration-500`}>
|
<Card
|
||||||
|
id="tour-live-trials"
|
||||||
|
className={`${liveTrials && liveTrials.length > 0 ? "border-primary bg-primary/5 shadow-sm" : "border-muted/40"} col-span-4 transition-colors duration-500`}
|
||||||
|
>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
Live Sessions
|
Live Sessions
|
||||||
{liveTrials && liveTrials.length > 0 && <span className="relative flex h-3 w-3">
|
{liveTrials && liveTrials.length > 0 && (
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
<span className="relative flex h-3 w-3">
|
||||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75"></span>
|
||||||
</span>}
|
<span className="relative inline-flex h-3 w-3 rounded-full bg-red-500"></span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Currently running trials in the Wizard interface
|
Currently running trials in the Wizard interface
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
<Link href="/trials">View All <ArrowRight className="ml-2 h-4 w-4" /></Link>
|
<Link href="/trials">
|
||||||
|
View All <ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{!liveTrials?.length ? (
|
{!liveTrials?.length ? (
|
||||||
<div className="flex h-[150px] flex-col items-center justify-center rounded-md border border-dashed border-muted-foreground/30 text-center animate-in fade-in-50 bg-background/50">
|
<div className="border-muted-foreground/30 animate-in fade-in-50 bg-background/50 flex h-[150px] flex-col items-center justify-center rounded-md border border-dashed text-center">
|
||||||
<Radio className="h-8 w-8 text-muted-foreground/50 mb-2" />
|
<Radio className="text-muted-foreground/50 mb-2 h-8 w-8" />
|
||||||
<p className="text-sm text-muted-foreground">No trials are currently running.</p>
|
<p className="text-muted-foreground text-sm">
|
||||||
|
No trials are currently running.
|
||||||
|
</p>
|
||||||
<Button variant="link" size="sm" asChild className="mt-1">
|
<Button variant="link" size="sm" asChild className="mt-1">
|
||||||
<Link href="/trials">Start a Trial</Link>
|
<Link href="/trials">Start a Trial</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -335,23 +373,37 @@ export default function DashboardPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{liveTrials.map((trial) => (
|
{liveTrials.map((trial) => (
|
||||||
<div key={trial.id} className="flex items-center justify-between rounded-lg border border-primary/20 p-3 bg-background shadow-sm hover:shadow transition-all duration-200">
|
<div
|
||||||
|
key={trial.id}
|
||||||
|
className="border-primary/20 bg-background flex items-center justify-between rounded-lg border p-3 shadow-sm transition-all duration-200 hover:shadow"
|
||||||
|
>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-400">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-100 text-red-600 dark:bg-red-900/40 dark:text-red-400">
|
||||||
<Radio className="h-5 w-5 animate-pulse" />
|
<Radio className="h-5 w-5 animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-sm">
|
<p className="text-sm font-medium">
|
||||||
{trial.participantCode}
|
{trial.participantCode}
|
||||||
<span className="ml-2 text-muted-foreground font-normal text-xs">• {trial.experimentName}</span>
|
<span className="text-muted-foreground ml-2 text-xs font-normal">
|
||||||
|
• {trial.experimentName}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||||
<Clock className="h-3 w-3" />
|
<Clock className="h-3 w-3" />
|
||||||
Started {trial.startedAt ? formatDistanceToNow(new Date(trial.startedAt), { addSuffix: true }) : 'just now'}
|
Started{" "}
|
||||||
|
{trial.startedAt
|
||||||
|
? formatDistanceToNow(new Date(trial.startedAt), {
|
||||||
|
addSuffix: true,
|
||||||
|
})
|
||||||
|
: "just now"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" className="gap-2 bg-primary hover:bg-primary/90" asChild>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="bg-primary hover:bg-primary/90 gap-2"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
<Link href={`/wizard/${trial.id}`}>
|
<Link href={`/wizard/${trial.id}`}>
|
||||||
<Play className="h-3.5 w-3.5" /> Spectate / Jump In
|
<Play className="h-3.5 w-3.5" /> Spectate / Jump In
|
||||||
</Link>
|
</Link>
|
||||||
@@ -364,7 +416,7 @@ export default function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Study Progress */}
|
{/* Study Progress */}
|
||||||
<Card className="col-span-3 border-muted/40 shadow-sm">
|
<Card className="border-muted/40 col-span-3 shadow-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Study Progress</CardTitle>
|
<CardTitle>Study Progress</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -376,13 +428,18 @@ export default function DashboardPage() {
|
|||||||
<div key={study.id} className="space-y-2">
|
<div key={study.id} className="space-y-2">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<div className="font-medium">{study.name}</div>
|
<div className="font-medium">{study.name}</div>
|
||||||
<div className="text-muted-foreground">{study.participants} / {study.totalParticipants} Participants</div>
|
<div className="text-muted-foreground">
|
||||||
|
{study.participants} / {study.totalParticipants}{" "}
|
||||||
|
Participants
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Progress value={study.progress} className="h-2" />
|
<Progress value={study.progress} className="h-2" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!studyProgress?.length && (
|
{!studyProgress?.length && (
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">No active studies to track.</p>
|
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||||
|
No active studies to track.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -407,16 +464,20 @@ function StatsCard({
|
|||||||
iconColor?: string;
|
iconColor?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card className="border-muted/40 shadow-sm hover:shadow-md transition-all duration-200 hover:border-primary/20">
|
<Card className="border-muted/40 hover:border-primary/20 shadow-sm transition-all duration-200 hover:shadow-md">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||||
<Icon className={`h-4 w-4 ${iconColor || "text-muted-foreground"}`} />
|
<Icon className={`h-4 w-4 ${iconColor || "text-muted-foreground"}`} />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{value}</div>
|
<div className="text-2xl font-bold">{value}</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-muted-foreground text-xs">
|
||||||
{description}
|
{description}
|
||||||
{trend && <span className="ml-1 text-green-600 dark:text-green-400 font-medium">{trend}</span>}
|
{trend && (
|
||||||
|
<span className="ml-1 font-medium text-green-600 dark:text-green-400">
|
||||||
|
{trend}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
209
src/app/page.tsx
209
src/app/page.tsx
@@ -16,6 +16,7 @@ import {
|
|||||||
PlayCircle,
|
PlayCircle,
|
||||||
Settings2,
|
Settings2,
|
||||||
Share2,
|
Share2,
|
||||||
|
Sparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
@@ -26,9 +27,9 @@ export default async function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-background text-foreground">
|
<div className="bg-background text-foreground flex min-h-screen flex-col">
|
||||||
{/* Navbar */}
|
{/* Navbar */}
|
||||||
<header className="sticky top-0 z-50 w-full border-b bg-background/80 backdrop-blur-sm">
|
<header className="bg-background/80 sticky top-0 z-50 w-full border-b backdrop-blur-sm">
|
||||||
<div className="container mx-auto flex h-16 items-center justify-between px-4">
|
<div className="container mx-auto flex h-16 items-center justify-between px-4">
|
||||||
<Logo iconSize="md" showText={true} />
|
<Logo iconSize="md" showText={true} />
|
||||||
<nav className="flex items-center gap-4">
|
<nav className="flex items-center gap-4">
|
||||||
@@ -38,7 +39,7 @@ export default async function Home() {
|
|||||||
<Button variant="ghost" asChild className="hidden sm:inline-flex">
|
<Button variant="ghost" asChild className="hidden sm:inline-flex">
|
||||||
<Link href="#architecture">Architecture</Link>
|
<Link href="#architecture">Architecture</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<div className="h-6 w-px bg-border hidden sm:block" />
|
<div className="bg-border hidden h-6 w-px sm:block" />
|
||||||
<Button variant="ghost" asChild>
|
<Button variant="ghost" asChild>
|
||||||
<Link href="/auth/signin">Sign In</Link>
|
<Link href="/auth/signin">Sign In</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -53,11 +54,15 @@ export default async function Home() {
|
|||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="relative overflow-hidden pt-20 pb-32 md:pt-32">
|
<section className="relative overflow-hidden pt-20 pb-32 md:pt-32">
|
||||||
{/* Background Gradients */}
|
{/* Background Gradients */}
|
||||||
<div className="absolute top-0 left-1/2 -z-10 h-[500px] w-[1000px] -translate-x-1/2 rounded-full bg-primary/20 blur-3xl opacity-30 dark:opacity-20" />
|
<div className="bg-primary/20 absolute top-0 left-1/2 -z-10 h-[500px] w-[1000px] -translate-x-1/2 rounded-full opacity-30 blur-3xl dark:opacity-20" />
|
||||||
|
|
||||||
<div className="container mx-auto flex flex-col items-center px-4 text-center">
|
<div className="container mx-auto flex flex-col items-center px-4 text-center">
|
||||||
<Badge variant="secondary" className="mb-6 rounded-full px-4 py-1.5 text-sm font-medium">
|
<Badge
|
||||||
✨ The Modern Standard for HRI Research
|
variant="secondary"
|
||||||
|
className="mb-6 rounded-full px-4 py-1.5 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<Sparkles className="mr-2 h-4 w-4 text-yellow-500" />
|
||||||
|
The Modern Standard for HRI Research
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
<h1 className="max-w-4xl text-5xl font-extrabold tracking-tight sm:text-6xl md:text-7xl">
|
<h1 className="max-w-4xl text-5xl font-extrabold tracking-tight sm:text-6xl md:text-7xl">
|
||||||
@@ -67,7 +72,7 @@ export default async function Home() {
|
|||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl">
|
<p className="text-muted-foreground mt-6 max-w-2xl text-lg md:text-xl">
|
||||||
HRIStudio is the open-source platform that bridges the gap between
|
HRIStudio is the open-source platform that bridges the gap between
|
||||||
ease of use and scientific rigor. Design, execute, and analyze
|
ease of use and scientific rigor. Design, execute, and analyze
|
||||||
human-robot interaction experiments with zero friction.
|
human-robot interaction experiments with zero friction.
|
||||||
@@ -80,22 +85,32 @@ export default async function Home() {
|
|||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="lg" variant="outline" className="h-12 px-8 text-base" asChild>
|
<Button
|
||||||
<Link href="https://github.com/robolab/hristudio" target="_blank">
|
size="lg"
|
||||||
|
variant="outline"
|
||||||
|
className="h-12 px-8 text-base"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href="https://github.com/robolab/hristudio"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
View on GitHub
|
View on GitHub
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mockup / Visual Interest */}
|
{/* Mockup / Visual Interest */}
|
||||||
<div className="relative mt-20 w-full max-w-5xl rounded-xl border bg-background/50 p-2 shadow-2xl backdrop-blur-sm lg:rounded-2xl lg:p-4">
|
<div className="bg-background/50 relative mt-20 w-full max-w-5xl rounded-xl border p-2 shadow-2xl backdrop-blur-sm lg:rounded-2xl lg:p-4">
|
||||||
<div className="absolute inset-x-0 -top-px mx-auto h-px w-3/4 bg-gradient-to-r from-transparent via-foreground/20 to-transparent" />
|
<div className="via-foreground/20 absolute inset-x-0 -top-px mx-auto h-px w-3/4 bg-gradient-to-r from-transparent to-transparent" />
|
||||||
<div className="aspect-[16/9] w-full overflow-hidden rounded-lg border bg-muted/50 flex items-center justify-center relative">
|
<div className="bg-muted/50 relative flex aspect-[16/9] w-full items-center justify-center overflow-hidden rounded-lg border">
|
||||||
{/* Placeholder for actual app screenshot */}
|
{/* Placeholder for actual app screenshot */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-tr from-blue-500/10 to-violet-500/10" />
|
<div className="absolute inset-0 bg-gradient-to-tr from-blue-500/10 to-violet-500/10" />
|
||||||
<div className="text-center p-8">
|
<div className="p-8 text-center">
|
||||||
<LayoutTemplate className="w-16 h-16 mx-auto text-muted-foreground/50 mb-4" />
|
<LayoutTemplate className="text-muted-foreground/50 mx-auto mb-4 h-16 w-16" />
|
||||||
<p className="text-muted-foreground font-medium">Interactive Experiment Designer</p>
|
<p className="text-muted-foreground font-medium">
|
||||||
|
Interactive Experiment Designer
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,13 +120,17 @@ export default async function Home() {
|
|||||||
{/* Features Bento Grid */}
|
{/* Features Bento Grid */}
|
||||||
<section id="features" className="container mx-auto px-4 py-24">
|
<section id="features" className="container mx-auto px-4 py-24">
|
||||||
<div className="mb-12 text-center">
|
<div className="mb-12 text-center">
|
||||||
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">Everything You Need</h2>
|
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">
|
||||||
<p className="mt-4 text-lg text-muted-foreground">Built for the specific needs of HRI researchers and wizards.</p>
|
Everything You Need
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground mt-4 text-lg">
|
||||||
|
Built for the specific needs of HRI researchers and wizards.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-4 lg:grid-rows-2">
|
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-4 lg:grid-rows-2">
|
||||||
{/* Visual Designer - Large Item */}
|
{/* Visual Designer - Large Item */}
|
||||||
<Card className="col-span-1 md:col-span-2 lg:col-span-2 row-span-2 flex flex-col overflow-hidden bg-gradient-to-br from-blue-500/5 to-violet-500/5 dark:from-blue-900/10 dark:to-violet-900/10">
|
<Card className="col-span-1 row-span-2 flex flex-col overflow-hidden bg-gradient-to-br from-blue-500/5 to-violet-500/5 md:col-span-2 lg:col-span-2 dark:from-blue-900/10 dark:to-violet-900/10">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<LayoutTemplate className="h-5 w-5 text-blue-500" />
|
<LayoutTemplate className="h-5 w-5 text-blue-500" />
|
||||||
@@ -120,16 +139,19 @@ export default async function Home() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1">
|
<CardContent className="flex-1">
|
||||||
<p className="text-muted-foreground mb-6">
|
<p className="text-muted-foreground mb-6">
|
||||||
Construct complex branching narratives without writing a single line of code.
|
Construct complex branching narratives without writing a
|
||||||
Our node-based editor handles logic, timing, and robot actions automatically.
|
single line of code. Our node-based editor handles logic,
|
||||||
|
timing, and robot actions automatically.
|
||||||
</p>
|
</p>
|
||||||
<div className="rounded-lg border bg-background/50 p-4 h-full min-h-[200px] flex items-center justify-center shadow-inner">
|
<div className="bg-background/50 flex h-full min-h-[200px] items-center justify-center rounded-lg border p-4 shadow-inner">
|
||||||
<div className="flex gap-2 items-center text-sm text-muted-foreground">
|
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||||
<span className="rounded bg-accent p-2">Start</span>
|
<span className="bg-accent rounded p-2">Start</span>
|
||||||
<ArrowRight className="h-4 w-4" />
|
<ArrowRight className="h-4 w-4" />
|
||||||
<span className="rounded bg-primary/10 p-2 border border-primary/20 text-primary font-medium">Robot: Greet</span>
|
<span className="bg-primary/10 border-primary/20 text-primary rounded border p-2 font-medium">
|
||||||
|
Robot: Greet
|
||||||
|
</span>
|
||||||
<ArrowRight className="h-4 w-4" />
|
<ArrowRight className="h-4 w-4" />
|
||||||
<span className="rounded bg-accent p-2">Wait: 5s</span>
|
<span className="bg-accent rounded p-2">Wait: 5s</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -145,14 +167,15 @@ export default async function Home() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Switch between robots instantly. Whether it's a NAO, Pepper, or a custom ROS2 bot,
|
Switch between robots instantly. Whether it's a NAO, Pepper,
|
||||||
your experiment logic remains strictly separated from hardware implementation.
|
or a custom ROS2 bot, your experiment logic remains strictly
|
||||||
|
separated from hardware implementation.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Role Based */}
|
{/* Role Based */}
|
||||||
<Card className="col-span-1 md:col-span-1 lg:col-span-1 bg-muted/30">
|
<Card className="bg-muted/30 col-span-1 md:col-span-1 lg:col-span-1">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
<Lock className="h-4 w-4 text-orange-500" />
|
<Lock className="h-4 w-4 text-orange-500" />
|
||||||
@@ -160,14 +183,15 @@ export default async function Home() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground text-sm">
|
||||||
Granular permissions for Principal Investigators, Wizards, and Observers.
|
Granular permissions for Principal Investigators, Wizards, and
|
||||||
|
Observers.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Data Logging */}
|
{/* Data Logging */}
|
||||||
<Card className="col-span-1 md:col-span-1 lg:col-span-1 bg-muted/30">
|
<Card className="bg-muted/30 col-span-1 md:col-span-1 lg:col-span-1">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
<Database className="h-4 w-4 text-rose-500" />
|
<Database className="h-4 w-4 text-rose-500" />
|
||||||
@@ -175,8 +199,9 @@ export default async function Home() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground text-sm">
|
||||||
Every wizard action, automated response, and sensor reading is time-stamped and logged.
|
Every wizard action, automated response, and sensor reading is
|
||||||
|
time-stamped and logged.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -184,41 +209,56 @@ export default async function Home() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Architecture Section */}
|
{/* Architecture Section */}
|
||||||
<section id="architecture" className="border-t bg-muted/30 py-24">
|
<section id="architecture" className="bg-muted/30 border-t py-24">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="grid gap-12 lg:grid-cols-2 lg:gap-8 items-center">
|
<div className="grid items-center gap-12 lg:grid-cols-2 lg:gap-8">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl font-bold tracking-tight">Enterprise-Grade Architecture</h2>
|
<h2 className="text-3xl font-bold tracking-tight">
|
||||||
<p className="mt-4 text-lg text-muted-foreground">
|
Enterprise-Grade Architecture
|
||||||
Designed for reliability and scale. HRIStudio uses a modern stack to ensure your data is safe and your experiments run smoothly.
|
</h2>
|
||||||
|
<p className="text-muted-foreground mt-4 text-lg">
|
||||||
|
Designed for reliability and scale. HRIStudio uses a modern
|
||||||
|
stack to ensure your data is safe and your experiments run
|
||||||
|
smoothly.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-8 space-y-4">
|
<div className="mt-8 space-y-4">
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-background border shadow-sm">
|
<div className="bg-background flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border shadow-sm">
|
||||||
<Network className="h-5 w-5 text-primary" />
|
<Network className="text-primary h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold">3-Layer Design</h3>
|
<h3 className="font-semibold">3-Layer Design</h3>
|
||||||
<p className="text-muted-foreground">Clear separation between UI, Data, and Hardware layers for maximum stability.</p>
|
<p className="text-muted-foreground">
|
||||||
|
Clear separation between UI, Data, and Hardware layers
|
||||||
|
for maximum stability.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-background border shadow-sm">
|
<div className="bg-background flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border shadow-sm">
|
||||||
<Share2 className="h-5 w-5 text-primary" />
|
<Share2 className="text-primary h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold">Collaborative by Default</h3>
|
<h3 className="font-semibold">
|
||||||
<p className="text-muted-foreground">Real-time state synchronization allows multiple researchers to monitor a single trial.</p>
|
Collaborative by Default
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Real-time state synchronization allows multiple
|
||||||
|
researchers to monitor a single trial.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-background border shadow-sm">
|
<div className="bg-background flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border shadow-sm">
|
||||||
<Settings2 className="h-5 w-5 text-primary" />
|
<Settings2 className="text-primary h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold">ROS2 Integration</h3>
|
<h3 className="font-semibold">ROS2 Integration</h3>
|
||||||
<p className="text-muted-foreground">Native support for ROS2 nodes, topics, and actions right out of the box.</p>
|
<p className="text-muted-foreground">
|
||||||
|
Native support for ROS2 nodes, topics, and actions right
|
||||||
|
out of the box.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -226,34 +266,46 @@ export default async function Home() {
|
|||||||
|
|
||||||
<div className="relative mx-auto w-full max-w-[500px]">
|
<div className="relative mx-auto w-full max-w-[500px]">
|
||||||
{/* Abstract representation of architecture */}
|
{/* Abstract representation of architecture */}
|
||||||
<div className="space-y-4 relative z-10">
|
<div className="relative z-10 space-y-4">
|
||||||
<Card className="border-blue-500/20 bg-blue-500/5 relative left-0 hover:left-2 transition-all cursor-default">
|
<Card className="relative left-0 cursor-default border-blue-500/20 bg-blue-500/5 transition-all hover:left-2">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-blue-600 dark:text-blue-400 text-sm font-mono">APP LAYER</CardTitle>
|
<CardTitle className="font-mono text-sm text-blue-600 dark:text-blue-400">
|
||||||
|
APP LAYER
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="font-semibold">Next.js Dashboard + Experiment Designer</p>
|
<p className="font-semibold">
|
||||||
|
Next.js Dashboard + Experiment Designer
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="border-violet-500/20 bg-violet-500/5 relative left-4 hover:left-6 transition-all cursor-default">
|
<Card className="relative left-4 cursor-default border-violet-500/20 bg-violet-500/5 transition-all hover:left-6">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-violet-600 dark:text-violet-400 text-sm font-mono">DATA LAYER</CardTitle>
|
<CardTitle className="font-mono text-sm text-violet-600 dark:text-violet-400">
|
||||||
|
DATA LAYER
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="font-semibold">PostgreSQL + MinIO + TRPC API</p>
|
<p className="font-semibold">
|
||||||
|
PostgreSQL + MinIO + TRPC API
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="border-green-500/20 bg-green-500/5 relative left-8 hover:left-10 transition-all cursor-default">
|
<Card className="relative left-8 cursor-default border-green-500/20 bg-green-500/5 transition-all hover:left-10">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-green-600 dark:text-green-400 text-sm font-mono">HARDWARE LAYER</CardTitle>
|
<CardTitle className="font-mono text-sm text-green-600 dark:text-green-400">
|
||||||
|
HARDWARE LAYER
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="font-semibold">ROS2 Bridge + Robot Plugins</p>
|
<p className="font-semibold">
|
||||||
|
ROS2 Bridge + Robot Plugins
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
{/* Decorative blobs */}
|
{/* Decorative blobs */}
|
||||||
<div className="absolute top-1/2 left-1/2 -z-10 h-[300px] w-[300px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/10 blur-3xl" />
|
<div className="bg-primary/10 absolute top-1/2 left-1/2 -z-10 h-[300px] w-[300px] -translate-x-1/2 -translate-y-1/2 rounded-full blur-3xl" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -261,31 +313,46 @@ export default async function Home() {
|
|||||||
|
|
||||||
{/* CTA Section */}
|
{/* CTA Section */}
|
||||||
<section className="container mx-auto px-4 py-24 text-center">
|
<section className="container mx-auto px-4 py-24 text-center">
|
||||||
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">Ready to upgrade your lab?</h2>
|
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">
|
||||||
<p className="mx-auto mt-4 max-w-2xl text-lg text-muted-foreground">
|
Ready to upgrade your lab?
|
||||||
Join the community of researchers building the future of HRI with reproducible, open-source tools.
|
</h2>
|
||||||
|
<p className="text-muted-foreground mx-auto mt-4 max-w-2xl text-lg">
|
||||||
|
Join the community of researchers building the future of HRI with
|
||||||
|
reproducible, open-source tools.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<Button size="lg" className="h-12 px-8 text-base shadow-lg shadow-primary/20" asChild>
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="shadow-primary/20 h-12 px-8 text-base shadow-lg"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
<Link href="/auth/signup">Get Started for Free</Link>
|
<Link href="/auth/signup">Get Started for Free</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="border-t bg-muted/40 py-12">
|
<footer className="bg-muted/40 border-t py-12">
|
||||||
<div className="container mx-auto px-4 flex flex-col items-center justify-between gap-6 md:flex-row text-center md:text-left">
|
<div className="container mx-auto flex flex-col items-center justify-between gap-6 px-4 text-center md:flex-row md:text-left">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Logo iconSize="sm" showText={true} />
|
<Logo iconSize="sm" showText={true} />
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground text-sm">
|
||||||
© {new Date().getFullYear()} HRIStudio. All rights reserved.
|
© {new Date().getFullYear()} HRIStudio. All rights reserved.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-6 text-sm text-muted-foreground">
|
<div className="text-muted-foreground flex gap-6 text-sm">
|
||||||
<Link href="#" className="hover:text-foreground">Privacy</Link>
|
<Link href="#" className="hover:text-foreground">
|
||||||
<Link href="#" className="hover:text-foreground">Terms</Link>
|
Privacy
|
||||||
<Link href="#" className="hover:text-foreground">GitHub</Link>
|
</Link>
|
||||||
<Link href="#" className="hover:text-foreground">Documentation</Link>
|
<Link href="#" className="hover:text-foreground">
|
||||||
|
Terms
|
||||||
|
</Link>
|
||||||
|
<Link href="#" className="hover:text-foreground">
|
||||||
|
GitHub
|
||||||
|
</Link>
|
||||||
|
<Link href="#" className="hover:text-foreground">
|
||||||
|
Documentation
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle
|
CardTitle,
|
||||||
} from "~/components/ui/card";
|
} from "~/components/ui/card";
|
||||||
import { auth } from "~/server/auth";
|
import { auth } from "~/server/auth";
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { Badge } from "~/components/ui/badge";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Separator } from "~/components/ui/separator";
|
import { Separator } from "~/components/ui/separator";
|
||||||
import {
|
import {
|
||||||
getAvailableRoles, getRoleColor, getRolePermissions
|
getAvailableRoles,
|
||||||
|
getRoleColor,
|
||||||
|
getRolePermissions,
|
||||||
} from "~/lib/auth-client";
|
} from "~/lib/auth-client";
|
||||||
|
|
||||||
export function RoleManagement() {
|
export function RoleManagement() {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Activity,
|
Activity,
|
||||||
Eye,
|
Eye,
|
||||||
Video
|
Video,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -78,14 +78,20 @@ export const columns: ColumnDef<AnalyticsTrial>[] = [
|
|||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
cell: ({ row }) => <div className="font-mono text-center">#{row.getValue("sessionNumber")}</div>,
|
cell: ({ row }) => (
|
||||||
|
<div className="text-center font-mono">
|
||||||
|
#{row.getValue("sessionNumber")}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "participant.participantCode",
|
accessorKey: "participant.participantCode",
|
||||||
id: "participantCode",
|
id: "participantCode",
|
||||||
header: "Participant",
|
header: "Participant",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="font-medium">{row.original.participant?.participantCode ?? "Unknown"}</div>
|
<div className="font-medium">
|
||||||
|
{row.original.participant?.participantCode ?? "Unknown"}
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -96,11 +102,12 @@ export const columns: ColumnDef<AnalyticsTrial>[] = [
|
|||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={`capitalize ${status === "completed"
|
className={`capitalize ${
|
||||||
? "bg-green-500/10 text-green-500 border-green-500/20"
|
status === "completed"
|
||||||
|
? "border-green-500/20 bg-green-500/10 text-green-500"
|
||||||
: status === "in_progress"
|
: status === "in_progress"
|
||||||
? "bg-blue-500/10 text-blue-500 border-blue-500/20"
|
? "border-blue-500/20 bg-blue-500/10 text-blue-500"
|
||||||
: "bg-slate-500/10 text-slate-500 border-slate-500/20"
|
: "border-slate-500/20 bg-slate-500/10 text-slate-500"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{status.replace("_", " ")}
|
{status.replace("_", " ")}
|
||||||
@@ -126,9 +133,11 @@ export const columns: ColumnDef<AnalyticsTrial>[] = [
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-sm">{date.toLocaleDateString()}</span>
|
<span className="text-sm">{date.toLocaleDateString()}</span>
|
||||||
<span className="text-xs text-muted-foreground">{formatDistanceToNow(date, { addSuffix: true })}</span>
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{formatDistanceToNow(date, { addSuffix: true })}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -148,10 +157,10 @@ export const columns: ColumnDef<AnalyticsTrial>[] = [
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Activity className="h-3 w-3 text-muted-foreground" />
|
<Activity className="text-muted-foreground h-3 w-3" />
|
||||||
<span>{row.getValue("eventCount")}</span>
|
<span>{row.getValue("eventCount")}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -162,10 +171,10 @@ export const columns: ColumnDef<AnalyticsTrial>[] = [
|
|||||||
if (count === 0) return <span className="text-muted-foreground">-</span>;
|
if (count === 0) return <span className="text-muted-foreground">-</span>;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Video className="h-3 w-3 text-muted-foreground" />
|
<Video className="text-muted-foreground h-3 w-3" />
|
||||||
<span>{count}</span>
|
<span>{count}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -183,13 +192,17 @@ export const columns: ColumnDef<AnalyticsTrial>[] = [
|
|||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href={`/studies/${trial.experiment?.studyId}/trials/${trial.id}/analysis`}>
|
<Link
|
||||||
|
href={`/studies/${trial.experiment?.studyId}/trials/${trial.id}/analysis`}
|
||||||
|
>
|
||||||
<Eye className="mr-2 h-4 w-4" />
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
View Analysis
|
View Analysis
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href={`/experiments/${trial.experimentId}/trials/${trial.id}`}>
|
<Link
|
||||||
|
href={`/experiments/${trial.experimentId}/trials/${trial.id}`}
|
||||||
|
>
|
||||||
View Trial Details
|
View Trial Details
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -204,7 +217,9 @@ interface StudyAnalyticsDataTableProps {
|
|||||||
data: AnalyticsTrial[];
|
data: AnalyticsTrial[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StudyAnalyticsDataTable({ data }: StudyAnalyticsDataTableProps) {
|
export function StudyAnalyticsDataTable({
|
||||||
|
data,
|
||||||
|
}: StudyAnalyticsDataTableProps) {
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||||
@@ -234,15 +249,20 @@ export function StudyAnalyticsDataTable({ data }: StudyAnalyticsDataTableProps)
|
|||||||
<div className="flex items-center py-4">
|
<div className="flex items-center py-4">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Filter participants..."
|
placeholder="Filter participants..."
|
||||||
value={(table.getColumn("participantCode")?.getFilterValue() as string) ?? ""}
|
value={
|
||||||
|
(table.getColumn("participantCode")?.getFilterValue() as string) ??
|
||||||
|
""
|
||||||
|
}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
table.getColumn("participantCode")?.setFilterValue(event.target.value)
|
table
|
||||||
|
.getColumn("participantCode")
|
||||||
|
?.setFilterValue(event.target.value)
|
||||||
}
|
}
|
||||||
className="max-w-sm"
|
className="max-w-sm"
|
||||||
id="tour-analytics-filter"
|
id="tour-analytics-filter"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border bg-card">
|
<div className="bg-card rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
@@ -254,7 +274,7 @@ export function StudyAnalyticsDataTable({ data }: StudyAnalyticsDataTableProps)
|
|||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header.column.columnDef.header,
|
header.column.columnDef.header,
|
||||||
header.getContext()
|
header.getContext(),
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
);
|
);
|
||||||
@@ -273,7 +293,7 @@ export function StudyAnalyticsDataTable({ data }: StudyAnalyticsDataTableProps)
|
|||||||
<TableCell key={cell.id}>
|
<TableCell key={cell.id}>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
cell.column.columnDef.cell,
|
cell.column.columnDef.cell,
|
||||||
cell.getContext()
|
cell.getContext(),
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
@@ -293,7 +313,7 @@ export function StudyAnalyticsDataTable({ data }: StudyAnalyticsDataTableProps)
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end space-x-2 py-4">
|
<div className="flex items-center justify-end space-x-2 py-4">
|
||||||
<div className="flex-1 text-sm text-muted-foreground">
|
<div className="text-muted-foreground flex-1 text-sm">
|
||||||
{table.getFilteredSelectedRowModel().rows.length} of{" "}
|
{table.getFilteredSelectedRowModel().rows.length} of{" "}
|
||||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
User,
|
User,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
Users,
|
Users,
|
||||||
|
FileText,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { useSidebar } from "~/components/ui/sidebar";
|
import { useSidebar } from "~/components/ui/sidebar";
|
||||||
@@ -96,6 +97,11 @@ const studyWorkItems = [
|
|||||||
url: "/experiments",
|
url: "/experiments",
|
||||||
icon: FlaskConical,
|
icon: FlaskConical,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Forms",
|
||||||
|
url: "/forms",
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Analytics",
|
title: "Analytics",
|
||||||
url: "/analytics",
|
url: "/analytics",
|
||||||
@@ -143,10 +149,15 @@ export function AppSidebar({
|
|||||||
const isAdmin = userRole === "administrator";
|
const isAdmin = userRole === "administrator";
|
||||||
const { state: sidebarState } = useSidebar();
|
const { state: sidebarState } = useSidebar();
|
||||||
const isCollapsed = sidebarState === "collapsed";
|
const isCollapsed = sidebarState === "collapsed";
|
||||||
const { selectedStudyId, userStudies, selectStudy, refreshStudyData, isLoadingUserStudies } =
|
const {
|
||||||
useStudyManagement();
|
selectedStudyId,
|
||||||
|
userStudies,
|
||||||
|
selectStudy,
|
||||||
|
refreshStudyData,
|
||||||
|
isLoadingUserStudies,
|
||||||
|
} = useStudyManagement();
|
||||||
|
|
||||||
const { startTour } = useTour();
|
const { startTour, isTourActive } = useTour();
|
||||||
|
|
||||||
// Reference to track if we've already attempted auto-selection to avoid fighting with manual clearing
|
// Reference to track if we've already attempted auto-selection to avoid fighting with manual clearing
|
||||||
const hasAutoSelected = useRef(false);
|
const hasAutoSelected = useRef(false);
|
||||||
@@ -170,12 +181,7 @@ export function AppSidebar({
|
|||||||
hasAutoSelected.current = true;
|
hasAutoSelected.current = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [isLoadingUserStudies, selectedStudyId, userStudies, selectStudy]);
|
||||||
isLoadingUserStudies,
|
|
||||||
selectedStudyId,
|
|
||||||
userStudies,
|
|
||||||
selectStudy,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Debug API call
|
// Debug API call
|
||||||
const { data: debugData } = api.dashboard.debug.useQuery(undefined, {
|
const { data: debugData } = api.dashboard.debug.useQuery(undefined, {
|
||||||
@@ -309,6 +315,17 @@ export function AppSidebar({
|
|||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
|
{isTourActive && !isCollapsed && (
|
||||||
|
<div className="mt-1 px-3 pb-2">
|
||||||
|
<div className="bg-primary/10 text-primary border-primary/20 animate-in fade-in slide-in-from-top-2 flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs font-medium shadow-sm">
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"></span>
|
||||||
|
<span className="bg-primary relative inline-flex h-2 w-2 rounded-full"></span>
|
||||||
|
</span>
|
||||||
|
Tutorial Active
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
|
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
@@ -324,7 +341,10 @@ export function AppSidebar({
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<SidebarMenuButton className="w-full" id="tour-sidebar-study-selector">
|
<SidebarMenuButton
|
||||||
|
className="w-full"
|
||||||
|
id="tour-sidebar-study-selector"
|
||||||
|
>
|
||||||
<Building className="h-4 w-4 flex-shrink-0" />
|
<Building className="h-4 w-4 flex-shrink-0" />
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{selectedStudy?.name ?? "Select Study"}
|
{selectedStudy?.name ?? "Select Study"}
|
||||||
@@ -373,7 +393,10 @@ export function AppSidebar({
|
|||||||
) : (
|
) : (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<SidebarMenuButton className="w-full" id="tour-sidebar-study-selector">
|
<SidebarMenuButton
|
||||||
|
className="w-full"
|
||||||
|
id="tour-sidebar-study-selector"
|
||||||
|
>
|
||||||
<Building className="h-4 w-4 flex-shrink-0" />
|
<Building className="h-4 w-4 flex-shrink-0" />
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{selectedStudy?.name ?? "Select Study"}
|
{selectedStudy?.name ?? "Select Study"}
|
||||||
@@ -576,7 +599,8 @@ export function AppSidebar({
|
|||||||
{helpItems.map((item) => {
|
{helpItems.map((item) => {
|
||||||
const isActive = pathname.startsWith(item.url);
|
const isActive = pathname.startsWith(item.url);
|
||||||
|
|
||||||
const menuButton = item.action === "tour" ? (
|
const menuButton =
|
||||||
|
item.action === "tour" ? (
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
onClick={() => startTour("full_platform")}
|
onClick={() => startTour("full_platform")}
|
||||||
isActive={false}
|
isActive={false}
|
||||||
|
|||||||
@@ -153,14 +153,18 @@ export function ExperimentForm({ mode, experimentId }: ExperimentFormProps) {
|
|||||||
...data,
|
...data,
|
||||||
estimatedDuration: data.estimatedDuration ?? undefined,
|
estimatedDuration: data.estimatedDuration ?? undefined,
|
||||||
});
|
});
|
||||||
router.push(`/studies/${data.studyId}/experiments/${newExperiment.id}/designer`);
|
router.push(
|
||||||
|
`/studies/${data.studyId}/experiments/${newExperiment.id}/designer`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
const updatedExperiment = await updateExperimentMutation.mutateAsync({
|
const updatedExperiment = await updateExperimentMutation.mutateAsync({
|
||||||
id: experimentId!,
|
id: experimentId!,
|
||||||
...data,
|
...data,
|
||||||
estimatedDuration: data.estimatedDuration ?? undefined,
|
estimatedDuration: data.estimatedDuration ?? undefined,
|
||||||
});
|
});
|
||||||
router.push(`/studies/${experiment?.studyId ?? data.studyId}/experiments/${updatedExperiment.id}`);
|
router.push(
|
||||||
|
`/studies/${experiment?.studyId ?? data.studyId}/experiments/${updatedExperiment.id}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(
|
setError(
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
import { Calendar, FlaskConical, Plus, Settings, Users } from "lucide-react";
|
import {
|
||||||
|
Calendar,
|
||||||
|
FlaskConical,
|
||||||
|
Plus,
|
||||||
|
Settings,
|
||||||
|
Users,
|
||||||
|
FileEdit,
|
||||||
|
TestTube,
|
||||||
|
CheckCircle2,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
@@ -45,22 +55,22 @@ const statusConfig = {
|
|||||||
draft: {
|
draft: {
|
||||||
label: "Draft",
|
label: "Draft",
|
||||||
className: "bg-gray-100 text-gray-800 hover:bg-gray-200",
|
className: "bg-gray-100 text-gray-800 hover:bg-gray-200",
|
||||||
icon: "📝",
|
icon: FileEdit,
|
||||||
},
|
},
|
||||||
testing: {
|
testing: {
|
||||||
label: "Testing",
|
label: "Testing",
|
||||||
className: "bg-yellow-100 text-yellow-800 hover:bg-yellow-200",
|
className: "bg-yellow-100 text-yellow-800 hover:bg-yellow-200",
|
||||||
icon: "🧪",
|
icon: TestTube,
|
||||||
},
|
},
|
||||||
ready: {
|
ready: {
|
||||||
label: "Ready",
|
label: "Ready",
|
||||||
className: "bg-green-100 text-green-800 hover:bg-green-200",
|
className: "bg-green-100 text-green-800 hover:bg-green-200",
|
||||||
icon: "✅",
|
icon: CheckCircle2,
|
||||||
},
|
},
|
||||||
deprecated: {
|
deprecated: {
|
||||||
label: "Deprecated",
|
label: "Deprecated",
|
||||||
className: "bg-red-100 text-red-800 hover:bg-red-200",
|
className: "bg-red-100 text-red-800 hover:bg-red-200",
|
||||||
icon: "🗑️",
|
icon: Trash2,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -98,7 +108,7 @@ function ExperimentCard({ experiment }: ExperimentCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge className={statusInfo.className} variant="secondary">
|
<Badge className={statusInfo.className} variant="secondary">
|
||||||
<span className="mr-1">{statusInfo.icon}</span>
|
<statusInfo.icon className="mr-1 h-3.5 w-3.5" />
|
||||||
{statusInfo.label}
|
{statusInfo.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,10 +168,16 @@ function ExperimentCard({ experiment }: ExperimentCardProps) {
|
|||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex gap-2 pt-2">
|
<div className="flex gap-2 pt-2">
|
||||||
<Button asChild size="sm" className="flex-1">
|
<Button asChild size="sm" className="flex-1">
|
||||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}>View Details</Link>
|
<Link
|
||||||
|
href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}
|
||||||
|
>
|
||||||
|
View Details
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild size="sm" variant="outline" className="flex-1">
|
<Button asChild size="sm" variant="outline" className="flex-1">
|
||||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
|
<Link
|
||||||
|
href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}
|
||||||
|
>
|
||||||
<Settings className="mr-1 h-3 w-3" />
|
<Settings className="mr-1 h-3 w-3" />
|
||||||
Design
|
Design
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { type ColumnDef } from "@tanstack/react-table";
|
import { type ColumnDef } from "@tanstack/react-table";
|
||||||
import { ArrowUpDown, MoreHorizontal, Edit, LayoutTemplate, Trash2 } from "lucide-react";
|
import {
|
||||||
|
ArrowUpDown,
|
||||||
|
MoreHorizontal,
|
||||||
|
Edit,
|
||||||
|
LayoutTemplate,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
@@ -261,10 +267,12 @@ function ExperimentActions({ experiment }: { experiment: Experiment }) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
asChild
|
asChild
|
||||||
className="h-8 w-8 text-muted-foreground hover:text-primary"
|
className="text-muted-foreground hover:text-primary h-8 w-8"
|
||||||
title="Open Designer"
|
title="Open Designer"
|
||||||
>
|
>
|
||||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
|
<Link
|
||||||
|
href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}
|
||||||
|
>
|
||||||
<LayoutTemplate className="h-4 w-4" />
|
<LayoutTemplate className="h-4 w-4" />
|
||||||
<span className="sr-only">Design</span>
|
<span className="sr-only">Design</span>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -278,7 +286,7 @@ function ExperimentActions({ experiment }: { experiment: Experiment }) {
|
|||||||
deleteMutation.mutate({ id: experiment.id });
|
deleteMutation.mutate({ id: experiment.id });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
className="text-muted-foreground hover:text-destructive h-8 w-8"
|
||||||
title="Delete Experiment"
|
title="Delete Experiment"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import type { ActionDefinition, ExperimentAction } from "~/lib/experiment-designer/types";
|
import type {
|
||||||
|
ActionDefinition,
|
||||||
|
ExperimentAction,
|
||||||
|
} from "~/lib/experiment-designer/types";
|
||||||
import corePluginDef from "~/plugins/definitions/hristudio-core.json";
|
import corePluginDef from "~/plugins/definitions/hristudio-core.json";
|
||||||
import wozPluginDef from "~/plugins/definitions/hristudio-woz.json";
|
import wozPluginDef from "~/plugins/definitions/hristudio-woz.json";
|
||||||
|
|
||||||
@@ -56,7 +59,9 @@ export class ActionRegistry {
|
|||||||
this.registerPluginDefinition(corePluginDef);
|
this.registerPluginDefinition(corePluginDef);
|
||||||
this.registerPluginDefinition(wozPluginDef);
|
this.registerPluginDefinition(wozPluginDef);
|
||||||
|
|
||||||
console.log(`[ActionRegistry] Loaded system plugins: ${this.SYSTEM_PLUGIN_IDS.join(", ")}`);
|
console.log(
|
||||||
|
`[ActionRegistry] Loaded system plugins: ${this.SYSTEM_PLUGIN_IDS.join(", ")}`,
|
||||||
|
);
|
||||||
|
|
||||||
this.coreActionsLoaded = true;
|
this.coreActionsLoaded = true;
|
||||||
this.notifyListeners();
|
this.notifyListeners();
|
||||||
@@ -64,10 +69,7 @@ export class ActionRegistry {
|
|||||||
|
|
||||||
/* ---------------- Plugin Actions ---------------- */
|
/* ---------------- Plugin Actions ---------------- */
|
||||||
|
|
||||||
loadPluginActions(
|
loadPluginActions(studyId: string, studyPlugins: any[]): void {
|
||||||
studyId: string,
|
|
||||||
studyPlugins: any[],
|
|
||||||
): void {
|
|
||||||
if (this.pluginActionsLoaded && this.loadedStudyId === studyId) return;
|
if (this.pluginActionsLoaded && this.loadedStudyId === studyId) return;
|
||||||
|
|
||||||
if (this.loadedStudyId !== studyId) {
|
if (this.loadedStudyId !== studyId) {
|
||||||
@@ -78,7 +80,7 @@ export class ActionRegistry {
|
|||||||
|
|
||||||
(studyPlugins ?? []).forEach((plugin) => {
|
(studyPlugins ?? []).forEach((plugin) => {
|
||||||
this.registerPluginDefinition(plugin);
|
this.registerPluginDefinition(plugin);
|
||||||
totalActionsLoaded += (plugin.actionDefinitions?.length || 0);
|
totalActionsLoaded += plugin.actionDefinitions?.length || 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
@@ -114,9 +116,9 @@ export class ActionRegistry {
|
|||||||
// Default category based on plugin type or explicit category
|
// Default category based on plugin type or explicit category
|
||||||
let category = categoryMap[rawCategory];
|
let category = categoryMap[rawCategory];
|
||||||
if (!category) {
|
if (!category) {
|
||||||
if (plugin.id === 'hristudio-woz') category = 'wizard';
|
if (plugin.id === "hristudio-woz") category = "wizard";
|
||||||
else if (plugin.id === 'hristudio-core') category = 'control';
|
else if (plugin.id === "hristudio-core") category = "control";
|
||||||
else category = 'robot';
|
else category = "robot";
|
||||||
}
|
}
|
||||||
|
|
||||||
const execution = action.ros2
|
const execution = action.ros2
|
||||||
@@ -184,7 +186,7 @@ export class ActionRegistry {
|
|||||||
},
|
},
|
||||||
execution,
|
execution,
|
||||||
parameterSchemaRaw: action.parameterSchema ?? undefined,
|
parameterSchemaRaw: action.parameterSchema ?? undefined,
|
||||||
nestable: action.nestable
|
nestable: action.nestable,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Prevent overwriting if it already exists (first-come-first-served, usually core first)
|
// Prevent overwriting if it already exists (first-come-first-served, usually core first)
|
||||||
@@ -193,7 +195,9 @@ export class ActionRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Register aliases
|
// Register aliases
|
||||||
const aliases = Array.isArray(action.aliases) ? action.aliases : undefined;
|
const aliases = Array.isArray(action.aliases)
|
||||||
|
? action.aliases
|
||||||
|
: undefined;
|
||||||
if (aliases) {
|
if (aliases) {
|
||||||
for (const alias of aliases) {
|
for (const alias of aliases) {
|
||||||
if (typeof alias === "string" && alias.trim()) {
|
if (typeof alias === "string" && alias.trim()) {
|
||||||
@@ -224,7 +228,8 @@ export class ActionRegistry {
|
|||||||
if (!schema?.properties) return [];
|
if (!schema?.properties) return [];
|
||||||
|
|
||||||
return Object.entries(schema.properties).map(([key, paramDef]) => {
|
return Object.entries(schema.properties).map(([key, paramDef]) => {
|
||||||
let type: "text" | "number" | "select" | "boolean" | "json" | "array" = "text";
|
let type: "text" | "number" | "select" | "boolean" | "json" | "array" =
|
||||||
|
"text";
|
||||||
|
|
||||||
if (paramDef.type === "number") {
|
if (paramDef.type === "number") {
|
||||||
type = "number";
|
type = "number";
|
||||||
@@ -259,7 +264,10 @@ export class ActionRegistry {
|
|||||||
// Robust Reset: Remove valid plugin actions, BUT protect system plugins.
|
// Robust Reset: Remove valid plugin actions, BUT protect system plugins.
|
||||||
const idsToDelete: string[] = [];
|
const idsToDelete: string[] = [];
|
||||||
this.actions.forEach((action, id) => {
|
this.actions.forEach((action, id) => {
|
||||||
if (action.source.kind === "plugin" && !this.SYSTEM_PLUGIN_IDS.includes(action.source.pluginId || "")) {
|
if (
|
||||||
|
action.source.kind === "plugin" &&
|
||||||
|
!this.SYSTEM_PLUGIN_IDS.includes(action.source.pluginId || "")
|
||||||
|
) {
|
||||||
idsToDelete.push(id);
|
idsToDelete.push(id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
PanelRightOpen,
|
PanelRightOpen,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
Settings
|
Settings,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
@@ -134,31 +134,43 @@ interface RawExperiment {
|
|||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined {
|
function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined {
|
||||||
console.log('[adaptExistingDesign] Entry - exp.steps:', exp.steps);
|
console.log("[adaptExistingDesign] Entry - exp.steps:", exp.steps);
|
||||||
|
|
||||||
// 1. Prefer database steps (Source of Truth) if valid, to ensure we have the latest
|
// 1. Prefer database steps (Source of Truth) if valid, to ensure we have the latest
|
||||||
// plugin provenance data (which might be missing from stale visualDesign snapshots).
|
// plugin provenance data (which might be missing from stale visualDesign snapshots).
|
||||||
// 1. Prefer database steps (Source of Truth) if valid.
|
// 1. Prefer database steps (Source of Truth) if valid.
|
||||||
if (Array.isArray(exp.steps) && exp.steps.length > 0) {
|
if (Array.isArray(exp.steps) && exp.steps.length > 0) {
|
||||||
console.log('[adaptExistingDesign] Has steps array, length:', exp.steps.length);
|
console.log(
|
||||||
|
"[adaptExistingDesign] Has steps array, length:",
|
||||||
|
exp.steps.length,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
// Check if steps are already converted (have trigger property) to avoid double-conversion data loss
|
// Check if steps are already converted (have trigger property) to avoid double-conversion data loss
|
||||||
const firstStep = exp.steps[0] as any;
|
const firstStep = exp.steps[0] as any;
|
||||||
let dbSteps: ExperimentStep[];
|
let dbSteps: ExperimentStep[];
|
||||||
|
|
||||||
if (firstStep && typeof firstStep === 'object' && 'trigger' in firstStep) {
|
if (
|
||||||
|
firstStep &&
|
||||||
|
typeof firstStep === "object" &&
|
||||||
|
"trigger" in firstStep
|
||||||
|
) {
|
||||||
// Already converted by server
|
// Already converted by server
|
||||||
dbSteps = exp.steps as ExperimentStep[];
|
dbSteps = exp.steps as ExperimentStep[];
|
||||||
} else {
|
} else {
|
||||||
// Raw DB steps, need conversion
|
// Raw DB steps, need conversion
|
||||||
console.log('[adaptExistingDesign] Taking raw DB conversion path');
|
console.log("[adaptExistingDesign] Taking raw DB conversion path");
|
||||||
dbSteps = convertDatabaseToSteps(exp.steps);
|
dbSteps = convertDatabaseToSteps(exp.steps);
|
||||||
|
|
||||||
// DEBUG: Check children after conversion
|
// DEBUG: Check children after conversion
|
||||||
dbSteps.forEach((step) => {
|
dbSteps.forEach((step) => {
|
||||||
step.actions.forEach((action) => {
|
step.actions.forEach((action) => {
|
||||||
if (["sequence", "parallel", "loop", "branch"].includes(action.type)) {
|
if (
|
||||||
console.log(`[adaptExistingDesign] Post-conversion ${action.type} (${action.name}) children:`, action.children);
|
["sequence", "parallel", "loop", "branch"].includes(action.type)
|
||||||
|
) {
|
||||||
|
console.log(
|
||||||
|
`[adaptExistingDesign] Post-conversion ${action.type} (${action.name}) children:`,
|
||||||
|
action.children,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -173,7 +185,10 @@ function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined {
|
|||||||
lastSaved: new Date(),
|
lastSaved: new Date(),
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[DesignerRoot] Failed to convert/hydrate steps, falling back to visualDesign:', err);
|
console.warn(
|
||||||
|
"[DesignerRoot] Failed to convert/hydrate steps, falling back to visualDesign:",
|
||||||
|
err,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,7 +265,7 @@ export function DesignerRoot({
|
|||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
gcTime: 0, // Garbage collect immediately
|
gcTime: 0, // Garbage collect immediately
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateExperiment = api.experiments.update.useMutation({
|
const updateExperiment = api.experiments.update.useMutation({
|
||||||
@@ -381,18 +396,23 @@ export function DesignerRoot({
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const [activeSortableItem, setActiveSortableItem] = useState<{
|
const [activeSortableItem, setActiveSortableItem] = useState<{
|
||||||
type: 'step' | 'action';
|
type: "step" | "action";
|
||||||
data: any;
|
data: any;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
/* ----------------------------- Initialization ---------------------------- */
|
/* ----------------------------- Initialization ---------------------------- */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('[DesignerRoot] useEffect triggered', { initialized, loadingExperiment, hasExperiment: !!experiment, hasInitialDesign: !!initialDesign });
|
console.log("[DesignerRoot] useEffect triggered", {
|
||||||
|
initialized,
|
||||||
|
loadingExperiment,
|
||||||
|
hasExperiment: !!experiment,
|
||||||
|
hasInitialDesign: !!initialDesign,
|
||||||
|
});
|
||||||
|
|
||||||
if (initialized) return;
|
if (initialized) return;
|
||||||
if (loadingExperiment && !initialDesign) return;
|
if (loadingExperiment && !initialDesign) return;
|
||||||
|
|
||||||
console.log('[DesignerRoot] Proceeding with initialization');
|
console.log("[DesignerRoot] Proceeding with initialization");
|
||||||
|
|
||||||
const adapted =
|
const adapted =
|
||||||
initialDesign ??
|
initialDesign ??
|
||||||
@@ -486,7 +506,6 @@ export function DesignerRoot({
|
|||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}, [steps, initialized, recomputeHash]);
|
}, [steps, initialized, recomputeHash]);
|
||||||
|
|
||||||
|
|
||||||
/* ----------------------------- Derived State ----------------------------- */
|
/* ----------------------------- Derived State ----------------------------- */
|
||||||
const hasUnsavedChanges =
|
const hasUnsavedChanges =
|
||||||
!!currentDesignHash && lastPersistedHash !== currentDesignHash;
|
!!currentDesignHash && lastPersistedHash !== currentDesignHash;
|
||||||
@@ -539,20 +558,30 @@ export function DesignerRoot({
|
|||||||
// Debug: Improved structured logging for validation results
|
// Debug: Improved structured logging for validation results
|
||||||
console.group("🧪 Experiment Validation Results");
|
console.group("🧪 Experiment Validation Results");
|
||||||
if (result.valid) {
|
if (result.valid) {
|
||||||
console.log(`%c✓ VALID (0 errors, ${result.warningCount} warnings, ${result.infoCount} hints)`, "color: green; font-weight: bold; font-size: 12px;");
|
console.log(
|
||||||
|
`%c✓ VALID (0 errors, ${result.warningCount} warnings, ${result.infoCount} hints)`,
|
||||||
|
"color: green; font-weight: bold; font-size: 12px;",
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
console.log(`%c✗ INVALID (${result.errorCount} errors, ${result.warningCount} warnings)`, "color: red; font-weight: bold; font-size: 12px;");
|
console.log(
|
||||||
|
`%c✗ INVALID (${result.errorCount} errors, ${result.warningCount} warnings)`,
|
||||||
|
"color: red; font-weight: bold; font-size: 12px;",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.issues.length > 0) {
|
if (result.issues.length > 0) {
|
||||||
console.table(
|
console.table(
|
||||||
result.issues.map(i => ({
|
result.issues.map((i) => ({
|
||||||
Severity: i.severity.toUpperCase(),
|
Severity: i.severity.toUpperCase(),
|
||||||
Category: i.category,
|
Category: i.category,
|
||||||
Message: i.message,
|
Message: i.message,
|
||||||
Suggest: i.suggestion,
|
Suggest: i.suggestion,
|
||||||
Location: i.actionId ? `Action ${i.actionId}` : (i.stepId ? `Step ${i.stepId}` : 'Global')
|
Location: i.actionId
|
||||||
}))
|
? `Action ${i.actionId}`
|
||||||
|
: i.stepId
|
||||||
|
? `Step ${i.stepId}`
|
||||||
|
: "Global",
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
console.log("No issues found. Design is perfectly compliant.");
|
console.log("No issues found. Design is perfectly compliant.");
|
||||||
@@ -583,7 +612,8 @@ export function DesignerRoot({
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(
|
toast.error(
|
||||||
`Validation error: ${err instanceof Error ? err.message : "Unknown error"
|
`Validation error: ${
|
||||||
|
err instanceof Error ? err.message : "Unknown error"
|
||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -610,7 +640,7 @@ export function DesignerRoot({
|
|||||||
const persist = useCallback(async () => {
|
const persist = useCallback(async () => {
|
||||||
if (!initialized) return;
|
if (!initialized) return;
|
||||||
|
|
||||||
console.log('[DesignerRoot] 💾 SAVE initiated', {
|
console.log("[DesignerRoot] 💾 SAVE initiated", {
|
||||||
stepsCount: steps.length,
|
stepsCount: steps.length,
|
||||||
actionsCount: steps.reduce((sum, s) => sum + s.actions.length, 0),
|
actionsCount: steps.reduce((sum, s) => sum + s.actions.length, 0),
|
||||||
currentHash: currentDesignHash?.slice(0, 16),
|
currentHash: currentDesignHash?.slice(0, 16),
|
||||||
@@ -625,7 +655,7 @@ export function DesignerRoot({
|
|||||||
lastSaved: new Date().toISOString(),
|
lastSaved: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('[DesignerRoot] 💾 Sending to server...', {
|
console.log("[DesignerRoot] 💾 Sending to server...", {
|
||||||
experimentId,
|
experimentId,
|
||||||
stepsCount: steps.length,
|
stepsCount: steps.length,
|
||||||
version: designMeta.version,
|
version: designMeta.version,
|
||||||
@@ -639,7 +669,7 @@ export function DesignerRoot({
|
|||||||
compileExecution: autoCompile,
|
compileExecution: autoCompile,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[DesignerRoot] 💾 Server save successful');
|
console.log("[DesignerRoot] 💾 Server save successful");
|
||||||
|
|
||||||
// NOTE: We do NOT refetch here because it would reset the local steps state
|
// NOTE: We do NOT refetch here because it would reset the local steps state
|
||||||
// to the server state, which would cause the hash to match the persisted hash,
|
// to the server state, which would cause the hash to match the persisted hash,
|
||||||
@@ -649,7 +679,7 @@ export function DesignerRoot({
|
|||||||
// Recompute hash and update persisted hash
|
// Recompute hash and update persisted hash
|
||||||
const hashResult = await recomputeHash();
|
const hashResult = await recomputeHash();
|
||||||
if (hashResult?.designHash) {
|
if (hashResult?.designHash) {
|
||||||
console.log('[DesignerRoot] 💾 Updated persisted hash:', {
|
console.log("[DesignerRoot] 💾 Updated persisted hash:", {
|
||||||
newPersistedHash: hashResult.designHash.slice(0, 16),
|
newPersistedHash: hashResult.designHash.slice(0, 16),
|
||||||
fullHash: hashResult.designHash,
|
fullHash: hashResult.designHash,
|
||||||
});
|
});
|
||||||
@@ -662,7 +692,7 @@ export function DesignerRoot({
|
|||||||
// Auto-validate after save to clear "Modified" (drift) status
|
// Auto-validate after save to clear "Modified" (drift) status
|
||||||
void validateDesign();
|
void validateDesign();
|
||||||
|
|
||||||
console.log('[DesignerRoot] 💾 SAVE complete');
|
console.log("[DesignerRoot] 💾 SAVE complete");
|
||||||
|
|
||||||
onPersist?.({
|
onPersist?.({
|
||||||
id: experimentId,
|
id: experimentId,
|
||||||
@@ -673,7 +703,7 @@ export function DesignerRoot({
|
|||||||
lastSaved: new Date(),
|
lastSaved: new Date(),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[DesignerRoot] 💾 SAVE failed:', error);
|
console.error("[DesignerRoot] 💾 SAVE failed:", error);
|
||||||
// Error already handled by mutation onError
|
// Error already handled by mutation onError
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
@@ -729,7 +759,8 @@ export function DesignerRoot({
|
|||||||
toast.success("Exported design bundle");
|
toast.success("Exported design bundle");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(
|
toast.error(
|
||||||
`Export failed: ${err instanceof Error ? err.message : "Unknown error"
|
`Export failed: ${
|
||||||
|
err instanceof Error ? err.message : "Unknown error"
|
||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -801,10 +832,7 @@ export function DesignerRoot({
|
|||||||
|
|
||||||
console.log("[DesignerRoot] DragStart", { activeId, activeData });
|
console.log("[DesignerRoot] DragStart", { activeId, activeData });
|
||||||
|
|
||||||
if (
|
if (activeId.startsWith("action-") && activeData?.action) {
|
||||||
activeId.startsWith("action-") &&
|
|
||||||
activeData?.action
|
|
||||||
) {
|
|
||||||
const a = activeData.action as {
|
const a = activeData.action as {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -822,14 +850,17 @@ export function DesignerRoot({
|
|||||||
} else if (activeId.startsWith("s-step-")) {
|
} else if (activeId.startsWith("s-step-")) {
|
||||||
console.log("[DesignerRoot] Setting active sortable STEP", activeData);
|
console.log("[DesignerRoot] Setting active sortable STEP", activeData);
|
||||||
setActiveSortableItem({
|
setActiveSortableItem({
|
||||||
type: 'step',
|
type: "step",
|
||||||
data: activeData
|
data: activeData,
|
||||||
});
|
});
|
||||||
} else if (activeId.startsWith("s-act-")) {
|
} else if (activeId.startsWith("s-act-")) {
|
||||||
console.log("[DesignerRoot] Setting active sortable ACTION", activeData);
|
console.log(
|
||||||
|
"[DesignerRoot] Setting active sortable ACTION",
|
||||||
|
activeData,
|
||||||
|
);
|
||||||
setActiveSortableItem({
|
setActiveSortableItem({
|
||||||
type: 'action',
|
type: "action",
|
||||||
data: activeData
|
data: activeData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -856,8 +887,6 @@ export function DesignerRoot({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const overId = over.id.toString();
|
const overId = over.id.toString();
|
||||||
const activeDef = active.data.current?.action;
|
const activeDef = active.data.current?.action;
|
||||||
|
|
||||||
@@ -892,7 +921,7 @@ export function DesignerRoot({
|
|||||||
// Let's assume index 0 for now (prepend) or implement lookup.
|
// Let's assume index 0 for now (prepend) or implement lookup.
|
||||||
// Better: lookup action -> children length.
|
// Better: lookup action -> children length.
|
||||||
const actionId = parentId;
|
const actionId = parentId;
|
||||||
const step = store.steps.find(s => s.id === stepId);
|
const step = store.steps.find((s) => s.id === stepId);
|
||||||
// Find action recursive? Store has `findActionById` helper but it is not exported/accessible easily here?
|
// Find action recursive? Store has `findActionById` helper but it is not exported/accessible easily here?
|
||||||
// Actually, `store.steps` is available.
|
// Actually, `store.steps` is available.
|
||||||
// We can implement a quick BFS/DFS or just assume 0.
|
// We can implement a quick BFS/DFS or just assume 0.
|
||||||
@@ -907,7 +936,6 @@ export function DesignerRoot({
|
|||||||
: overId.slice("step-".length);
|
: overId.slice("step-".length);
|
||||||
const step = store.steps.find((s) => s.id === stepId);
|
const step = store.steps.find((s) => s.id === stepId);
|
||||||
index = step ? step.actions.length : 0;
|
index = step ? step.actions.length : 0;
|
||||||
|
|
||||||
} else if (overId === "projection-placeholder") {
|
} else if (overId === "projection-placeholder") {
|
||||||
// Hovering over our own projection placeholder -> keep current state
|
// Hovering over our own projection placeholder -> keep current state
|
||||||
return;
|
return;
|
||||||
@@ -969,13 +997,19 @@ export function DesignerRoot({
|
|||||||
if (activeId.startsWith("s-step-")) {
|
if (activeId.startsWith("s-step-")) {
|
||||||
const overId = over.id.toString();
|
const overId = over.id.toString();
|
||||||
// Allow reordering over both sortable steps (s-step-) and drop zones (step-)
|
// Allow reordering over both sortable steps (s-step-) and drop zones (step-)
|
||||||
if (!overId.startsWith("s-step-") && !overId.startsWith("step-")) return;
|
if (!overId.startsWith("s-step-") && !overId.startsWith("step-"))
|
||||||
|
return;
|
||||||
|
|
||||||
// Strip prefixes to get raw IDs
|
// Strip prefixes to get raw IDs
|
||||||
const rawActiveId = activeId.replace(/^s-step-/, "");
|
const rawActiveId = activeId.replace(/^s-step-/, "");
|
||||||
const rawOverId = overId.replace(/^s-step-/, "").replace(/^step-/, "");
|
const rawOverId = overId.replace(/^s-step-/, "").replace(/^step-/, "");
|
||||||
|
|
||||||
console.log("[DesignerRoot] DragEnd - Step Sort", { activeId, overId, rawActiveId, rawOverId });
|
console.log("[DesignerRoot] DragEnd - Step Sort", {
|
||||||
|
activeId,
|
||||||
|
overId,
|
||||||
|
rawActiveId,
|
||||||
|
rawOverId,
|
||||||
|
});
|
||||||
|
|
||||||
const oldIndex = steps.findIndex((s) => s.id === rawActiveId);
|
const oldIndex = steps.findIndex((s) => s.id === rawActiveId);
|
||||||
const newIndex = steps.findIndex((s) => s.id === rawOverId);
|
const newIndex = steps.findIndex((s) => s.id === rawOverId);
|
||||||
@@ -1020,7 +1054,10 @@ export function DesignerRoot({
|
|||||||
if (!targetStep) return;
|
if (!targetStep) return;
|
||||||
|
|
||||||
// 2. Instantiate Action
|
// 2. Instantiate Action
|
||||||
if (active.id.toString().startsWith("action-") && active.data.current?.action) {
|
if (
|
||||||
|
active.id.toString().startsWith("action-") &&
|
||||||
|
active.data.current?.action
|
||||||
|
) {
|
||||||
const actionDef = active.data.current.action as {
|
const actionDef = active.data.current.action as {
|
||||||
id: string; // type
|
id: string; // type
|
||||||
type: string;
|
type: string;
|
||||||
@@ -1061,12 +1098,14 @@ export function DesignerRoot({
|
|||||||
category: actionDef.category as any,
|
category: actionDef.category as any,
|
||||||
description: "",
|
description: "",
|
||||||
parameters: defaultParams,
|
parameters: defaultParams,
|
||||||
source: actionDef.source ? {
|
source: actionDef.source
|
||||||
|
? {
|
||||||
kind: actionDef.source.kind as any,
|
kind: actionDef.source.kind as any,
|
||||||
pluginId: actionDef.source.pluginId,
|
pluginId: actionDef.source.pluginId,
|
||||||
pluginVersion: actionDef.source.pluginVersion,
|
pluginVersion: actionDef.source.pluginVersion,
|
||||||
baseActionId: actionDef.id
|
baseActionId: actionDef.id,
|
||||||
} : { kind: "core" },
|
}
|
||||||
|
: { kind: "core" },
|
||||||
execution,
|
execution,
|
||||||
children: [],
|
children: [],
|
||||||
};
|
};
|
||||||
@@ -1080,13 +1119,25 @@ export function DesignerRoot({
|
|||||||
void recomputeHash();
|
void recomputeHash();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[steps, upsertAction, selectAction, recomputeHash, toggleLibraryScrollLock, reorderStep],
|
[
|
||||||
|
steps,
|
||||||
|
upsertAction,
|
||||||
|
selectAction,
|
||||||
|
recomputeHash,
|
||||||
|
toggleLibraryScrollLock,
|
||||||
|
reorderStep,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
// validation status badges removed (unused)
|
// validation status badges removed (unused)
|
||||||
/* ------------------------------- Panels ---------------------------------- */
|
/* ------------------------------- Panels ---------------------------------- */
|
||||||
const leftPanel = useMemo(
|
const leftPanel = useMemo(
|
||||||
() => (
|
() => (
|
||||||
<div id="tour-designer-blocks" ref={libraryRootRef} data-library-root className="h-full">
|
<div
|
||||||
|
id="tour-designer-blocks"
|
||||||
|
ref={libraryRootRef}
|
||||||
|
data-library-root
|
||||||
|
className="h-full"
|
||||||
|
>
|
||||||
<ActionLibraryPanel />
|
<ActionLibraryPanel />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -1167,10 +1218,10 @@ export function DesignerRoot({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-[calc(100vh-5rem)] w-full flex-col overflow-hidden bg-background">
|
<div className="bg-background relative flex h-[calc(100vh-5rem)] w-full flex-col overflow-hidden">
|
||||||
{/* Subtle Background Gradients */}
|
{/* Subtle Background Gradients */}
|
||||||
<div className="absolute top-0 left-1/2 -z-10 h-[400px] w-[800px] -translate-x-1/2 rounded-full bg-primary/10 blur-3xl opacity-20 dark:opacity-10" />
|
<div className="bg-primary/10 absolute top-0 left-1/2 -z-10 h-[400px] w-[800px] -translate-x-1/2 rounded-full opacity-20 blur-3xl dark:opacity-10" />
|
||||||
<div className="absolute bottom-0 right-0 -z-10 h-[250px] w-[250px] rounded-full bg-violet-500/5 blur-3xl" />
|
<div className="absolute right-0 bottom-0 -z-10 h-[250px] w-[250px] rounded-full bg-violet-500/5 blur-3xl" />
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={designMeta.name}
|
title={designMeta.name}
|
||||||
description={designMeta.description || "No description"}
|
description={designMeta.description || "No description"}
|
||||||
@@ -1181,7 +1232,7 @@ export function DesignerRoot({
|
|||||||
|
|
||||||
{/* Main Grid Container - 2-4-2 Split */}
|
{/* Main Grid Container - 2-4-2 Split */}
|
||||||
{/* Main Grid Container - 2-4-2 Split */}
|
{/* Main Grid Container - 2-4-2 Split */}
|
||||||
<div className="flex-1 min-h-0 w-full px-2 overflow-hidden">
|
<div className="min-h-0 w-full flex-1 overflow-hidden px-2">
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
@@ -1190,14 +1241,16 @@ export function DesignerRoot({
|
|||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
onDragCancel={() => toggleLibraryScrollLock(false)}
|
onDragCancel={() => toggleLibraryScrollLock(false)}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-8 gap-4 h-full w-full transition-all duration-300 ease-in-out">
|
<div className="grid h-full w-full grid-cols-8 gap-4 transition-all duration-300 ease-in-out">
|
||||||
{/* Left Panel (Library) */}
|
{/* Left Panel (Library) */}
|
||||||
{!leftCollapsed && (
|
{!leftCollapsed && (
|
||||||
<div className={cn(
|
<div
|
||||||
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm",
|
className={cn(
|
||||||
rightCollapsed ? "col-span-3" : "col-span-2"
|
"bg-background flex flex-col overflow-hidden rounded-lg border shadow-sm",
|
||||||
)}>
|
rightCollapsed ? "col-span-3" : "col-span-2",
|
||||||
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30">
|
)}
|
||||||
|
>
|
||||||
|
<div className="bg-muted/30 flex items-center justify-between border-b px-3 py-2">
|
||||||
<span className="text-sm font-medium">Action Library</span>
|
<span className="text-sm font-medium">Action Library</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -1208,26 +1261,31 @@ export function DesignerRoot({
|
|||||||
<PanelLeftClose className="h-4 w-4" />
|
<PanelLeftClose className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-hidden min-h-0 bg-muted/10">
|
<div className="bg-muted/10 min-h-0 flex-1 overflow-hidden">
|
||||||
{leftPanel}
|
{leftPanel}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Center Panel (Workspace) */}
|
{/* Center Panel (Workspace) */}
|
||||||
<div className={cn(
|
<div
|
||||||
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm",
|
className={cn(
|
||||||
leftCollapsed && rightCollapsed ? "col-span-8" :
|
"bg-background flex flex-col overflow-hidden rounded-lg border shadow-sm",
|
||||||
leftCollapsed ? "col-span-6" :
|
leftCollapsed && rightCollapsed
|
||||||
rightCollapsed ? "col-span-5" :
|
? "col-span-8"
|
||||||
"col-span-4"
|
: leftCollapsed
|
||||||
)}>
|
? "col-span-6"
|
||||||
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30">
|
: rightCollapsed
|
||||||
|
? "col-span-5"
|
||||||
|
: "col-span-4",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="bg-muted/30 flex items-center justify-between border-b px-3 py-2">
|
||||||
{leftCollapsed && (
|
{leftCollapsed && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-6 w-6 mr-2"
|
className="mr-2 h-6 w-6"
|
||||||
onClick={() => setLeftCollapsed(false)}
|
onClick={() => setLeftCollapsed(false)}
|
||||||
title="Open Library"
|
title="Open Library"
|
||||||
>
|
>
|
||||||
@@ -1237,14 +1295,19 @@ export function DesignerRoot({
|
|||||||
<span className="text-sm font-medium">Flow Workspace</span>
|
<span className="text-sm font-medium">Flow Workspace</span>
|
||||||
{rightCollapsed && (
|
{rightCollapsed && (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => startTour('designer')}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => startTour("designer")}
|
||||||
|
>
|
||||||
<HelpCircle className="h-4 w-4" />
|
<HelpCircle className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{rightCollapsed && (
|
{rightCollapsed && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-6 w-6 ml-2"
|
className="ml-2 h-6 w-6"
|
||||||
onClick={() => setRightCollapsed(false)}
|
onClick={() => setRightCollapsed(false)}
|
||||||
title="Open Inspector"
|
title="Open Inspector"
|
||||||
>
|
>
|
||||||
@@ -1254,7 +1317,7 @@ export function DesignerRoot({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-hidden min-h-0 relative">
|
<div className="relative min-h-0 flex-1 overflow-hidden">
|
||||||
{centerPanel}
|
{centerPanel}
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t">
|
<div className="border-t">
|
||||||
@@ -1273,11 +1336,13 @@ export function DesignerRoot({
|
|||||||
|
|
||||||
{/* Right Panel (Inspector) */}
|
{/* Right Panel (Inspector) */}
|
||||||
{!rightCollapsed && (
|
{!rightCollapsed && (
|
||||||
<div className={cn(
|
<div
|
||||||
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm",
|
className={cn(
|
||||||
leftCollapsed ? "col-span-2" : "col-span-2"
|
"bg-background flex flex-col overflow-hidden rounded-lg border shadow-sm",
|
||||||
)}>
|
leftCollapsed ? "col-span-2" : "col-span-2",
|
||||||
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30">
|
)}
|
||||||
|
>
|
||||||
|
<div className="bg-muted/30 flex items-center justify-between border-b px-3 py-2">
|
||||||
<span className="text-sm font-medium">Inspector</span>
|
<span className="text-sm font-medium">Inspector</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -1288,7 +1353,7 @@ export function DesignerRoot({
|
|||||||
<PanelRightClose className="h-4 w-4" />
|
<PanelRightClose className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-hidden min-h-0 bg-muted/10">
|
<div className="bg-muted/10 min-h-0 flex-1 overflow-hidden">
|
||||||
{rightPanel}
|
{rightPanel}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1298,7 +1363,7 @@ export function DesignerRoot({
|
|||||||
<DragOverlay dropAnimation={null}>
|
<DragOverlay dropAnimation={null}>
|
||||||
{dragOverlayAction ? (
|
{dragOverlayAction ? (
|
||||||
// Library Item Drag
|
// Library Item Drag
|
||||||
<div className="bg-background pointer-events-none flex items-center gap-2 rounded border px-3 py-2 text-xs font-medium shadow-lg select-none ring-2 ring-blue-500/20">
|
<div className="bg-background pointer-events-none flex items-center gap-2 rounded border px-3 py-2 text-xs font-medium shadow-lg ring-2 ring-blue-500/20 select-none">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-4 w-4 items-center justify-center rounded text-white",
|
"flex h-4 w-4 items-center justify-center rounded text-white",
|
||||||
@@ -1310,23 +1375,26 @@ export function DesignerRoot({
|
|||||||
/>
|
/>
|
||||||
{dragOverlayAction.name}
|
{dragOverlayAction.name}
|
||||||
</div>
|
</div>
|
||||||
) : activeSortableItem?.type === 'action' ? (
|
) : activeSortableItem?.type === "action" ? (
|
||||||
// Existing Action Sort
|
// Existing Action Sort
|
||||||
<div className="w-[300px] opacity-90 pointer-events-none">
|
<div className="pointer-events-none w-[300px] opacity-90">
|
||||||
<SortableActionChip
|
<SortableActionChip
|
||||||
stepId={activeSortableItem.data.stepId}
|
stepId={activeSortableItem.data.stepId}
|
||||||
action={activeSortableItem.data.action}
|
action={activeSortableItem.data.action}
|
||||||
parentId={activeSortableItem.data.parentId}
|
parentId={activeSortableItem.data.parentId}
|
||||||
selectedActionId={selectedActionId}
|
selectedActionId={selectedActionId}
|
||||||
onSelectAction={() => { }}
|
onSelectAction={() => {}}
|
||||||
onDeleteAction={() => { }}
|
onDeleteAction={() => {}}
|
||||||
dragHandle={true}
|
dragHandle={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : activeSortableItem?.type === 'step' ? (
|
) : activeSortableItem?.type === "step" ? (
|
||||||
// Existing Step Sort
|
// Existing Step Sort
|
||||||
<div className="w-[400px] pointer-events-none opacity-90">
|
<div className="pointer-events-none w-[400px] opacity-90">
|
||||||
<StepCardPreview step={activeSortableItem.data.step} dragHandle />
|
<StepCardPreview
|
||||||
|
step={activeSortableItem.data.step}
|
||||||
|
dragHandle
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
|
|||||||
@@ -173,8 +173,8 @@ export function PropertiesPanelBase({
|
|||||||
let def = registry.getAction(selectedAction.type);
|
let def = registry.getAction(selectedAction.type);
|
||||||
|
|
||||||
// Fallback: If action not found in registry, try without plugin prefix
|
// Fallback: If action not found in registry, try without plugin prefix
|
||||||
if (!def && selectedAction.type.includes('.')) {
|
if (!def && selectedAction.type.includes(".")) {
|
||||||
const baseType = selectedAction.type.split('.').pop();
|
const baseType = selectedAction.type.split(".").pop();
|
||||||
if (baseType) {
|
if (baseType) {
|
||||||
def = registry.getAction(baseType);
|
def = registry.getAction(baseType);
|
||||||
}
|
}
|
||||||
@@ -187,9 +187,9 @@ export function PropertiesPanelBase({
|
|||||||
type: selectedAction.type,
|
type: selectedAction.type,
|
||||||
name: selectedAction.name,
|
name: selectedAction.name,
|
||||||
description: `Action type: ${selectedAction.type}`,
|
description: `Action type: ${selectedAction.type}`,
|
||||||
category: selectedAction.category || 'control',
|
category: selectedAction.category || "control",
|
||||||
icon: 'Zap',
|
icon: "Zap",
|
||||||
color: '#6366f1',
|
color: "#6366f1",
|
||||||
parameters: [],
|
parameters: [],
|
||||||
source: selectedAction.source,
|
source: selectedAction.source,
|
||||||
};
|
};
|
||||||
@@ -230,7 +230,10 @@ export function PropertiesPanelBase({
|
|||||||
: Zap;
|
: Zap;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("w-full min-w-0 space-y-3 px-3", className)} id="tour-designer-properties">
|
<div
|
||||||
|
className={cn("w-full min-w-0 space-y-3 px-3", className)}
|
||||||
|
id="tour-designer-properties"
|
||||||
|
>
|
||||||
{/* Header / Metadata */}
|
{/* Header / Metadata */}
|
||||||
<div className="border-b pb-3">
|
<div className="border-b pb-3">
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
@@ -305,17 +308,23 @@ export function PropertiesPanelBase({
|
|||||||
{/* Branching Configuration (Special Case) */}
|
{/* Branching Configuration (Special Case) */}
|
||||||
{selectedAction.type === "branch" ? (
|
{selectedAction.type === "branch" ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="text-muted-foreground text-[10px] tracking-wide uppercase flex justify-between items-center">
|
<div className="text-muted-foreground flex items-center justify-between text-[10px] tracking-wide uppercase">
|
||||||
<span>Branch Options</span>
|
<span>Branch Options</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-5 w-5 p-0"
|
className="h-5 w-5 p-0"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const currentOptions = ((containingStep.trigger.conditions as any).options as any[]) || [];
|
const currentOptions =
|
||||||
|
((containingStep.trigger.conditions as any)
|
||||||
|
.options as any[]) || [];
|
||||||
const newOptions = [
|
const newOptions = [
|
||||||
...currentOptions,
|
...currentOptions,
|
||||||
{ label: "New Option", nextStepId: design.steps[containingStep.order + 1]?.id, variant: "default" }
|
{
|
||||||
|
label: "New Option",
|
||||||
|
nextStepId: design.steps[containingStep.order + 1]?.id,
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Sync to Step Trigger (Source of Truth)
|
// Sync to Step Trigger (Source of Truth)
|
||||||
@@ -324,16 +333,16 @@ export function PropertiesPanelBase({
|
|||||||
...containingStep.trigger,
|
...containingStep.trigger,
|
||||||
conditions: {
|
conditions: {
|
||||||
...containingStep.trigger.conditions,
|
...containingStep.trigger.conditions,
|
||||||
options: newOptions
|
options: newOptions,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
// Sync to Action Params (for consistency)
|
// Sync to Action Params (for consistency)
|
||||||
onActionUpdate(containingStep.id, selectedAction.id, {
|
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||||
parameters: {
|
parameters: {
|
||||||
...selectedAction.parameters,
|
...selectedAction.parameters,
|
||||||
options: newOptions
|
options: newOptions,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -342,26 +351,43 @@ export function PropertiesPanelBase({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{(((containingStep.trigger.conditions as any).options as any[]) || []).map((opt: any, idx: number) => (
|
{(
|
||||||
<div key={idx} className="space-y-2 p-2 rounded border bg-muted/50">
|
((containingStep.trigger.conditions as any).options as any[]) ||
|
||||||
|
[]
|
||||||
|
).map((opt: any, idx: number) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="bg-muted/50 space-y-2 rounded border p-2"
|
||||||
|
>
|
||||||
<div className="grid grid-cols-5 gap-2">
|
<div className="grid grid-cols-5 gap-2">
|
||||||
<div className="col-span-3">
|
<div className="col-span-3">
|
||||||
<Label className="text-[10px]">Label</Label>
|
<Label className="text-[10px]">Label</Label>
|
||||||
<Input
|
<Input
|
||||||
value={opt.label}
|
value={opt.label}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const currentOptions = ((containingStep.trigger.conditions as any).options as any[]) || [];
|
const currentOptions =
|
||||||
|
((containingStep.trigger.conditions as any)
|
||||||
|
.options as any[]) || [];
|
||||||
const newOpts = [...currentOptions];
|
const newOpts = [...currentOptions];
|
||||||
newOpts[idx] = { ...newOpts[idx], label: e.target.value };
|
newOpts[idx] = {
|
||||||
|
...newOpts[idx],
|
||||||
|
label: e.target.value,
|
||||||
|
};
|
||||||
|
|
||||||
onStepUpdate(containingStep.id, {
|
onStepUpdate(containingStep.id, {
|
||||||
trigger: {
|
trigger: {
|
||||||
...containingStep.trigger,
|
...containingStep.trigger,
|
||||||
conditions: { ...containingStep.trigger.conditions, options: newOpts }
|
conditions: {
|
||||||
}
|
...containingStep.trigger.conditions,
|
||||||
|
options: newOpts,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
onActionUpdate(containingStep.id, selectedAction.id, {
|
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||||
parameters: { ...selectedAction.parameters, options: newOpts }
|
parameters: {
|
||||||
|
...selectedAction.parameters,
|
||||||
|
options: newOpts,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
@@ -370,34 +396,53 @@ export function PropertiesPanelBase({
|
|||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<Label className="text-[10px]">Target Step</Label>
|
<Label className="text-[10px]">Target Step</Label>
|
||||||
{design.steps.length <= 1 ? (
|
{design.steps.length <= 1 ? (
|
||||||
<div className="h-7 flex items-center text-[10px] text-muted-foreground border rounded px-2 bg-muted/50 truncate" title="Add more steps to link">
|
<div
|
||||||
|
className="text-muted-foreground bg-muted/50 flex h-7 items-center truncate rounded border px-2 text-[10px]"
|
||||||
|
title="Add more steps to link"
|
||||||
|
>
|
||||||
No linkable steps
|
No linkable steps
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Select
|
<Select
|
||||||
value={opt.nextStepId ?? ""}
|
value={opt.nextStepId ?? ""}
|
||||||
onValueChange={(val) => {
|
onValueChange={(val) => {
|
||||||
const currentOptions = ((containingStep.trigger.conditions as any).options as any[]) || [];
|
const currentOptions =
|
||||||
|
((containingStep.trigger.conditions as any)
|
||||||
|
.options as any[]) || [];
|
||||||
const newOpts = [...currentOptions];
|
const newOpts = [...currentOptions];
|
||||||
newOpts[idx] = { ...newOpts[idx], nextStepId: val };
|
newOpts[idx] = { ...newOpts[idx], nextStepId: val };
|
||||||
|
|
||||||
onStepUpdate(containingStep.id, {
|
onStepUpdate(containingStep.id, {
|
||||||
trigger: {
|
trigger: {
|
||||||
...containingStep.trigger,
|
...containingStep.trigger,
|
||||||
conditions: { ...containingStep.trigger.conditions, options: newOpts }
|
conditions: {
|
||||||
}
|
...containingStep.trigger.conditions,
|
||||||
});
|
options: newOpts,
|
||||||
onActionUpdate(containingStep.id, selectedAction.id, {
|
},
|
||||||
parameters: { ...selectedAction.parameters, options: newOpts }
|
},
|
||||||
});
|
});
|
||||||
|
onActionUpdate(
|
||||||
|
containingStep.id,
|
||||||
|
selectedAction.id,
|
||||||
|
{
|
||||||
|
parameters: {
|
||||||
|
...selectedAction.parameters,
|
||||||
|
options: newOpts,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-7 text-xs w-full">
|
<SelectTrigger className="h-7 w-full text-xs">
|
||||||
<SelectValue placeholder="Select..." />
|
<SelectValue placeholder="Select..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="min-w-[180px]">
|
<SelectContent className="min-w-[180px]">
|
||||||
{design.steps.map((s) => (
|
{design.steps.map((s) => (
|
||||||
<SelectItem key={s.id} value={s.id} disabled={s.id === containingStep.id}>
|
<SelectItem
|
||||||
|
key={s.id}
|
||||||
|
value={s.id}
|
||||||
|
disabled={s.id === containingStep.id}
|
||||||
|
>
|
||||||
{s.order + 1}. {s.name}
|
{s.order + 1}. {s.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
@@ -410,18 +455,26 @@ export function PropertiesPanelBase({
|
|||||||
<Select
|
<Select
|
||||||
value={opt.variant || "default"}
|
value={opt.variant || "default"}
|
||||||
onValueChange={(val) => {
|
onValueChange={(val) => {
|
||||||
const currentOptions = ((containingStep.trigger.conditions as any).options as any[]) || [];
|
const currentOptions =
|
||||||
|
((containingStep.trigger.conditions as any)
|
||||||
|
.options as any[]) || [];
|
||||||
const newOpts = [...currentOptions];
|
const newOpts = [...currentOptions];
|
||||||
newOpts[idx] = { ...newOpts[idx], variant: val };
|
newOpts[idx] = { ...newOpts[idx], variant: val };
|
||||||
|
|
||||||
onStepUpdate(containingStep.id, {
|
onStepUpdate(containingStep.id, {
|
||||||
trigger: {
|
trigger: {
|
||||||
...containingStep.trigger,
|
...containingStep.trigger,
|
||||||
conditions: { ...containingStep.trigger.conditions, options: newOpts }
|
conditions: {
|
||||||
}
|
...containingStep.trigger.conditions,
|
||||||
|
options: newOpts,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
onActionUpdate(containingStep.id, selectedAction.id, {
|
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||||
parameters: { ...selectedAction.parameters, options: newOpts }
|
parameters: {
|
||||||
|
...selectedAction.parameters,
|
||||||
|
options: newOpts,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -430,7 +483,9 @@ export function PropertiesPanelBase({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="default">Default (Next)</SelectItem>
|
<SelectItem value="default">Default (Next)</SelectItem>
|
||||||
<SelectItem value="destructive">Destructive (Red)</SelectItem>
|
<SelectItem value="destructive">
|
||||||
|
Destructive (Red)
|
||||||
|
</SelectItem>
|
||||||
<SelectItem value="outline">Outline</SelectItem>
|
<SelectItem value="outline">Outline</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -438,20 +493,28 @@ export function PropertiesPanelBase({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-red-500"
|
className="text-muted-foreground h-6 w-6 p-0 hover:text-red-500"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const currentOptions = ((containingStep.trigger.conditions as any).options as any[]) || [];
|
const currentOptions =
|
||||||
|
((containingStep.trigger.conditions as any)
|
||||||
|
.options as any[]) || [];
|
||||||
const newOpts = [...currentOptions];
|
const newOpts = [...currentOptions];
|
||||||
newOpts.splice(idx, 1);
|
newOpts.splice(idx, 1);
|
||||||
|
|
||||||
onStepUpdate(containingStep.id, {
|
onStepUpdate(containingStep.id, {
|
||||||
trigger: {
|
trigger: {
|
||||||
...containingStep.trigger,
|
...containingStep.trigger,
|
||||||
conditions: { ...containingStep.trigger.conditions, options: newOpts }
|
conditions: {
|
||||||
}
|
...containingStep.trigger.conditions,
|
||||||
|
options: newOpts,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
onActionUpdate(containingStep.id, selectedAction.id, {
|
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||||
parameters: { ...selectedAction.parameters, options: newOpts }
|
parameters: {
|
||||||
|
...selectedAction.parameters,
|
||||||
|
options: newOpts,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -460,9 +523,12 @@ export function PropertiesPanelBase({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{(!(((containingStep.trigger.conditions as any).options as any[])?.length)) && (
|
{!((containingStep.trigger.conditions as any).options as any[])
|
||||||
<div className="text-center py-4 border border-dashed rounded text-xs text-muted-foreground">
|
?.length && (
|
||||||
No options defined.<br />Click + to add a branch.
|
<div className="text-muted-foreground rounded border border-dashed py-4 text-center text-xs">
|
||||||
|
No options defined.
|
||||||
|
<br />
|
||||||
|
Click + to add a branch.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -478,7 +544,7 @@ export function PropertiesPanelBase({
|
|||||||
{/* Iterations */}
|
{/* Iterations */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">Iterations</Label>
|
<Label className="text-xs">Iterations</Label>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="mt-1 flex items-center gap-2">
|
||||||
<Slider
|
<Slider
|
||||||
min={1}
|
min={1}
|
||||||
max={20}
|
max={20}
|
||||||
@@ -493,15 +559,14 @@ export function PropertiesPanelBase({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs font-mono w-8 text-right">
|
<span className="w-8 text-right font-mono text-xs">
|
||||||
{Number(selectedAction.parameters.iterations || 1)}
|
{Number(selectedAction.parameters.iterations || 1)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : /* Standard Parameters */
|
||||||
/* Standard Parameters */
|
|
||||||
def?.parameters.length ? (
|
def?.parameters.length ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="text-muted-foreground text-[10px] tracking-wide uppercase">
|
<div className="text-muted-foreground text-[10px] tracking-wide uppercase">
|
||||||
@@ -521,7 +586,7 @@ export function PropertiesPanelBase({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onCommit={() => { }}
|
onCommit={() => {}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -530,7 +595,6 @@ export function PropertiesPanelBase({
|
|||||||
<div className="text-muted-foreground text-xs">
|
<div className="text-muted-foreground text-xs">
|
||||||
No parameters for this action.
|
No parameters for this action.
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -539,7 +603,10 @@ export function PropertiesPanelBase({
|
|||||||
/* --------------------------- Step Properties View --------------------------- */
|
/* --------------------------- Step Properties View --------------------------- */
|
||||||
if (selectedStep) {
|
if (selectedStep) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("w-full min-w-0 space-y-3 px-3", className)} id="tour-designer-properties">
|
<div
|
||||||
|
className={cn("w-full min-w-0 space-y-3 px-3", className)}
|
||||||
|
id="tour-designer-properties"
|
||||||
|
>
|
||||||
<div className="border-b pb-2">
|
<div className="border-b pb-2">
|
||||||
<h3 className="flex items-center gap-2 text-sm font-medium">
|
<h3 className="flex items-center gap-2 text-sm font-medium">
|
||||||
<div
|
<div
|
||||||
@@ -625,7 +692,8 @@ export function PropertiesPanelBase({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||||
Steps always execute sequentially. Use control flow actions for parallel/conditional logic.
|
Steps always execute sequentially. Use control flow actions
|
||||||
|
for parallel/conditional logic.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -697,7 +765,7 @@ const ParameterEditor = React.memo(function ParameterEditor({
|
|||||||
param,
|
param,
|
||||||
value: rawValue,
|
value: rawValue,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onCommit
|
onCommit,
|
||||||
}: ParameterEditorProps) {
|
}: ParameterEditorProps) {
|
||||||
// Local state for immediate feedback
|
// Local state for immediate feedback
|
||||||
const [localValue, setLocalValue] = useState<unknown>(rawValue);
|
const [localValue, setLocalValue] = useState<unknown>(rawValue);
|
||||||
@@ -708,7 +776,8 @@ const ParameterEditor = React.memo(function ParameterEditor({
|
|||||||
setLocalValue(rawValue);
|
setLocalValue(rawValue);
|
||||||
}, [rawValue]);
|
}, [rawValue]);
|
||||||
|
|
||||||
const handleUpdate = useCallback((newVal: unknown, immediate = false) => {
|
const handleUpdate = useCallback(
|
||||||
|
(newVal: unknown, immediate = false) => {
|
||||||
setLocalValue(newVal);
|
setLocalValue(newVal);
|
||||||
|
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
@@ -720,7 +789,9 @@ const ParameterEditor = React.memo(function ParameterEditor({
|
|||||||
onUpdate(newVal);
|
onUpdate(newVal);
|
||||||
}, 300);
|
}, 300);
|
||||||
}
|
}
|
||||||
}, [onUpdate]);
|
},
|
||||||
|
[onUpdate],
|
||||||
|
);
|
||||||
|
|
||||||
const handleCommit = useCallback(() => {
|
const handleCommit = useCallback(() => {
|
||||||
if (localValue !== rawValue) {
|
if (localValue !== rawValue) {
|
||||||
@@ -772,13 +843,22 @@ const ParameterEditor = React.memo(function ParameterEditor({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (param.type === "number") {
|
} else if (param.type === "number") {
|
||||||
const numericVal = typeof localValue === "number" ? localValue : (param.min ?? 0);
|
const numericVal =
|
||||||
|
typeof localValue === "number" ? localValue : (param.min ?? 0);
|
||||||
|
|
||||||
if (param.min !== undefined || param.max !== undefined) {
|
if (param.min !== undefined || param.max !== undefined) {
|
||||||
const min = param.min ?? 0;
|
const min = param.min ?? 0;
|
||||||
const max = param.max ?? Math.max(min + 1, Number.isFinite(numericVal) ? numericVal : min + 1);
|
const max =
|
||||||
|
param.max ??
|
||||||
|
Math.max(min + 1, Number.isFinite(numericVal) ? numericVal : min + 1);
|
||||||
const range = max - min;
|
const range = max - min;
|
||||||
const step = param.step ?? (range <= 5 ? 0.1 : range <= 50 ? 0.5 : Math.max(1, Math.round(range / 100)));
|
const step =
|
||||||
|
param.step ??
|
||||||
|
(range <= 5
|
||||||
|
? 0.1
|
||||||
|
: range <= 50
|
||||||
|
? 0.5
|
||||||
|
: Math.max(1, Math.round(range / 100)));
|
||||||
|
|
||||||
control = (
|
control = (
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
@@ -792,7 +872,9 @@ const ParameterEditor = React.memo(function ParameterEditor({
|
|||||||
onPointerUp={() => handleUpdate(localValue)} // Commit on release
|
onPointerUp={() => handleUpdate(localValue)} // Commit on release
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground min-w-[2.5rem] text-right text-[10px] tabular-nums">
|
<span className="text-muted-foreground min-w-[2.5rem] text-right text-[10px] tabular-nums">
|
||||||
{step < 1 ? Number(numericVal).toFixed(2) : Number(numericVal).toString()}
|
{step < 1
|
||||||
|
? Number(numericVal).toFixed(2)
|
||||||
|
: Number(numericVal).toString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground mt-1 flex justify-between text-[10px]">
|
<div className="text-muted-foreground mt-1 flex justify-between text-[10px]">
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function SettingsModal({
|
|||||||
}: SettingsModalProps) {
|
}: SettingsModalProps) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-4xl max-h-[90vh] p-0">
|
<DialogContent className="max-h-[90vh] max-w-4xl p-0">
|
||||||
<DialogHeader className="sr-only">
|
<DialogHeader className="sr-only">
|
||||||
<DialogTitle>Experiment Settings</DialogTitle>
|
<DialogTitle>Experiment Settings</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
@@ -106,8 +106,6 @@ function flattenIssues(issuesMap: Record<string, ValidationIssue[]>) {
|
|||||||
return flattened;
|
return flattened;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
/* Issue Item Component */
|
/* Issue Item Component */
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
@@ -145,7 +143,7 @@ function IssueItem({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-[12px] leading-snug break-words whitespace-normal text-foreground">
|
<p className="text-foreground text-[12px] leading-snug break-words whitespace-normal">
|
||||||
{issue.message}
|
{issue.message}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -248,8 +246,6 @@ export function ValidationPanel({
|
|||||||
console.log("[ValidationPanel] issues", issues, { flatIssues, counts });
|
console.log("[ValidationPanel] issues", issues, { flatIssues, counts });
|
||||||
}, [issues, flatIssues, counts]);
|
}, [issues, flatIssues, counts]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
List,
|
List,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Play,
|
Play,
|
||||||
HelpCircle
|
HelpCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { type ExperimentAction } from "~/lib/experiment-designer/types";
|
import { type ExperimentAction } from "~/lib/experiment-designer/types";
|
||||||
@@ -30,7 +30,11 @@ export interface ActionChipProps {
|
|||||||
selectedActionId: string | null | undefined;
|
selectedActionId: string | null | undefined;
|
||||||
onSelectAction: (stepId: string, actionId: string | undefined) => void;
|
onSelectAction: (stepId: string, actionId: string | undefined) => void;
|
||||||
onDeleteAction: (stepId: string, actionId: string) => void;
|
onDeleteAction: (stepId: string, actionId: string) => void;
|
||||||
onReorderAction?: (stepId: string, actionId: string, direction: 'up' | 'down') => void;
|
onReorderAction?: (
|
||||||
|
stepId: string,
|
||||||
|
actionId: string,
|
||||||
|
direction: "up" | "down",
|
||||||
|
) => void;
|
||||||
dragHandle?: boolean;
|
dragHandle?: boolean;
|
||||||
isFirst?: boolean;
|
isFirst?: boolean;
|
||||||
isLast?: boolean;
|
isLast?: boolean;
|
||||||
@@ -43,7 +47,7 @@ export interface ActionChipVisualsProps {
|
|||||||
isOverNested?: boolean;
|
isOverNested?: boolean;
|
||||||
onSelect?: (e: React.MouseEvent) => void;
|
onSelect?: (e: React.MouseEvent) => void;
|
||||||
onDelete?: (e: React.MouseEvent) => void;
|
onDelete?: (e: React.MouseEvent) => void;
|
||||||
onReorder?: (direction: 'up' | 'down') => void;
|
onReorder?: (direction: "up" | "down") => void;
|
||||||
dragHandleProps?: React.HTMLAttributes<HTMLElement>;
|
dragHandleProps?: React.HTMLAttributes<HTMLElement>;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
isFirst?: boolean;
|
isFirst?: boolean;
|
||||||
@@ -103,8 +107,6 @@ function getActionVisualStyle(action: ExperimentAction) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// General Categories
|
// General Categories
|
||||||
if (category === "wizard") {
|
if (category === "wizard") {
|
||||||
return {
|
return {
|
||||||
@@ -117,7 +119,11 @@ function getActionVisualStyle(action: ExperimentAction) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((category as string) === "robot" || (category as string) === "movement" || (category as string) === "speech") {
|
if (
|
||||||
|
(category as string) === "robot" ||
|
||||||
|
(category as string) === "movement" ||
|
||||||
|
(category as string) === "speech"
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
variant: "robot",
|
variant: "robot",
|
||||||
icon: Play, // Or specific robot icon if available
|
icon: Play, // Or specific robot icon if available
|
||||||
@@ -125,7 +131,7 @@ function getActionVisualStyle(action: ExperimentAction) {
|
|||||||
border: "border-slate-200 dark:border-slate-700",
|
border: "border-slate-200 dark:border-slate-700",
|
||||||
text: "text-slate-700 dark:text-slate-300",
|
text: "text-slate-700 dark:text-slate-300",
|
||||||
accent: "bg-slate-500",
|
accent: "bg-slate-500",
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default
|
// Default
|
||||||
@@ -139,7 +145,6 @@ function getActionVisualStyle(action: ExperimentAction) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function ActionChipVisuals({
|
export function ActionChipVisuals({
|
||||||
action,
|
action,
|
||||||
isSelected,
|
isSelected,
|
||||||
@@ -164,9 +169,11 @@ export function ActionChipVisuals({
|
|||||||
"group relative flex w-full flex-col items-start gap-1 rounded border px-3 py-2 text-[11px] transition-all duration-200",
|
"group relative flex w-full flex-col items-start gap-1 rounded border px-3 py-2 text-[11px] transition-all duration-200",
|
||||||
style.bg,
|
style.bg,
|
||||||
style.border,
|
style.border,
|
||||||
isSelected && "ring-2 ring-primary border-primary bg-accent/50",
|
isSelected && "ring-primary border-primary bg-accent/50 ring-2",
|
||||||
isDragging && "opacity-70 shadow-lg scale-95",
|
isDragging && "scale-95 opacity-70 shadow-lg",
|
||||||
isOverNested && !isDragging && "ring-2 ring-blue-400 ring-offset-1 bg-blue-50/50 dark:bg-blue-900/20"
|
isOverNested &&
|
||||||
|
!isDragging &&
|
||||||
|
"bg-blue-50/50 ring-2 ring-blue-400 ring-offset-1 dark:bg-blue-900/20",
|
||||||
)}
|
)}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
role="button"
|
role="button"
|
||||||
@@ -175,50 +182,77 @@ export function ActionChipVisuals({
|
|||||||
>
|
>
|
||||||
{/* Accent Bar logic for control flow */}
|
{/* Accent Bar logic for control flow */}
|
||||||
{style.variant !== "default" && style.variant !== "robot" && (
|
{style.variant !== "default" && style.variant !== "robot" && (
|
||||||
<div className={cn("absolute left-0 top-0 bottom-0 w-1 rounded-l", style.accent)} />
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute top-0 bottom-0 left-0 w-1 rounded-l",
|
||||||
|
style.accent,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={cn("flex w-full items-center gap-2", style.variant !== "default" && style.variant !== "robot" && "pl-2")}>
|
<div
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
className={cn(
|
||||||
{Icon && <Icon className={cn("h-3.5 w-3.5 flex-shrink-0", style.text)} />}
|
"flex w-full items-center gap-2",
|
||||||
<span className={cn("leading-snug font-medium break-words truncate", style.text)}>
|
style.variant !== "default" && style.variant !== "robot" && "pl-2",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
|
{Icon && (
|
||||||
|
<Icon className={cn("h-3.5 w-3.5 flex-shrink-0", style.text)} />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"truncate leading-snug font-medium break-words",
|
||||||
|
style.text,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{action.name}
|
{action.name}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Inline Info for Control Actions */}
|
{/* Inline Info for Control Actions */}
|
||||||
{style.variant === "wait" && !!action.parameters.duration && (
|
{style.variant === "wait" && !!action.parameters.duration && (
|
||||||
<span className="ml-1 text-[10px] bg-background/50 px-1.5 py-0.5 rounded font-mono text-muted-foreground">
|
<span className="bg-background/50 text-muted-foreground ml-1 rounded px-1.5 py-0.5 font-mono text-[10px]">
|
||||||
{String(action.parameters.duration ?? "")}s
|
{String(action.parameters.duration ?? "")}s
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{style.variant === "loop" && (
|
{style.variant === "loop" && (
|
||||||
<span className="ml-1 text-[10px] bg-background/50 px-1.5 py-0.5 rounded font-mono text-muted-foreground">
|
<span className="bg-background/50 text-muted-foreground ml-1 rounded px-1.5 py-0.5 font-mono text-[10px]">
|
||||||
{String(action.parameters.iterations || 1)}x
|
{String(action.parameters.iterations || 1)}x
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{style.variant === "loop" && action.parameters.requireApproval !== false && (
|
{style.variant === "loop" &&
|
||||||
<span className="ml-1 text-[10px] bg-purple-500/20 px-1.5 py-0.5 rounded font-mono text-purple-700 dark:text-purple-300 flex items-center gap-0.5" title="Requires Wizard Approval">
|
action.parameters.requireApproval !== false && (
|
||||||
|
<span
|
||||||
|
className="ml-1 flex items-center gap-0.5 rounded bg-purple-500/20 px-1.5 py-0.5 font-mono text-[10px] text-purple-700 dark:text-purple-300"
|
||||||
|
title="Requires Wizard Approval"
|
||||||
|
>
|
||||||
<HelpCircle className="h-2 w-2" />
|
<HelpCircle className="h-2 w-2" />
|
||||||
Ask
|
Ask
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{validationStatus === "error" && (
|
{validationStatus === "error" && (
|
||||||
<div className="h-2 w-2 rounded-full bg-red-500 ring-1 ring-red-600 flex-shrink-0" aria-label="Error" />
|
<div
|
||||||
|
className="h-2 w-2 flex-shrink-0 rounded-full bg-red-500 ring-1 ring-red-600"
|
||||||
|
aria-label="Error"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{validationStatus === "warning" && (
|
{validationStatus === "warning" && (
|
||||||
<div className="h-2 w-2 rounded-full bg-amber-500 ring-1 ring-amber-600 flex-shrink-0" aria-label="Warning" />
|
<div
|
||||||
|
className="h-2 w-2 flex-shrink-0 rounded-full bg-amber-500 ring-1 ring-amber-600"
|
||||||
|
aria-label="Warning"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-0.5 mr-1 bg-background/50 rounded-md border border-border/50 shadow-sm px-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="bg-background/50 border-border/50 mr-1 flex items-center gap-0.5 rounded-md border px-0.5 opacity-0 shadow-sm transition-opacity group-hover:opacity-100">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-5 w-5 p-0 text-[10px] text-muted-foreground hover:text-foreground z-20 pointer-events-auto"
|
className="text-muted-foreground hover:text-foreground pointer-events-auto z-20 h-5 w-5 p-0 text-[10px]"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onReorder?.('up');
|
onReorder?.("up");
|
||||||
}}
|
}}
|
||||||
disabled={isFirst}
|
disabled={isFirst}
|
||||||
aria-label="Move action up"
|
aria-label="Move action up"
|
||||||
@@ -228,10 +262,10 @@ export function ActionChipVisuals({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-5 w-5 p-0 text-[10px] text-muted-foreground hover:text-foreground z-20 pointer-events-auto"
|
className="text-muted-foreground hover:text-foreground pointer-events-auto z-20 h-5 w-5 p-0 text-[10px]"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onReorder?.('down');
|
onReorder?.("down");
|
||||||
}}
|
}}
|
||||||
disabled={isLast}
|
disabled={isLast}
|
||||||
aria-label="Move action down"
|
aria-label="Move action down"
|
||||||
@@ -251,35 +285,39 @@ export function ActionChipVisuals({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description / Subtext */}
|
{/* Description / Subtext */}
|
||||||
{
|
{def?.description && (
|
||||||
def?.description && (
|
<div
|
||||||
<div className={cn("text-muted-foreground line-clamp-2 w-full text-[10px] leading-snug pl-2 mt-0.5", style.variant !== "default" && style.variant !== "robot" && "pl-4")}>
|
className={cn(
|
||||||
|
"text-muted-foreground mt-0.5 line-clamp-2 w-full pl-2 text-[10px] leading-snug",
|
||||||
|
style.variant !== "default" && style.variant !== "robot" && "pl-4",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{def.description}
|
{def.description}
|
||||||
</div>
|
</div>
|
||||||
)
|
)}
|
||||||
}
|
|
||||||
|
|
||||||
{/* Tags for parameters (hide for specialized control blocks that show inline) */}
|
{/* Tags for parameters (hide for specialized control blocks that show inline) */}
|
||||||
{
|
{def?.parameters?.length &&
|
||||||
def?.parameters?.length && (style.variant === 'default' || style.variant === 'robot') ? (
|
(style.variant === "default" || style.variant === "robot") ? (
|
||||||
<div className="flex flex-wrap gap-1 pt-1">
|
<div className="flex flex-wrap gap-1 pt-1">
|
||||||
{def.parameters.slice(0, 3).map((p) => (
|
{def.parameters.slice(0, 3).map((p) => (
|
||||||
<span
|
<span
|
||||||
key={p.id}
|
key={p.id}
|
||||||
className="bg-background/80 text-muted-foreground ring-border rounded px-1 py-0.5 text-[9px] font-medium ring-1 truncate max-w-[80px]"
|
className="bg-background/80 text-muted-foreground ring-border max-w-[80px] truncate rounded px-1 py-0.5 text-[9px] font-medium ring-1"
|
||||||
>
|
>
|
||||||
{p.name}
|
{p.name}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{def.parameters.length > 3 && (
|
{def.parameters.length > 3 && (
|
||||||
<span className="text-[9px] text-muted-foreground">+{def.parameters.length - 3}</span>
|
<span className="text-muted-foreground text-[9px]">
|
||||||
|
+{def.parameters.length - 3}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null
|
) : null}
|
||||||
}
|
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</div >
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,9 +344,12 @@ export function SortableActionChip({
|
|||||||
if (!action.type.includes("branch") || !currentStep) return null;
|
if (!action.type.includes("branch") || !currentStep) return null;
|
||||||
|
|
||||||
const options = (currentStep.trigger as any)?.conditions?.options;
|
const options = (currentStep.trigger as any)?.conditions?.options;
|
||||||
if (!options?.length && !(currentStep.trigger as any)?.conditions?.nextStepId) {
|
if (
|
||||||
|
!options?.length &&
|
||||||
|
!(currentStep.trigger as any)?.conditions?.nextStepId
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 text-muted-foreground/60 italic text-center py-2 text-[10px] bg-background/50 rounded border border-dashed">
|
<div className="text-muted-foreground/60 bg-background/50 mt-2 rounded border border-dashed py-2 text-center text-[10px] italic">
|
||||||
No branches configured. Add options in properties.
|
No branches configured. Add options in properties.
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -319,43 +360,55 @@ export function SortableActionChip({
|
|||||||
// (step.trigger.conditions as any).options.map...
|
// (step.trigger.conditions as any).options.map...
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 space-y-1 w-full">
|
<div className="mt-2 w-full space-y-1">
|
||||||
{options?.map((opt: any, idx: number) => {
|
{options?.map((opt: any, idx: number) => {
|
||||||
// Resolve ID to name for display
|
// Resolve ID to name for display
|
||||||
let targetName = "Unlinked";
|
let targetName = "Unlinked";
|
||||||
let targetIndex = -1;
|
let targetIndex = -1;
|
||||||
|
|
||||||
if (opt.nextStepId) {
|
if (opt.nextStepId) {
|
||||||
const target = steps.find(s => s.id === opt.nextStepId);
|
const target = steps.find((s) => s.id === opt.nextStepId);
|
||||||
if (target) {
|
if (target) {
|
||||||
targetName = target.name;
|
targetName = target.name;
|
||||||
targetIndex = target.order;
|
targetIndex = target.order;
|
||||||
}
|
}
|
||||||
} else if (typeof opt.nextStepIndex === 'number') {
|
} else if (typeof opt.nextStepIndex === "number") {
|
||||||
targetIndex = opt.nextStepIndex;
|
targetIndex = opt.nextStepIndex;
|
||||||
targetName = `Step #${targetIndex + 1}`;
|
targetName = `Step #${targetIndex + 1}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={idx} className="flex items-center justify-between rounded bg-background/50 shadow-sm border p-1.5 text-[10px]">
|
<div
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
key={idx}
|
||||||
<Badge variant="outline" className={cn(
|
className="bg-background/50 flex items-center justify-between rounded border p-1.5 text-[10px] shadow-sm"
|
||||||
"text-[9px] uppercase font-bold tracking-wider px-1 py-0 min-w-[60px] justify-center bg-background",
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"bg-background min-w-[60px] justify-center px-1 py-0 text-[9px] font-bold tracking-wider uppercase",
|
||||||
opt.variant === "destructive"
|
opt.variant === "destructive"
|
||||||
? "border-red-500/30 text-red-600 dark:text-red-400"
|
? "border-red-500/30 text-red-600 dark:text-red-400"
|
||||||
: "border-slate-500/30 text-foreground"
|
: "text-foreground border-slate-500/30",
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
<ChevronRight className="h-3 w-3 text-muted-foreground/50 flex-shrink-0" />
|
<ChevronRight className="text-muted-foreground/50 h-3 w-3 flex-shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 text-right min-w-0 max-w-[60%] justify-end">
|
<div className="flex max-w-[60%] min-w-0 items-center justify-end gap-1.5 text-right">
|
||||||
<span className="font-medium truncate text-foreground/80" title={targetName}>
|
<span
|
||||||
|
className="text-foreground/80 truncate font-medium"
|
||||||
|
title={targetName}
|
||||||
|
>
|
||||||
{targetName}
|
{targetName}
|
||||||
</span>
|
</span>
|
||||||
{targetIndex !== -1 && (
|
{targetIndex !== -1 && (
|
||||||
<Badge variant="secondary" className="px-1 py-0 h-3.5 text-[9px] min-w-[18px] justify-center tabular-nums bg-slate-100 dark:bg-slate-800">
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="h-3.5 min-w-[18px] justify-center bg-slate-100 px-1 py-0 text-[9px] tabular-nums dark:bg-slate-800"
|
||||||
|
>
|
||||||
#{targetIndex + 1}
|
#{targetIndex + 1}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -410,18 +463,15 @@ export function SortableActionChip({
|
|||||||
/* ------------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------------ */
|
||||||
const def = actionRegistry.getAction(action.type);
|
const def = actionRegistry.getAction(action.type);
|
||||||
const nestedDroppableId = `container-${action.id}`;
|
const nestedDroppableId = `container-${action.id}`;
|
||||||
const {
|
const { isOver: isOverNested, setNodeRef: setNestedNodeRef } = useDroppable({
|
||||||
isOver: isOverNested,
|
|
||||||
setNodeRef: setNestedNodeRef
|
|
||||||
} = useDroppable({
|
|
||||||
id: nestedDroppableId,
|
id: nestedDroppableId,
|
||||||
disabled: !def?.nestable || isPlaceholder, // Disable droppable for placeholder
|
disabled: !def?.nestable || isPlaceholder, // Disable droppable for placeholder
|
||||||
data: {
|
data: {
|
||||||
type: "container",
|
type: "container",
|
||||||
stepId,
|
stepId,
|
||||||
parentId: action.id,
|
parentId: action.id,
|
||||||
action // Pass full action for projection logic
|
action, // Pass full action for projection logic
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const shouldRenderChildren = !!def?.nestable;
|
const shouldRenderChildren = !!def?.nestable;
|
||||||
@@ -431,7 +481,7 @@ export function SortableActionChip({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-full flex-col items-start gap-1 rounded border border-dashed px-3 py-2 text-[11px]",
|
"relative flex w-full flex-col items-start gap-1 rounded border border-dashed px-3 py-2 text-[11px]",
|
||||||
"bg-blue-50/50 dark:bg-blue-900/20 border-blue-400 opacity-70"
|
"border-blue-400 bg-blue-50/50 opacity-70 dark:bg-blue-900/20",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center gap-2">
|
<div className="flex w-full items-center gap-2">
|
||||||
@@ -439,7 +489,7 @@ export function SortableActionChip({
|
|||||||
{action.name}
|
{action.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div >
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,12 +522,12 @@ export function SortableActionChip({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"mt-2 w-full space-y-2 rounded border border-dashed p-1.5 transition-colors",
|
"mt-2 w-full space-y-2 rounded border border-dashed p-1.5 transition-colors",
|
||||||
isOverNested
|
isOverNested
|
||||||
? "bg-blue-100/50 dark:bg-blue-900/20 border-blue-400"
|
? "border-blue-400 bg-blue-100/50 dark:bg-blue-900/20"
|
||||||
: "bg-muted/20 dark:bg-muted/10 border-border/50"
|
: "bg-muted/20 dark:bg-muted/10 border-border/50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{displayChildren?.length === 0 ? (
|
{displayChildren?.length === 0 ? (
|
||||||
<div className="py-2 text-center text-[10px] text-muted-foreground/60 italic">
|
<div className="text-muted-foreground/60 py-2 text-center text-[10px] italic">
|
||||||
Empty container
|
Empty container
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -97,8 +97,12 @@ interface StepRowProps {
|
|||||||
onDeleteAction: (stepId: string, actionId: string) => void;
|
onDeleteAction: (stepId: string, actionId: string) => void;
|
||||||
setRenamingStepId: (id: string | null) => void;
|
setRenamingStepId: (id: string | null) => void;
|
||||||
registerMeasureRef: (stepId: string, el: HTMLDivElement | null) => void;
|
registerMeasureRef: (stepId: string, el: HTMLDivElement | null) => void;
|
||||||
onReorderStep: (stepId: string, direction: 'up' | 'down') => void;
|
onReorderStep: (stepId: string, direction: "up" | "down") => void;
|
||||||
onReorderAction?: (stepId: string, actionId: string, direction: 'up' | 'down') => void;
|
onReorderAction?: (
|
||||||
|
stepId: string,
|
||||||
|
actionId: string,
|
||||||
|
direction: "up" | "down",
|
||||||
|
) => void;
|
||||||
isChild?: boolean;
|
isChild?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,12 +161,12 @@ function StepRow({
|
|||||||
ref={(el) => registerMeasureRef(step.id, el)}
|
ref={(el) => registerMeasureRef(step.id, el)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative px-3 py-4 transition-all duration-300",
|
"relative px-3 py-4 transition-all duration-300",
|
||||||
isChild && "ml-8 pl-0"
|
isChild && "ml-8 pl-0",
|
||||||
)}
|
)}
|
||||||
data-step-id={step.id}
|
data-step-id={step.id}
|
||||||
>
|
>
|
||||||
{isChild && (
|
{isChild && (
|
||||||
<div className="absolute left-[-24px] top-8 text-muted-foreground/40">
|
<div className="text-muted-foreground/40 absolute top-8 left-[-24px]">
|
||||||
<CornerDownRight className="h-5 w-5" />
|
<CornerDownRight className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -172,7 +176,7 @@ function StepRow({
|
|||||||
"mb-2 rounded-lg border shadow-sm transition-colors",
|
"mb-2 rounded-lg border shadow-sm transition-colors",
|
||||||
selectedStepId === step.id
|
selectedStepId === step.id
|
||||||
? "border-border bg-accent/30"
|
? "border-border bg-accent/30"
|
||||||
: "hover:bg-accent/30"
|
: "hover:bg-accent/30",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -268,10 +272,10 @@ function StepRow({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 w-7 p-0 text-[11px] text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground h-7 w-7 p-0 text-[11px]"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onReorderStep(step.id, 'up');
|
onReorderStep(step.id, "up");
|
||||||
}}
|
}}
|
||||||
disabled={item.index === 0}
|
disabled={item.index === 0}
|
||||||
aria-label="Move step up"
|
aria-label="Move step up"
|
||||||
@@ -281,58 +285,69 @@ function StepRow({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 w-7 p-0 text-[11px] text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground h-7 w-7 p-0 text-[11px]"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onReorderStep(step.id, 'down');
|
onReorderStep(step.id, "down");
|
||||||
}}
|
}}
|
||||||
disabled={item.index === totalSteps - 1}
|
disabled={item.index === totalSteps - 1}
|
||||||
aria-label="Move step down"
|
aria-label="Move step down"
|
||||||
>
|
>
|
||||||
<ChevronRight className="h-4 w-4 rotate-90" />
|
<ChevronRight className="h-4 w-4 rotate-90" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Conditional Branching Visualization */}
|
{/* Conditional Branching Visualization */}
|
||||||
|
|
||||||
|
|
||||||
{/* Loop Visualization */}
|
{/* Loop Visualization */}
|
||||||
{step.type === "loop" && (
|
{step.type === "loop" && (
|
||||||
<div className="mx-3 my-3 rounded-md border text-xs" style={{
|
<div
|
||||||
backgroundColor: 'var(--validation-info-bg, #f0f9ff)',
|
className="mx-3 my-3 rounded-md border text-xs"
|
||||||
borderColor: 'var(--validation-info-border, #bae6fd)',
|
style={{
|
||||||
}}>
|
backgroundColor: "var(--validation-info-bg, #f0f9ff)",
|
||||||
<div className="flex items-center gap-2 border-b px-3 py-2 font-medium" style={{
|
borderColor: "var(--validation-info-border, #bae6fd)",
|
||||||
borderColor: 'var(--validation-info-border, #bae6fd)',
|
}}
|
||||||
color: 'var(--validation-info-text, #0369a1)'
|
>
|
||||||
}}>
|
<div
|
||||||
|
className="flex items-center gap-2 border-b px-3 py-2 font-medium"
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--validation-info-border, #bae6fd)",
|
||||||
|
color: "var(--validation-info-text, #0369a1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Repeat className="h-3.5 w-3.5" />
|
<Repeat className="h-3.5 w-3.5" />
|
||||||
<span>Loop Logic</span>
|
<span>Loop Logic</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-2 space-y-2">
|
<div className="space-y-2 p-2">
|
||||||
<div className="flex items-center gap-2 text-[11px]">
|
<div className="flex items-center gap-2 text-[11px]">
|
||||||
<span className="text-muted-foreground">Repeat:</span>
|
<span className="text-muted-foreground">Repeat:</span>
|
||||||
<Badge variant="outline" className="font-mono">
|
<Badge variant="outline" className="font-mono">
|
||||||
{(step.trigger.conditions as any).loop?.iterations || 1} times
|
{(step.trigger.conditions as any).loop?.iterations || 1}{" "}
|
||||||
|
times
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-[11px]">
|
<div className="flex items-center gap-2 text-[11px]">
|
||||||
<span className="text-muted-foreground">Approval:</span>
|
<span className="text-muted-foreground">Approval:</span>
|
||||||
<Badge variant={(step.trigger.conditions as any).loop?.requireApproval !== false ? "default" : "secondary"}>
|
<Badge
|
||||||
{(step.trigger.conditions as any).loop?.requireApproval !== false ? "Required" : "Auto-proceed"}
|
variant={
|
||||||
|
(step.trigger.conditions as any).loop?.requireApproval !==
|
||||||
|
false
|
||||||
|
? "default"
|
||||||
|
: "secondary"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(step.trigger.conditions as any).loop?.requireApproval !==
|
||||||
|
false
|
||||||
|
? "Required"
|
||||||
|
: "Auto-proceed"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Action List (Collapsible/Virtual content) */}
|
{/* Action List (Collapsible/Virtual content) */}
|
||||||
{step.expanded && (
|
{step.expanded && (
|
||||||
<div className="bg-background/40 min-h-[3rem] space-y-2 p-2 pb-8">
|
<div className="bg-background/40 min-h-[3rem] space-y-2 p-2 pb-8">
|
||||||
@@ -342,7 +357,7 @@ function StepRow({
|
|||||||
>
|
>
|
||||||
<div className="flex w-full flex-col gap-2">
|
<div className="flex w-full flex-col gap-2">
|
||||||
{displayActions.length === 0 ? (
|
{displayActions.length === 0 ? (
|
||||||
<div className="flex h-12 items-center justify-center rounded border border-dashed text-xs text-muted-foreground">
|
<div className="text-muted-foreground flex h-12 items-center justify-center rounded border border-dashed text-xs">
|
||||||
Drop actions here
|
Drop actions here
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -367,7 +382,7 @@ function StepRow({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div >
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,15 +390,21 @@ function StepRow({
|
|||||||
/* Step Card Preview (for DragOverlay) */
|
/* Step Card Preview (for DragOverlay) */
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
export function StepCardPreview({ step, dragHandle }: { step: ExperimentStep; dragHandle?: boolean }) {
|
export function StepCardPreview({
|
||||||
|
step,
|
||||||
|
dragHandle,
|
||||||
|
}: {
|
||||||
|
step: ExperimentStep;
|
||||||
|
dragHandle?: boolean;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg border bg-background shadow-xl ring-2 ring-blue-500/20",
|
"bg-background rounded-lg border shadow-xl ring-2 ring-blue-500/20",
|
||||||
dragHandle && "cursor-grabbing"
|
dragHandle && "cursor-grabbing",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2 border-b px-2 py-1.5 p-3">
|
<div className="flex items-center justify-between gap-2 border-b p-3 px-2 py-1.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="text-muted-foreground rounded p-1">
|
<div className="text-muted-foreground rounded p-1">
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
@@ -401,13 +422,13 @@ export function StepCardPreview({ step, dragHandle }: { step: ExperimentStep; dr
|
|||||||
{step.actions.length} actions
|
{step.actions.length} actions
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 text-muted-foreground">
|
<div className="text-muted-foreground flex items-center gap-1">
|
||||||
<GripVertical className="h-4 w-4" />
|
<GripVertical className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Preview optional: show empty body hint or just the header? Header is usually enough for sorting. */}
|
{/* Preview optional: show empty body hint or just the header? Header is usually enough for sorting. */}
|
||||||
<div className="bg-muted/10 p-2 h-12 flex items-center justify-center border-t border-dashed">
|
<div className="bg-muted/10 flex h-12 items-center justify-center border-t border-dashed p-2">
|
||||||
<span className="text-[10px] text-muted-foreground">
|
<span className="text-muted-foreground text-[10px]">
|
||||||
{step.actions.length} actions hidden while dragging
|
{step.actions.length} actions hidden while dragging
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -423,8 +444,6 @@ function generateStepId(): string {
|
|||||||
return `step-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
return `step-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function sortableStepId(stepId: string) {
|
function sortableStepId(stepId: string) {
|
||||||
return `s-step-${stepId}`;
|
return `s-step-${stepId}`;
|
||||||
}
|
}
|
||||||
@@ -447,7 +466,7 @@ function StepDroppableArea({ stepId }: { stepId: string }) {
|
|||||||
|
|
||||||
const { isOver, setNodeRef } = useDroppable({
|
const { isOver, setNodeRef } = useDroppable({
|
||||||
id: `step-${stepId}`,
|
id: `step-${stepId}`,
|
||||||
disabled: isStepDragging
|
disabled: isStepDragging,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isStepDragging) return null;
|
if (isStepDragging) return null;
|
||||||
@@ -465,8 +484,6 @@ function StepDroppableArea({ stepId }: { stepId: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
/* FlowWorkspace Component */
|
/* FlowWorkspace Component */
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
@@ -520,7 +537,10 @@ export function FlowWorkspace({
|
|||||||
const childStepIds = useMemo(() => {
|
const childStepIds = useMemo(() => {
|
||||||
const children = new Set<string>();
|
const children = new Set<string>();
|
||||||
for (const step of steps) {
|
for (const step of steps) {
|
||||||
if (step.type === 'conditional' && (step.trigger.conditions as any)?.options) {
|
if (
|
||||||
|
step.type === "conditional" &&
|
||||||
|
(step.trigger.conditions as any)?.options
|
||||||
|
) {
|
||||||
for (const opt of (step.trigger.conditions as any).options) {
|
for (const opt of (step.trigger.conditions as any).options) {
|
||||||
if (opt.nextStepId) {
|
if (opt.nextStepId) {
|
||||||
children.add(opt.nextStepId);
|
children.add(opt.nextStepId);
|
||||||
@@ -695,26 +715,33 @@ export function FlowWorkspace({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleReorderStep = useCallback(
|
const handleReorderStep = useCallback(
|
||||||
(stepId: string, direction: 'up' | 'down') => {
|
(stepId: string, direction: "up" | "down") => {
|
||||||
console.log('handleReorderStep', stepId, direction);
|
console.log("handleReorderStep", stepId, direction);
|
||||||
const currentIndex = steps.findIndex((s) => s.id === stepId);
|
const currentIndex = steps.findIndex((s) => s.id === stepId);
|
||||||
console.log('currentIndex', currentIndex, 'total', steps.length);
|
console.log("currentIndex", currentIndex, "total", steps.length);
|
||||||
if (currentIndex === -1) return;
|
if (currentIndex === -1) return;
|
||||||
const newIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
const newIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1;
|
||||||
console.log('newIndex', newIndex);
|
console.log("newIndex", newIndex);
|
||||||
if (newIndex < 0 || newIndex >= steps.length) return;
|
if (newIndex < 0 || newIndex >= steps.length) return;
|
||||||
reorderStep(currentIndex, newIndex);
|
reorderStep(currentIndex, newIndex);
|
||||||
},
|
},
|
||||||
[steps, reorderStep]
|
[steps, reorderStep],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleReorderAction = useCallback(
|
const handleReorderAction = useCallback(
|
||||||
(stepId: string, actionId: string, direction: 'up' | 'down') => {
|
(stepId: string, actionId: string, direction: "up" | "down") => {
|
||||||
const step = steps.find(s => s.id === stepId);
|
const step = steps.find((s) => s.id === stepId);
|
||||||
if (!step) return;
|
if (!step) return;
|
||||||
|
|
||||||
const findInTree = (list: ExperimentAction[], pId: string | null): { list: ExperimentAction[], parentId: string | null, index: number } | null => {
|
const findInTree = (
|
||||||
const idx = list.findIndex(a => a.id === actionId);
|
list: ExperimentAction[],
|
||||||
|
pId: string | null,
|
||||||
|
): {
|
||||||
|
list: ExperimentAction[];
|
||||||
|
parentId: string | null;
|
||||||
|
index: number;
|
||||||
|
} | null => {
|
||||||
|
const idx = list.findIndex((a) => a.id === actionId);
|
||||||
if (idx !== -1) return { list, parentId: pId, index: idx };
|
if (idx !== -1) return { list, parentId: pId, index: idx };
|
||||||
|
|
||||||
for (const a of list) {
|
for (const a of list) {
|
||||||
@@ -730,16 +757,15 @@ export function FlowWorkspace({
|
|||||||
if (!context) return;
|
if (!context) return;
|
||||||
|
|
||||||
const { parentId, index, list } = context;
|
const { parentId, index, list } = context;
|
||||||
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
const newIndex = direction === "up" ? index - 1 : index + 1;
|
||||||
|
|
||||||
if (newIndex < 0 || newIndex >= list.length) return;
|
if (newIndex < 0 || newIndex >= list.length) return;
|
||||||
|
|
||||||
moveAction(stepId, actionId, parentId, newIndex);
|
moveAction(stepId, actionId, parentId, newIndex);
|
||||||
},
|
},
|
||||||
[steps, moveAction]
|
[steps, moveAction],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------------ */
|
||||||
/* Sortable (Local) DnD Monitoring */
|
/* Sortable (Local) DnD Monitoring */
|
||||||
/* ------------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------------ */
|
||||||
@@ -768,9 +794,11 @@ export function FlowWorkspace({
|
|||||||
const overData = over.data.current;
|
const overData = over.data.current;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
activeData && overData &&
|
activeData &&
|
||||||
|
overData &&
|
||||||
activeData.stepId === overData.stepId &&
|
activeData.stepId === overData.stepId &&
|
||||||
activeData.type === 'action' && overData.type === 'action'
|
activeData.type === "action" &&
|
||||||
|
overData.type === "action"
|
||||||
) {
|
) {
|
||||||
const stepId = activeData.stepId as string;
|
const stepId = activeData.stepId as string;
|
||||||
// Fix: SortableActionChip puts 'id' directly on data, not inside 'action' property
|
// Fix: SortableActionChip puts 'id' directly on data, not inside 'action' property
|
||||||
@@ -809,8 +837,8 @@ export function FlowWorkspace({
|
|||||||
if (
|
if (
|
||||||
activeData &&
|
activeData &&
|
||||||
overData &&
|
overData &&
|
||||||
activeData.type === 'action' &&
|
activeData.type === "action" &&
|
||||||
overData.type === 'action'
|
overData.type === "action"
|
||||||
) {
|
) {
|
||||||
// Fix: Access 'id' directly from data payload
|
// Fix: Access 'id' directly from data payload
|
||||||
const activeActionId = activeData.id;
|
const activeActionId = activeData.id;
|
||||||
@@ -825,12 +853,17 @@ export function FlowWorkspace({
|
|||||||
if (activeParentId !== overParentId || activeStepId !== overStepId) {
|
if (activeParentId !== overParentId || activeStepId !== overStepId) {
|
||||||
// Determine new index
|
// Determine new index
|
||||||
// verification of safe move handled by store
|
// verification of safe move handled by store
|
||||||
moveAction(overStepId, activeActionId, overParentId, overData.sortable.index);
|
moveAction(
|
||||||
|
overStepId,
|
||||||
|
activeActionId,
|
||||||
|
overParentId,
|
||||||
|
overData.sortable.index,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[moveAction]
|
[moveAction],
|
||||||
);
|
);
|
||||||
|
|
||||||
useDndMonitor({
|
useDndMonitor({
|
||||||
@@ -960,4 +993,3 @@ export function FlowWorkspace({
|
|||||||
|
|
||||||
// Wrap in React.memo to prevent unnecessary re-renders causing flashing
|
// Wrap in React.memo to prevent unnecessary re-renders causing flashing
|
||||||
export default React.memo(FlowWorkspace);
|
export default React.memo(FlowWorkspace);
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export function BottomStatusBar({
|
|||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
<div className="text-muted-foreground flex items-center gap-1.5">
|
||||||
<Hash className="h-3.5 w-3.5" />
|
<Hash className="h-3.5 w-3.5" />
|
||||||
<span className="hidden sm:inline">Unvalidated</span>
|
<span className="hidden sm:inline">Unvalidated</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,7 +102,7 @@ export function BottomStatusBar({
|
|||||||
|
|
||||||
const savingIndicator =
|
const savingIndicator =
|
||||||
pendingSave || saving ? (
|
pendingSave || saving ? (
|
||||||
<div className="flex items-center gap-1.5 text-muted-foreground animate-pulse">
|
<div className="text-muted-foreground flex animate-pulse items-center gap-1.5">
|
||||||
<RefreshCw className="h-3 w-3 animate-spin" />
|
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||||
<span>Saving...</span>
|
<span>Saving...</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,7 +117,7 @@ export function BottomStatusBar({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Status Indicators */}
|
{/* Status Indicators */}
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
{validationBadge}
|
{validationBadge}
|
||||||
{unsavedBadge}
|
{unsavedBadge}
|
||||||
{savingIndicator}
|
{savingIndicator}
|
||||||
|
|||||||
@@ -64,18 +64,19 @@ export interface PanelsContainerProps {
|
|||||||
* - Resize handles are absolutely positioned over the grid at the left and right boundaries.
|
* - Resize handles are absolutely positioned over the grid at the left and right boundaries.
|
||||||
* - Fractions are clamped with configurable min/max so panels remain usable at all sizes.
|
* - Fractions are clamped with configurable min/max so panels remain usable at all sizes.
|
||||||
*/
|
*/
|
||||||
const Panel: React.FC<React.PropsWithChildren<{
|
const Panel: React.FC<
|
||||||
|
React.PropsWithChildren<{
|
||||||
className?: string;
|
className?: string;
|
||||||
panelClassName?: string;
|
panelClassName?: string;
|
||||||
contentClassName?: string;
|
contentClassName?: string;
|
||||||
}>> = ({
|
}>
|
||||||
className: panelCls,
|
> = ({ className: panelCls, panelClassName, contentClassName, children }) => (
|
||||||
panelClassName,
|
|
||||||
contentClassName,
|
|
||||||
children,
|
|
||||||
}) => (
|
|
||||||
<section
|
<section
|
||||||
className={cn("min-w-0 overflow-hidden transition-[width,opacity] duration-300 ease-in-out", panelCls, panelClassName)}
|
className={cn(
|
||||||
|
"min-w-0 overflow-hidden transition-[width,opacity] duration-300 ease-in-out",
|
||||||
|
panelCls,
|
||||||
|
panelClassName,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -86,7 +87,7 @@ const Panel: React.FC<React.PropsWithChildren<{
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
||||||
export function PanelsContainer({
|
export function PanelsContainer({
|
||||||
left,
|
left,
|
||||||
@@ -178,7 +179,7 @@ export function PanelsContainer({
|
|||||||
minRightPct,
|
minRightPct,
|
||||||
maxRightPct,
|
maxRightPct,
|
||||||
leftCollapsed,
|
leftCollapsed,
|
||||||
rightCollapsed
|
rightCollapsed,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -206,7 +207,16 @@ export function PanelsContainer({
|
|||||||
setRightPct(nextRight);
|
setRightPct(nextRight);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[hasLeft, hasRight, minLeftPct, maxLeftPct, minRightPct, maxRightPct, leftCollapsed, rightCollapsed],
|
[
|
||||||
|
hasLeft,
|
||||||
|
hasRight,
|
||||||
|
minLeftPct,
|
||||||
|
maxLeftPct,
|
||||||
|
minRightPct,
|
||||||
|
maxRightPct,
|
||||||
|
leftCollapsed,
|
||||||
|
rightCollapsed,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const endDrag = React.useCallback(() => {
|
const endDrag = React.useCallback(() => {
|
||||||
@@ -304,14 +314,12 @@ export function PanelsContainer({
|
|||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Mobile Layout (Flex + Sheets) */}
|
{/* Mobile Layout (Flex + Sheets) */}
|
||||||
<div className={cn("flex flex-col h-full w-full md:hidden", className)}>
|
<div className={cn("flex h-full w-full flex-col md:hidden", className)}>
|
||||||
{/* Mobile Header/Toolbar for access to panels */}
|
{/* Mobile Header/Toolbar for access to panels */}
|
||||||
<div className="flex items-center justify-between border-b px-4 py-2 bg-background">
|
<div className="bg-background flex items-center justify-between border-b px-4 py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{hasLeft && (
|
{hasLeft && (
|
||||||
<Sheet>
|
<Sheet>
|
||||||
@@ -321,9 +329,7 @@ export function PanelsContainer({
|
|||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent side="left" className="w-[85vw] p-0 sm:max-w-md">
|
<SheetContent side="left" className="w-[85vw] p-0 sm:max-w-md">
|
||||||
<div className="h-full overflow-hidden">
|
<div className="h-full overflow-hidden">{left}</div>
|
||||||
{left}
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
)}
|
)}
|
||||||
@@ -338,16 +344,14 @@ export function PanelsContainer({
|
|||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent side="right" className="w-[85vw] p-0 sm:max-w-md">
|
<SheetContent side="right" className="w-[85vw] p-0 sm:max-w-md">
|
||||||
<div className="h-full overflow-hidden">
|
<div className="h-full overflow-hidden">{right}</div>
|
||||||
{right}
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content (Center) */}
|
{/* Main Content (Center) */}
|
||||||
<div className="flex-1 min-h-0 min-w-0 overflow-hidden relative">
|
<div className="relative min-h-0 min-w-0 flex-1 overflow-hidden">
|
||||||
{center}
|
{center}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -357,15 +361,31 @@ export function PanelsContainer({
|
|||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative hidden md:grid h-full min-h-0 w-full max-w-full overflow-hidden select-none",
|
"relative hidden h-full min-h-0 w-full max-w-full overflow-hidden select-none md:grid",
|
||||||
// 2-3-2 ratio for left-center-right panels when all visible
|
// 2-3-2 ratio for left-center-right panels when all visible
|
||||||
hasLeft && hasRight && !leftCollapsed && !rightCollapsed && "grid-cols-[2fr_3fr_2fr]",
|
hasLeft &&
|
||||||
|
hasRight &&
|
||||||
|
!leftCollapsed &&
|
||||||
|
!rightCollapsed &&
|
||||||
|
"grid-cols-[2fr_3fr_2fr]",
|
||||||
// Left collapsed: center + right (3:2 ratio)
|
// Left collapsed: center + right (3:2 ratio)
|
||||||
hasLeft && hasRight && leftCollapsed && !rightCollapsed && "grid-cols-[3fr_2fr]",
|
hasLeft &&
|
||||||
|
hasRight &&
|
||||||
|
leftCollapsed &&
|
||||||
|
!rightCollapsed &&
|
||||||
|
"grid-cols-[3fr_2fr]",
|
||||||
// Right collapsed: left + center (2:3 ratio)
|
// Right collapsed: left + center (2:3 ratio)
|
||||||
hasLeft && hasRight && !leftCollapsed && rightCollapsed && "grid-cols-[2fr_3fr]",
|
hasLeft &&
|
||||||
|
hasRight &&
|
||||||
|
!leftCollapsed &&
|
||||||
|
rightCollapsed &&
|
||||||
|
"grid-cols-[2fr_3fr]",
|
||||||
// Both collapsed: center only
|
// Both collapsed: center only
|
||||||
hasLeft && hasRight && leftCollapsed && rightCollapsed && "grid-cols-1",
|
hasLeft &&
|
||||||
|
hasRight &&
|
||||||
|
leftCollapsed &&
|
||||||
|
rightCollapsed &&
|
||||||
|
"grid-cols-1",
|
||||||
// Only left and center
|
// Only left and center
|
||||||
hasLeft && !hasRight && !leftCollapsed && "grid-cols-[2fr_3fr]",
|
hasLeft && !hasRight && !leftCollapsed && "grid-cols-[2fr_3fr]",
|
||||||
hasLeft && !hasRight && leftCollapsed && "grid-cols-1",
|
hasLeft && !hasRight && leftCollapsed && "grid-cols-1",
|
||||||
@@ -409,7 +429,7 @@ export function PanelsContainer({
|
|||||||
{hasLeft && !leftCollapsed && (
|
{hasLeft && !leftCollapsed && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute top-0 bottom-0 w-1.5 -ml-0.75 z-50 cursor-col-resize hover:bg-blue-400/50 transition-colors focus:outline-none"
|
className="absolute top-0 bottom-0 z-50 -ml-0.75 w-1.5 cursor-col-resize transition-colors hover:bg-blue-400/50 focus:outline-none"
|
||||||
style={{ left: "var(--col-left)" }}
|
style={{ left: "var(--col-left)" }}
|
||||||
onPointerDown={startDrag("left")}
|
onPointerDown={startDrag("left")}
|
||||||
onKeyDown={onKeyResize("left")}
|
onKeyDown={onKeyResize("left")}
|
||||||
@@ -419,7 +439,7 @@ export function PanelsContainer({
|
|||||||
{hasRight && !rightCollapsed && (
|
{hasRight && !rightCollapsed && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute top-0 bottom-0 w-1.5 -mr-0.75 z-50 cursor-col-resize hover:bg-blue-400/50 transition-colors focus:outline-none"
|
className="absolute top-0 bottom-0 z-50 -mr-0.75 w-1.5 cursor-col-resize transition-colors hover:bg-blue-400/50 focus:outline-none"
|
||||||
style={{ right: "var(--col-right)" }}
|
style={{ right: "var(--col-right)" }}
|
||||||
onPointerDown={startDrag("right")}
|
onPointerDown={startDrag("right")}
|
||||||
onKeyDown={onKeyResize("right")}
|
onKeyDown={onKeyResize("right")}
|
||||||
|
|||||||
@@ -174,7 +174,10 @@ export interface ActionLibraryPanelProps {
|
|||||||
onCollapse?: (collapsed: boolean) => void;
|
onCollapse?: (collapsed: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActionLibraryPanel({ collapsed, onCollapse }: ActionLibraryPanelProps = {}) {
|
export function ActionLibraryPanel({
|
||||||
|
collapsed,
|
||||||
|
onCollapse,
|
||||||
|
}: ActionLibraryPanelProps = {}) {
|
||||||
const registry = useActionRegistry();
|
const registry = useActionRegistry();
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@@ -299,8 +302,6 @@ export function ActionLibraryPanel({ collapsed, onCollapse }: ActionLibraryPanel
|
|||||||
setShowOnlyFavorites(false);
|
setShowOnlyFavorites(false);
|
||||||
}, [categories]);
|
}, [categories]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const activeCats = selectedCategories;
|
const activeCats = selectedCategories;
|
||||||
const q = search.trim().toLowerCase();
|
const q = search.trim().toLowerCase();
|
||||||
@@ -339,7 +340,10 @@ export function ActionLibraryPanel({ collapsed, onCollapse }: ActionLibraryPanel
|
|||||||
).length;
|
).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden" id="tour-designer-blocks">
|
<div
|
||||||
|
className="flex h-full flex-col overflow-hidden"
|
||||||
|
id="tour-designer-blocks"
|
||||||
|
>
|
||||||
<div className="bg-background/60 flex-shrink-0 border-b p-2">
|
<div className="bg-background/60 flex-shrink-0 border-b p-2">
|
||||||
<div className="relative mb-2">
|
<div className="relative mb-2">
|
||||||
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2" />
|
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2" />
|
||||||
@@ -493,4 +497,3 @@ export function ActionLibraryPanel({ collapsed, onCollapse }: ActionLibraryPanel
|
|||||||
|
|
||||||
// Wrap in React.memo to prevent unnecessary re-renders causing flashing in categories
|
// Wrap in React.memo to prevent unnecessary re-renders causing flashing in categories
|
||||||
export default React.memo(ActionLibraryPanel);
|
export default React.memo(ActionLibraryPanel);
|
||||||
|
|
||||||
|
|||||||
@@ -155,9 +155,12 @@ function projectActionForDesign(
|
|||||||
pluginVersion: action.source.pluginVersion,
|
pluginVersion: action.source.pluginVersion,
|
||||||
baseActionId: action.source.baseActionId,
|
baseActionId: action.source.baseActionId,
|
||||||
},
|
},
|
||||||
execution: action.execution ? projectExecutionDescriptor(action.execution) : null,
|
execution: action.execution
|
||||||
|
? projectExecutionDescriptor(action.execution)
|
||||||
|
: null,
|
||||||
parameterKeysOrValues: parameterProjection,
|
parameterKeysOrValues: parameterProjection,
|
||||||
children: action.children?.map(c => projectActionForDesign(c, options)) ?? [],
|
children:
|
||||||
|
action.children?.map((c) => projectActionForDesign(c, options)) ?? [],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (options.includeActionNames) {
|
if (options.includeActionNames) {
|
||||||
@@ -249,7 +252,9 @@ export async function computeActionSignature(
|
|||||||
timeoutMs: def.execution.timeoutMs ?? null,
|
timeoutMs: def.execution.timeoutMs ?? null,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
schema: def.parameterSchemaRaw ? canonicalize(def.parameterSchemaRaw) : null,
|
schema: def.parameterSchemaRaw
|
||||||
|
? canonicalize(def.parameterSchemaRaw)
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
return hashObject(projection);
|
return hashObject(projection);
|
||||||
}
|
}
|
||||||
@@ -271,9 +276,12 @@ export async function computeDesignHash(
|
|||||||
const sortedSteps = steps.slice().sort((a, b) => a.order - b.order);
|
const sortedSteps = steps.slice().sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
// 2. Map hierarchically (Merkle style)
|
// 2. Map hierarchically (Merkle style)
|
||||||
const stepHashes = await Promise.all(sortedSteps.map(async (s) => {
|
const stepHashes = await Promise.all(
|
||||||
|
sortedSteps.map(async (s) => {
|
||||||
// Action hashes
|
// Action hashes
|
||||||
const actionHashes = await Promise.all(s.actions.map(a => hashObject(projectActionForDesign(a, options))));
|
const actionHashes = await Promise.all(
|
||||||
|
s.actions.map((a) => hashObject(projectActionForDesign(a, options))),
|
||||||
|
);
|
||||||
|
|
||||||
// Step hash
|
// Step hash
|
||||||
const pStep = {
|
const pStep = {
|
||||||
@@ -288,12 +296,13 @@ export async function computeDesignHash(
|
|||||||
...(options.includeStepNames ? { name: s.name } : {}),
|
...(options.includeStepNames ? { name: s.name } : {}),
|
||||||
};
|
};
|
||||||
return hashObject(pStep);
|
return hashObject(pStep);
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// 3. Aggregate design hash
|
// 3. Aggregate design hash
|
||||||
return hashObject({
|
return hashObject({
|
||||||
steps: stepHashes,
|
steps: stepHashes,
|
||||||
count: steps.length
|
count: steps.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export interface DesignerState {
|
|||||||
parentId: string | null;
|
parentId: string | null;
|
||||||
index: number;
|
index: number;
|
||||||
action: ExperimentAction;
|
action: ExperimentAction;
|
||||||
} | null
|
} | null,
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
/* ------------------------------ Mutators --------------------------------- */
|
/* ------------------------------ Mutators --------------------------------- */
|
||||||
@@ -109,10 +109,20 @@ export interface DesignerState {
|
|||||||
reorderStep: (from: number, to: number) => void;
|
reorderStep: (from: number, to: number) => void;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
upsertAction: (stepId: string, action: ExperimentAction, parentId?: string | null, index?: number) => void;
|
upsertAction: (
|
||||||
|
stepId: string,
|
||||||
|
action: ExperimentAction,
|
||||||
|
parentId?: string | null,
|
||||||
|
index?: number,
|
||||||
|
) => void;
|
||||||
removeAction: (stepId: string, actionId: string) => void;
|
removeAction: (stepId: string, actionId: string) => void;
|
||||||
reorderAction: (stepId: string, from: number, to: number) => void;
|
reorderAction: (stepId: string, from: number, to: number) => void;
|
||||||
moveAction: (stepId: string, actionId: string, newParentId: string | null, newIndex: number) => void;
|
moveAction: (
|
||||||
|
stepId: string,
|
||||||
|
actionId: string,
|
||||||
|
newParentId: string | null,
|
||||||
|
newIndex: number,
|
||||||
|
) => void;
|
||||||
|
|
||||||
// Dirty
|
// Dirty
|
||||||
markDirty: (id: string) => void;
|
markDirty: (id: string) => void;
|
||||||
@@ -173,8 +183,7 @@ function cloneSteps(steps: ExperimentStep[]): ExperimentStep[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function reindexSteps(steps: ExperimentStep[]): ExperimentStep[] {
|
function reindexSteps(steps: ExperimentStep[]): ExperimentStep[] {
|
||||||
return steps
|
return steps.map((s, idx) => ({ ...s, order: idx }));
|
||||||
.map((s, idx) => ({ ...s, order: idx }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function reindexActions(actions: ExperimentAction[]): ExperimentAction[] {
|
function reindexActions(actions: ExperimentAction[]): ExperimentAction[] {
|
||||||
@@ -257,8 +266,11 @@ function insertActionIntoTree(
|
|||||||
|
|
||||||
export const createDesignerStore = (props: {
|
export const createDesignerStore = (props: {
|
||||||
initialSteps?: ExperimentStep[];
|
initialSteps?: ExperimentStep[];
|
||||||
}) => create<DesignerState>((set, get) => ({
|
}) =>
|
||||||
steps: props.initialSteps ? reindexSteps(cloneSteps(props.initialSteps)) : [],
|
create<DesignerState>((set, get) => ({
|
||||||
|
steps: props.initialSteps
|
||||||
|
? reindexSteps(cloneSteps(props.initialSteps))
|
||||||
|
: [],
|
||||||
dirtyEntities: new Set<string>(),
|
dirtyEntities: new Set<string>(),
|
||||||
validationIssues: {},
|
validationIssues: {},
|
||||||
actionSignatureIndex: new Map(),
|
actionSignatureIndex: new Map(),
|
||||||
@@ -345,7 +357,12 @@ export const createDesignerStore = (props: {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
/* ------------------------------- Actions --------------------------------- */
|
/* ------------------------------- Actions --------------------------------- */
|
||||||
upsertAction: (stepId: string, action: ExperimentAction, parentId: string | null = null, index?: number) =>
|
upsertAction: (
|
||||||
|
stepId: string,
|
||||||
|
action: ExperimentAction,
|
||||||
|
parentId: string | null = null,
|
||||||
|
index?: number,
|
||||||
|
) =>
|
||||||
set((state: DesignerState) => {
|
set((state: DesignerState) => {
|
||||||
const stepsDraft: ExperimentStep[] = state.steps.map((s) => {
|
const stepsDraft: ExperimentStep[] = state.steps.map((s) => {
|
||||||
if (s.id !== stepId) return s;
|
if (s.id !== stepId) return s;
|
||||||
@@ -357,7 +374,7 @@ export const createDesignerStore = (props: {
|
|||||||
// Use moveAction for moving.
|
// Use moveAction for moving.
|
||||||
return {
|
return {
|
||||||
...s,
|
...s,
|
||||||
actions: updateActionInTree(s.actions, action)
|
actions: updateActionInTree(s.actions, action),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,7 +384,12 @@ export const createDesignerStore = (props: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...s,
|
...s,
|
||||||
actions: insertActionIntoTree(s.actions, action, parentId, insertIndex)
|
actions: insertActionIntoTree(
|
||||||
|
s.actions,
|
||||||
|
action,
|
||||||
|
parentId,
|
||||||
|
insertIndex,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
@@ -403,7 +425,12 @@ export const createDesignerStore = (props: {
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
moveAction: (stepId: string, actionId: string, newParentId: string | null, newIndex: number) =>
|
moveAction: (
|
||||||
|
stepId: string,
|
||||||
|
actionId: string,
|
||||||
|
newParentId: string | null,
|
||||||
|
newIndex: number,
|
||||||
|
) =>
|
||||||
set((state: DesignerState) => {
|
set((state: DesignerState) => {
|
||||||
const stepsDraft = state.steps.map((s) => {
|
const stepsDraft = state.steps.map((s) => {
|
||||||
if (s.id !== stepId) return s;
|
if (s.id !== stepId) return s;
|
||||||
@@ -412,19 +439,34 @@ export const createDesignerStore = (props: {
|
|||||||
if (!actionToMove) return s;
|
if (!actionToMove) return s;
|
||||||
|
|
||||||
const pruned = removeActionFromTree(s.actions, actionId);
|
const pruned = removeActionFromTree(s.actions, actionId);
|
||||||
const inserted = insertActionIntoTree(pruned, actionToMove, newParentId, newIndex);
|
const inserted = insertActionIntoTree(
|
||||||
|
pruned,
|
||||||
|
actionToMove,
|
||||||
|
newParentId,
|
||||||
|
newIndex,
|
||||||
|
);
|
||||||
return { ...s, actions: inserted };
|
return { ...s, actions: inserted };
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
steps: stepsDraft,
|
steps: stepsDraft,
|
||||||
dirtyEntities: new Set<string>([...state.dirtyEntities, stepId, actionId]),
|
dirtyEntities: new Set<string>([
|
||||||
|
...state.dirtyEntities,
|
||||||
|
stepId,
|
||||||
|
actionId,
|
||||||
|
]),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
reorderAction: (stepId: string, from: number, to: number) =>
|
reorderAction: (stepId: string, from: number, to: number) =>
|
||||||
get().moveAction(stepId, get().steps.find(s => s.id === stepId)?.actions[from]?.id!, null, to), // Legacy compat support (only works for root level reorder)
|
get().moveAction(
|
||||||
|
stepId,
|
||||||
|
get().steps.find((s) => s.id === stepId)?.actions[from]?.id!,
|
||||||
|
null,
|
||||||
|
to,
|
||||||
|
), // Legacy compat support (only works for root level reorder)
|
||||||
|
|
||||||
setInsertionProjection: (projection) => set({ insertionProjection: projection }),
|
setInsertionProjection: (projection) =>
|
||||||
|
set({ insertionProjection: projection }),
|
||||||
|
|
||||||
/* -------------------------------- Dirty ---------------------------------- */
|
/* -------------------------------- Dirty ---------------------------------- */
|
||||||
markDirty: (id: string) =>
|
markDirty: (id: string) =>
|
||||||
@@ -548,7 +590,7 @@ export const createDesignerStore = (props: {
|
|||||||
conflict: undefined,
|
conflict: undefined,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const useDesignerStore = createDesignerStore({});
|
export const useDesignerStore = createDesignerStore({});
|
||||||
|
|
||||||
|
|||||||
@@ -51,10 +51,7 @@ export interface ValidationResult {
|
|||||||
|
|
||||||
// Steps should ALWAYS execute sequentially
|
// Steps should ALWAYS execute sequentially
|
||||||
// Parallel/conditional/loop execution happens at the ACTION level, not step level
|
// Parallel/conditional/loop execution happens at the ACTION level, not step level
|
||||||
const VALID_STEP_TYPES: StepType[] = [
|
const VALID_STEP_TYPES: StepType[] = ["sequential", "conditional"];
|
||||||
"sequential",
|
|
||||||
"conditional",
|
|
||||||
];
|
|
||||||
const VALID_TRIGGER_TYPES: TriggerType[] = [
|
const VALID_TRIGGER_TYPES: TriggerType[] = [
|
||||||
"trial_start",
|
"trial_start",
|
||||||
"participant_action",
|
"participant_action",
|
||||||
|
|||||||
@@ -22,7 +22,13 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "~/components/ui/select";
|
} from "~/components/ui/select";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
@@ -96,7 +102,9 @@ export function SettingsTab({ experiment, designStats }: SettingsTabProps) {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Experiment Settings</h2>
|
<h2 className="text-2xl font-bold tracking-tight">
|
||||||
|
Experiment Settings
|
||||||
|
</h2>
|
||||||
<p className="text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1">
|
||||||
Configure experiment metadata and status
|
Configure experiment metadata and status
|
||||||
</p>
|
</p>
|
||||||
@@ -104,9 +112,9 @@ export function SettingsTab({ experiment, designStats }: SettingsTabProps) {
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||||
{/* Left Column: Basic Information (Spans 2) */}
|
{/* Left Column: Basic Information (Spans 2) */}
|
||||||
<div className="md:col-span-2 space-y-6">
|
<div className="space-y-6 md:col-span-2">
|
||||||
<Card className="h-full">
|
<Card className="h-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Basic Information</CardTitle>
|
<CardTitle>Basic Information</CardTitle>
|
||||||
@@ -141,12 +149,13 @@ export function SettingsTab({ experiment, designStats }: SettingsTabProps) {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Describe your experiment goals, methodology, and expected outcomes..."
|
placeholder="Describe your experiment goals, methodology, and expected outcomes..."
|
||||||
className="resize-none min-h-[300px]"
|
className="min-h-[300px] resize-none"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Detailed description of the experiment purpose and design
|
Detailed description of the experiment purpose and
|
||||||
|
design
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -162,9 +171,7 @@ export function SettingsTab({ experiment, designStats }: SettingsTabProps) {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Status</CardTitle>
|
<CardTitle>Status</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>Track lifecycle stage</CardDescription>
|
||||||
Track lifecycle stage
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<FormField
|
<FormField
|
||||||
@@ -173,7 +180,10 @@ export function SettingsTab({ experiment, designStats }: SettingsTabProps) {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Current Status</FormLabel>
|
<FormLabel>Current Status</FormLabel>
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a status" />
|
<SelectValue placeholder="Select a status" />
|
||||||
@@ -183,25 +193,40 @@ export function SettingsTab({ experiment, designStats }: SettingsTabProps) {
|
|||||||
<SelectItem value="draft">
|
<SelectItem value="draft">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="secondary">Draft</Badge>
|
<Badge variant="secondary">Draft</Badge>
|
||||||
<span className="text-xs text-muted-foreground">WIP</span>
|
<span className="text-muted-foreground text-xs">
|
||||||
|
WIP
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="testing">
|
<SelectItem value="testing">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline">Testing</Badge>
|
<Badge variant="outline">Testing</Badge>
|
||||||
<span className="text-xs text-muted-foreground">Validation</span>
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Validation
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="ready">
|
<SelectItem value="ready">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="default" className="bg-green-500">Ready</Badge>
|
<Badge
|
||||||
<span className="text-xs text-muted-foreground">Live</span>
|
variant="default"
|
||||||
|
className="bg-green-500"
|
||||||
|
>
|
||||||
|
Ready
|
||||||
|
</Badge>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Live
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="deprecated">
|
<SelectItem value="deprecated">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="destructive">Deprecated</Badge>
|
<Badge variant="destructive">
|
||||||
<span className="text-xs text-muted-foreground">Retired</span>
|
Deprecated
|
||||||
|
</Badge>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Retired
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -217,49 +242,73 @@ export function SettingsTab({ experiment, designStats }: SettingsTabProps) {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Metadata</CardTitle>
|
<CardTitle>Metadata</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>Read-only information</CardDescription>
|
||||||
Read-only information
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-1">Study</p>
|
<p className="text-muted-foreground mb-1 text-xs font-medium">
|
||||||
|
Study
|
||||||
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href={`/studies/${experiment.study.id}`}
|
href={`/studies/${experiment.study.id}`}
|
||||||
className="text-sm hover:underline flex items-center gap-1 text-primary truncate"
|
className="text-primary flex items-center gap-1 truncate text-sm hover:underline"
|
||||||
>
|
>
|
||||||
{experiment.study.name}
|
{experiment.study.name}
|
||||||
<ExternalLink className="h-3 w-3 flex-shrink-0" />
|
<ExternalLink className="h-3 w-3 flex-shrink-0" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-1">Experiment ID</p>
|
<p className="text-muted-foreground mb-1 text-xs font-medium">
|
||||||
<p className="text-xs font-mono bg-muted p-1 rounded select-all">{experiment.id.split('-')[0]}...</p>
|
Experiment ID
|
||||||
|
</p>
|
||||||
|
<p className="bg-muted rounded p-1 font-mono text-xs select-all">
|
||||||
|
{experiment.id.split("-")[0]}...
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-1">Created</p>
|
<p className="text-muted-foreground mb-1 text-xs font-medium">
|
||||||
<p className="text-xs">{new Date(experiment.createdAt).toLocaleDateString()}</p>
|
Created
|
||||||
|
</p>
|
||||||
|
<p className="text-xs">
|
||||||
|
{new Date(
|
||||||
|
experiment.createdAt,
|
||||||
|
).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-1">Updated</p>
|
<p className="text-muted-foreground mb-1 text-xs font-medium">
|
||||||
<p className="text-xs">{new Date(experiment.updatedAt).toLocaleDateString()}</p>
|
Updated
|
||||||
|
</p>
|
||||||
|
<p className="text-xs">
|
||||||
|
{new Date(
|
||||||
|
experiment.updatedAt,
|
||||||
|
).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{designStats && (
|
{designStats && (
|
||||||
<div className="pt-4 border-t">
|
<div className="border-t pt-4">
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-2">Statistics</p>
|
<p className="text-muted-foreground mb-2 text-xs font-medium">
|
||||||
|
Statistics
|
||||||
|
</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<div className="flex items-center gap-1.5 bg-muted/50 px-2 py-1 rounded text-xs">
|
<div className="bg-muted/50 flex items-center gap-1.5 rounded px-2 py-1 text-xs">
|
||||||
<span className="font-semibold">{designStats.stepCount}</span>
|
<span className="font-semibold">
|
||||||
|
{designStats.stepCount}
|
||||||
|
</span>
|
||||||
<span className="text-muted-foreground">Steps</span>
|
<span className="text-muted-foreground">Steps</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 bg-muted/50 px-2 py-1 rounded text-xs">
|
<div className="bg-muted/50 flex items-center gap-1.5 rounded px-2 py-1 text-xs">
|
||||||
<span className="font-semibold">{designStats.actionCount}</span>
|
<span className="font-semibold">
|
||||||
<span className="text-muted-foreground">Actions</span>
|
{designStats.actionCount}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Actions
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -270,7 +319,7 @@ export function SettingsTab({ experiment, designStats }: SettingsTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Save Button */}
|
{/* Save Button */}
|
||||||
<div className="flex justify-end pt-4 border-t">
|
<div className="flex justify-end border-t pt-4">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={updateExperiment.isPending || !isDirty}
|
disabled={updateExperiment.isPending || !isDirty}
|
||||||
@@ -280,7 +329,7 @@ export function SettingsTab({ experiment, designStats }: SettingsTabProps) {
|
|||||||
"Saving..."
|
"Saving..."
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Save className="h-4 w-4 mr-2" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
Save Changes
|
Save Changes
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -105,10 +105,12 @@ function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
asChild
|
asChild
|
||||||
className="h-8 w-8 text-muted-foreground hover:text-primary"
|
className="text-muted-foreground hover:text-primary h-8 w-8"
|
||||||
title="Open Designer"
|
title="Open Designer"
|
||||||
>
|
>
|
||||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
|
<Link
|
||||||
|
href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}
|
||||||
|
>
|
||||||
<LayoutTemplate className="h-4 w-4" />
|
<LayoutTemplate className="h-4 w-4" />
|
||||||
<span className="sr-only">Design</span>
|
<span className="sr-only">Design</span>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -119,7 +121,7 @@ function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
className="text-muted-foreground hover:text-destructive h-8 w-8"
|
||||||
title="Delete Experiment"
|
title="Delete Experiment"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
|
|||||||
@@ -1,16 +1,30 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { createContext, useContext, useEffect, useRef } from "react";
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { driver, type Driver } from "driver.js";
|
import { driver, type Driver } from "driver.js";
|
||||||
import "driver.js/dist/driver.css";
|
import "driver.js/dist/driver.css";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
|
|
||||||
type TourType = "dashboard" | "study_creation" | "participant_creation" | "designer" | "wizard" | "analytics" | "full_platform";
|
type TourType =
|
||||||
|
| "dashboard"
|
||||||
|
| "study_creation"
|
||||||
|
| "participant_creation"
|
||||||
|
| "designer"
|
||||||
|
| "wizard"
|
||||||
|
| "analytics"
|
||||||
|
| "full_platform";
|
||||||
|
|
||||||
interface TourContextType {
|
interface TourContextType {
|
||||||
startTour: (tour: TourType) => void;
|
startTour: (tour: TourType) => void;
|
||||||
|
isTourActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TourContext = createContext<TourContextType | undefined>(undefined);
|
const TourContext = createContext<TourContextType | undefined>(undefined);
|
||||||
@@ -25,8 +39,10 @@ export function useTour() {
|
|||||||
|
|
||||||
export function TourProvider({ children }: { children: React.ReactNode }) {
|
export function TourProvider({ children }: { children: React.ReactNode }) {
|
||||||
const driverObj = useRef<Driver | null>(null);
|
const driverObj = useRef<Driver | null>(null);
|
||||||
|
const [isTourActive, setIsTourActive] = useState(false);
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
// --- Multi-page Tour Logic ---
|
// --- Multi-page Tour Logic ---
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -34,11 +50,13 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const localMode = localStorage.getItem("hristudio_tour_mode");
|
const localMode = localStorage.getItem("hristudio_tour_mode");
|
||||||
const cookieMode = Cookies.get("hristudio_tour_mode");
|
const cookieMode = Cookies.get("hristudio_tour_mode");
|
||||||
|
|
||||||
const tourMode = localMode === "full" || cookieMode === "full" ? "full" : null;
|
const tourMode =
|
||||||
|
localMode === "full" || cookieMode === "full" ? "full" : null;
|
||||||
|
|
||||||
if (tourMode === "full") {
|
if (tourMode === "full") {
|
||||||
// Re-sync local storage if missing but cookie present
|
// Re-sync local storage if missing but cookie present
|
||||||
if (localMode !== "full") localStorage.setItem("hristudio_tour_mode", "full");
|
if (localMode !== "full")
|
||||||
|
localStorage.setItem("hristudio_tour_mode", "full");
|
||||||
|
|
||||||
// Small delay to ensure DOM is ready
|
// Small delay to ensure DOM is ready
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -67,11 +85,20 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('hristudio-start-tour', handleTourTrigger);
|
document.addEventListener("hristudio-start-tour", handleTourTrigger);
|
||||||
return () => document.removeEventListener('hristudio-start-tour', handleTourTrigger);
|
return () =>
|
||||||
|
document.removeEventListener("hristudio-start-tour", handleTourTrigger);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const runTourSegment = (segment: "dashboard" | "study_creation" | "participant_creation" | "designer" | "wizard" | "analytics") => {
|
const runTourSegment = (
|
||||||
|
segment:
|
||||||
|
| "dashboard"
|
||||||
|
| "study_creation"
|
||||||
|
| "participant_creation"
|
||||||
|
| "designer"
|
||||||
|
| "wizard"
|
||||||
|
| "analytics",
|
||||||
|
) => {
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
// We add a specific class to handle dark/light overrides reliably
|
// We add a specific class to handle dark/light overrides reliably
|
||||||
const themeClass = isDark ? "driverjs-theme-dark" : "driverjs-theme-light";
|
const themeClass = isDark ? "driverjs-theme-dark" : "driverjs-theme-light";
|
||||||
@@ -84,7 +111,8 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
|||||||
element: "#dashboard-header",
|
element: "#dashboard-header",
|
||||||
popover: {
|
popover: {
|
||||||
title: "Overview",
|
title: "Overview",
|
||||||
description: "Welcome to HRIStudio. This dashboard gives you a high-level view of your research activities, active studies, and data collection progress.",
|
description:
|
||||||
|
"Welcome to HRIStudio. This dashboard gives you a high-level view of your research activities, active studies, and data collection progress.",
|
||||||
side: "bottom",
|
side: "bottom",
|
||||||
align: "start",
|
align: "start",
|
||||||
},
|
},
|
||||||
@@ -93,7 +121,8 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
|||||||
element: "#tour-sidebar-overview",
|
element: "#tour-sidebar-overview",
|
||||||
popover: {
|
popover: {
|
||||||
title: "Navigation: Overview",
|
title: "Navigation: Overview",
|
||||||
description: "Quickly return to this main dashboard from anywhere in the application.",
|
description:
|
||||||
|
"Quickly return to this main dashboard from anywhere in the application.",
|
||||||
side: "right",
|
side: "right",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -101,7 +130,8 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
|||||||
element: "#tour-sidebar-studies",
|
element: "#tour-sidebar-studies",
|
||||||
popover: {
|
popover: {
|
||||||
title: "Navigation: Studies",
|
title: "Navigation: Studies",
|
||||||
description: "Manage all your research studies, IRBs, and team permissions in one place.",
|
description:
|
||||||
|
"Manage all your research studies, IRBs, and team permissions in one place.",
|
||||||
side: "right",
|
side: "right",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -109,7 +139,8 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
|||||||
element: "#tour-sidebar-study-selector",
|
element: "#tour-sidebar-study-selector",
|
||||||
popover: {
|
popover: {
|
||||||
title: "Active Study Selector",
|
title: "Active Study Selector",
|
||||||
description: "Switch between different studies here. Selecting a study unlocks study-specific tools like the Experiment Designer and Data Analytics.",
|
description:
|
||||||
|
"Switch between different studies here. Selecting a study unlocks study-specific tools like the Experiment Designer and Data Analytics.",
|
||||||
side: "right",
|
side: "right",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -117,7 +148,8 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
|||||||
element: "#tour-new-study",
|
element: "#tour-new-study",
|
||||||
popover: {
|
popover: {
|
||||||
title: "Create a New Study",
|
title: "Create a New Study",
|
||||||
description: "Ready to start? Click here to initialize a new research project and define your protocol.",
|
description:
|
||||||
|
"Ready to start? Click here to initialize a new research project and define your protocol.",
|
||||||
side: "right",
|
side: "right",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -128,26 +160,29 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
|||||||
element: "#tour-study-name",
|
element: "#tour-study-name",
|
||||||
popover: {
|
popover: {
|
||||||
title: "Naming Your Study",
|
title: "Naming Your Study",
|
||||||
description: "Choose a concise, descriptive name. This will properly namespace your data, logs, and robot configurations.",
|
description:
|
||||||
|
"Choose a concise, descriptive name. This will properly namespace your data, logs, and robot configurations.",
|
||||||
side: "right",
|
side: "right",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
element: "#tour-study-description",
|
element: "#tour-study-description",
|
||||||
popover: {
|
popover: {
|
||||||
title: "Research Protocol",
|
title: "Research Protocol",
|
||||||
description: "Add a short description of your methodology or research questions. This helps team members understand the context.",
|
description:
|
||||||
|
"Add a short description of your methodology or research questions. This helps team members understand the context.",
|
||||||
side: "right",
|
side: "right",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
element: "#tour-study-submit",
|
element: "#tour-study-submit",
|
||||||
popover: {
|
popover: {
|
||||||
title: "Initialize Project",
|
title: "Initialize Project",
|
||||||
description: "Create the study to access the full suite of tools: Experiment Designer, Wizard Interface, and Analytics.",
|
description:
|
||||||
|
"Create the study to access the full suite of tools: Experiment Designer, Wizard Interface, and Analytics.",
|
||||||
side: "top",
|
side: "top",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
} else if (segment === "participant_creation") {
|
} else if (segment === "participant_creation") {
|
||||||
steps = [
|
steps = [
|
||||||
@@ -155,42 +190,47 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
|||||||
element: "#tour-participant-code",
|
element: "#tour-participant-code",
|
||||||
popover: {
|
popover: {
|
||||||
title: "Participant ID",
|
title: "Participant ID",
|
||||||
description: "Assign a unique code (e.g., P001) to identify this participant while maintaining anonymity.",
|
description:
|
||||||
|
"Assign a unique code (e.g., P001) to identify this participant while maintaining anonymity.",
|
||||||
side: "right",
|
side: "right",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
element: "#tour-participant-name",
|
element: "#tour-participant-name",
|
||||||
popover: {
|
popover: {
|
||||||
title: "Name (Optional)",
|
title: "Name (Optional)",
|
||||||
description: "You store their name for internal reference; analytics will use the ID.",
|
description:
|
||||||
|
"You store their name for internal reference; analytics will use the ID.",
|
||||||
side: "right",
|
side: "right",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
element: "#tour-participant-study-container",
|
element: "#tour-participant-study-container",
|
||||||
popover: {
|
popover: {
|
||||||
title: "Study Association",
|
title: "Study Association",
|
||||||
description: "Link this participant to a specific research study to enable data collection.",
|
description:
|
||||||
|
"Link this participant to a specific research study to enable data collection.",
|
||||||
side: "right",
|
side: "right",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
element: "#tour-participant-consent",
|
element: "#tour-participant-consent",
|
||||||
popover: {
|
popover: {
|
||||||
title: "Informed Consent",
|
title: "Informed Consent",
|
||||||
description: "Mandatory check to confirm you have obtained necessary ethical approvals and consent.",
|
description:
|
||||||
|
"Mandatory check to confirm you have obtained necessary ethical approvals and consent.",
|
||||||
side: "top",
|
side: "top",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
element: "#tour-participant-submit",
|
element: "#tour-participant-submit",
|
||||||
popover: {
|
popover: {
|
||||||
title: "Register",
|
title: "Register",
|
||||||
description: "Create the participant record to begin scheduling trials.",
|
description:
|
||||||
|
"Create the participant record to begin scheduling trials.",
|
||||||
side: "top",
|
side: "top",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
} else if (segment === "designer") {
|
} else if (segment === "designer") {
|
||||||
steps = [
|
steps = [
|
||||||
@@ -198,7 +238,8 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
|||||||
element: "#tour-designer-blocks",
|
element: "#tour-designer-blocks",
|
||||||
popover: {
|
popover: {
|
||||||
title: "Action Library",
|
title: "Action Library",
|
||||||
description: "Drag and drop robot behaviors (Speech, Gestures, Movement) onto the canvas. Includes both core actions and those from installed plugins.",
|
description:
|
||||||
|
"Drag and drop robot behaviors (Speech, Gestures, Movement) onto the canvas. Includes both core actions and those from installed plugins.",
|
||||||
side: "right",
|
side: "right",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -206,7 +247,8 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
|||||||
element: "#tour-designer-canvas",
|
element: "#tour-designer-canvas",
|
||||||
popover: {
|
popover: {
|
||||||
title: "Visual Flow Canvas",
|
title: "Visual Flow Canvas",
|
||||||
description: "Design your experiment logic here. Connect blocks to create sequences, branches, and loops for the robot to execute.",
|
description:
|
||||||
|
"Design your experiment logic here. Connect blocks to create sequences, branches, and loops for the robot to execute.",
|
||||||
side: "top",
|
side: "top",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -214,7 +256,8 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
|||||||
element: "#tour-designer-properties",
|
element: "#tour-designer-properties",
|
||||||
popover: {
|
popover: {
|
||||||
title: "Properties Panel",
|
title: "Properties Panel",
|
||||||
description: "Select any block to configure its parameters—like speech text, speed, volume, or timeout durations.",
|
description:
|
||||||
|
"Select any block to configure its parameters—like speech text, speed, volume, or timeout durations.",
|
||||||
side: "left",
|
side: "left",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -225,7 +268,8 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
|||||||
element: "#tour-wizard-controls",
|
element: "#tour-wizard-controls",
|
||||||
popover: {
|
popover: {
|
||||||
title: "Wizard Dashboard",
|
title: "Wizard Dashboard",
|
||||||
description: "The command center for running trials. Manually trigger robot actions or override autonomous behaviors in real-time.",
|
description:
|
||||||
|
"The command center for running trials. Manually trigger robot actions or override autonomous behaviors in real-time.",
|
||||||
side: "right",
|
side: "right",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -233,7 +277,8 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
|||||||
element: "#tour-wizard-timeline",
|
element: "#tour-wizard-timeline",
|
||||||
popover: {
|
popover: {
|
||||||
title: "Live Timeline",
|
title: "Live Timeline",
|
||||||
description: "See exactly what the robot is doing, what's coming next, and a history of all events in the current session.",
|
description:
|
||||||
|
"See exactly what the robot is doing, what's coming next, and a history of all events in the current session.",
|
||||||
side: "top",
|
side: "top",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -241,19 +286,20 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
|||||||
element: "#tour-wizard-robot-status",
|
element: "#tour-wizard-robot-status",
|
||||||
popover: {
|
popover: {
|
||||||
title: "System Health",
|
title: "System Health",
|
||||||
description: "Monitor critical telemetry: battery levels, joint temperatures, and network latency to ensure safety.",
|
description:
|
||||||
|
"Monitor critical telemetry: battery levels, joint temperatures, and network latency to ensure safety.",
|
||||||
side: "left",
|
side: "left",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
} else if (segment === "analytics") {
|
||||||
else if (segment === "analytics") {
|
|
||||||
steps = [
|
steps = [
|
||||||
{
|
{
|
||||||
element: "#tour-analytics-table",
|
element: "#tour-analytics-table",
|
||||||
popover: {
|
popover: {
|
||||||
title: "Study Analytics",
|
title: "Study Analytics",
|
||||||
description: "View aggregate data across all participant sessions. Sort and filter to identify trends or specific trials.",
|
description:
|
||||||
|
"View aggregate data across all participant sessions. Sort and filter to identify trends or specific trials.",
|
||||||
side: "bottom",
|
side: "bottom",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -261,7 +307,8 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
|||||||
element: "#tour-analytics-filter",
|
element: "#tour-analytics-filter",
|
||||||
popover: {
|
popover: {
|
||||||
title: "Filter Data",
|
title: "Filter Data",
|
||||||
description: "Quickly find participants by ID or name using this search bar.",
|
description:
|
||||||
|
"Quickly find participants by ID or name using this search bar.",
|
||||||
side: "bottom",
|
side: "bottom",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -269,7 +316,8 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
|||||||
element: "#tour-trial-metrics",
|
element: "#tour-trial-metrics",
|
||||||
popover: {
|
popover: {
|
||||||
title: "Trial Metrics",
|
title: "Trial Metrics",
|
||||||
description: "High-level KPIs for the selected trial: Duration, Robot Actions, and Intervention counts.",
|
description:
|
||||||
|
"High-level KPIs for the selected trial: Duration, Robot Actions, and Intervention counts.",
|
||||||
side: "bottom",
|
side: "bottom",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -277,7 +325,8 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
|||||||
element: "#tour-trial-timeline",
|
element: "#tour-trial-timeline",
|
||||||
popover: {
|
popover: {
|
||||||
title: "Video & Timeline",
|
title: "Video & Timeline",
|
||||||
description: "Watch the trial recording synced with the event timeline. Click any event to jump to that moment in the video.",
|
description:
|
||||||
|
"Watch the trial recording synced with the event timeline. Click any event to jump to that moment in the video.",
|
||||||
side: "right",
|
side: "right",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -285,7 +334,8 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
|||||||
element: "#tour-trial-events",
|
element: "#tour-trial-events",
|
||||||
popover: {
|
popover: {
|
||||||
title: "Event Log",
|
title: "Event Log",
|
||||||
description: "A detailed, searchable log of every system event, robot action, and wizard interaction.",
|
description:
|
||||||
|
"A detailed, searchable log of every system event, robot action, and wizard interaction.",
|
||||||
side: "left",
|
side: "left",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -305,10 +355,12 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
|||||||
})),
|
})),
|
||||||
onDestroyed: () => {
|
onDestroyed: () => {
|
||||||
// Persistence handled by localStorage state
|
// Persistence handled by localStorage state
|
||||||
}
|
setIsTourActive(false);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
driverObj.current.drive();
|
driverObj.current.drive();
|
||||||
|
setIsTourActive(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const startTour = (tour: TourType) => {
|
const startTour = (tour: TourType) => {
|
||||||
@@ -316,21 +368,21 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
|||||||
localStorage.setItem("hristudio_tour_mode", "full");
|
localStorage.setItem("hristudio_tour_mode", "full");
|
||||||
Cookies.set("hristudio_tour_mode", "full", { expires: 7 }); // 7 days persistence
|
Cookies.set("hristudio_tour_mode", "full", { expires: 7 }); // 7 days persistence
|
||||||
|
|
||||||
// Trigger current page immediately
|
if (pathname !== "/dashboard") {
|
||||||
if (pathname === "/dashboard") runTourSegment("dashboard");
|
router.push("/dashboard");
|
||||||
else if (pathname.includes("/studies/new")) runTourSegment("study_creation");
|
return;
|
||||||
else if (pathname.includes("/participants/new")) runTourSegment("participant_creation");
|
}
|
||||||
else if (pathname.includes("/designer")) runTourSegment("designer");
|
|
||||||
else if (pathname.includes("/wizard")) runTourSegment("wizard");
|
// We are already on dashboard, trigger it immediately
|
||||||
else if (pathname.includes("/analysis")) runTourSegment("analytics");
|
runTourSegment("dashboard");
|
||||||
else runTourSegment("dashboard"); // Fallback
|
|
||||||
} else {
|
} else {
|
||||||
localStorage.setItem("hristudio_tour_mode", "manual");
|
localStorage.setItem("hristudio_tour_mode", "manual");
|
||||||
Cookies.remove("hristudio_tour_mode");
|
Cookies.remove("hristudio_tour_mode");
|
||||||
|
|
||||||
if (tour === "dashboard") runTourSegment("dashboard");
|
if (tour === "dashboard") runTourSegment("dashboard");
|
||||||
if (tour === "study_creation") runTourSegment("study_creation");
|
if (tour === "study_creation") runTourSegment("study_creation");
|
||||||
if (tour === "participant_creation") runTourSegment("participant_creation");
|
if (tour === "participant_creation")
|
||||||
|
runTourSegment("participant_creation");
|
||||||
if (tour === "designer") runTourSegment("designer");
|
if (tour === "designer") runTourSegment("designer");
|
||||||
if (tour === "wizard") runTourSegment("wizard");
|
if (tour === "wizard") runTourSegment("wizard");
|
||||||
if (tour === "analytics") runTourSegment("analytics");
|
if (tour === "analytics") runTourSegment("analytics");
|
||||||
@@ -338,7 +390,7 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TourContext.Provider value={{ startTour }}>
|
<TourContext.Provider value={{ startTour, isTourActive }}>
|
||||||
{children}
|
{children}
|
||||||
<style jsx global>{`
|
<style jsx global>{`
|
||||||
/*
|
/*
|
||||||
@@ -373,30 +425,38 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
|||||||
Using CSS variables requires a bit of trickery because border-color expects distinct values.
|
Using CSS variables requires a bit of trickery because border-color expects distinct values.
|
||||||
We'll target the side classes driver.js adds.
|
We'll target the side classes driver.js adds.
|
||||||
*/
|
*/
|
||||||
.driver-popover-override.driverjs-theme-dark .driver-popover-arrow-side-left.driver-popover-arrow {
|
.driver-popover-override.driverjs-theme-dark
|
||||||
|
.driver-popover-arrow-side-left.driver-popover-arrow {
|
||||||
border-left-color: var(--card) !important;
|
border-left-color: var(--card) !important;
|
||||||
}
|
}
|
||||||
.driver-popover-override.driverjs-theme-dark .driver-popover-arrow-side-right.driver-popover-arrow {
|
.driver-popover-override.driverjs-theme-dark
|
||||||
|
.driver-popover-arrow-side-right.driver-popover-arrow {
|
||||||
border-right-color: var(--card) !important;
|
border-right-color: var(--card) !important;
|
||||||
}
|
}
|
||||||
.driver-popover-override.driverjs-theme-dark .driver-popover-arrow-side-top.driver-popover-arrow {
|
.driver-popover-override.driverjs-theme-dark
|
||||||
|
.driver-popover-arrow-side-top.driver-popover-arrow {
|
||||||
border-top-color: var(--card) !important;
|
border-top-color: var(--card) !important;
|
||||||
}
|
}
|
||||||
.driver-popover-override.driverjs-theme-dark .driver-popover-arrow-side-bottom.driver-popover-arrow {
|
.driver-popover-override.driverjs-theme-dark
|
||||||
|
.driver-popover-arrow-side-bottom.driver-popover-arrow {
|
||||||
border-bottom-color: var(--card) !important;
|
border-bottom-color: var(--card) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Light mode fallbacks (using border color for definition, though card bg is usually sufficient) */
|
/* Light mode fallbacks (using border color for definition, though card bg is usually sufficient) */
|
||||||
.driver-popover-override.driverjs-theme-light .driver-popover-arrow-side-left.driver-popover-arrow {
|
.driver-popover-override.driverjs-theme-light
|
||||||
|
.driver-popover-arrow-side-left.driver-popover-arrow {
|
||||||
border-left-color: var(--card) !important;
|
border-left-color: var(--card) !important;
|
||||||
}
|
}
|
||||||
.driver-popover-override.driverjs-theme-light .driver-popover-arrow-side-right.driver-popover-arrow {
|
.driver-popover-override.driverjs-theme-light
|
||||||
|
.driver-popover-arrow-side-right.driver-popover-arrow {
|
||||||
border-right-color: var(--card) !important;
|
border-right-color: var(--card) !important;
|
||||||
}
|
}
|
||||||
.driver-popover-override.driverjs-theme-light .driver-popover-arrow-side-top.driver-popover-arrow {
|
.driver-popover-override.driverjs-theme-light
|
||||||
|
.driver-popover-arrow-side-top.driver-popover-arrow {
|
||||||
border-top-color: var(--card) !important;
|
border-top-color: var(--card) !important;
|
||||||
}
|
}
|
||||||
.driver-popover-override.driverjs-theme-light .driver-popover-arrow-side-bottom.driver-popover-arrow {
|
.driver-popover-override.driverjs-theme-light
|
||||||
|
.driver-popover-arrow-side-bottom.driver-popover-arrow {
|
||||||
border-bottom-color: var(--card) !important;
|
border-bottom-color: var(--card) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,12 +498,16 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Navigation Buttons (Previous/Next) specifically */
|
/* Navigation Buttons (Previous/Next) specifically */
|
||||||
.driver-popover-override .driver-popover-footer .driver-popover-prev-btn {
|
.driver-popover-override
|
||||||
|
.driver-popover-footer
|
||||||
|
.driver-popover-prev-btn {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
color: var(--muted-foreground) !important;
|
color: var(--muted-foreground) !important;
|
||||||
border: 1px solid var(--border) !important;
|
border: 1px solid var(--border) !important;
|
||||||
}
|
}
|
||||||
.driver-popover-override .driver-popover-footer .driver-popover-prev-btn:hover {
|
.driver-popover-override
|
||||||
|
.driver-popover-footer
|
||||||
|
.driver-popover-prev-btn:hover {
|
||||||
background-color: var(--accent) !important;
|
background-color: var(--accent) !important;
|
||||||
color: var(--accent-foreground) !important;
|
color: var(--accent-foreground) !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Upload, X, FileText, CheckCircle, AlertCircle, Loader2 } from "lucide-react";
|
import {
|
||||||
|
Upload,
|
||||||
|
X,
|
||||||
|
FileText,
|
||||||
|
CheckCircle,
|
||||||
|
AlertCircle,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Progress } from "~/components/ui/progress";
|
import { Progress } from "~/components/ui/progress";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
@@ -28,7 +35,8 @@ export function ConsentUploadForm({
|
|||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
const getUploadUrlMutation = api.participants.getConsentUploadUrl.useMutation();
|
const getUploadUrlMutation =
|
||||||
|
api.participants.getConsentUploadUrl.useMutation();
|
||||||
const recordConsentMutation = api.participants.recordConsent.useMutation();
|
const recordConsentMutation = api.participants.recordConsent.useMutation();
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -42,7 +50,12 @@ export function ConsentUploadForm({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Validate type
|
// Validate type
|
||||||
const allowedTypes = ["application/pdf", "image/png", "image/jpeg", "image/jpg"];
|
const allowedTypes = [
|
||||||
|
"application/pdf",
|
||||||
|
"image/png",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/jpg",
|
||||||
|
];
|
||||||
if (!allowedTypes.includes(selectedFile.type)) {
|
if (!allowedTypes.includes(selectedFile.type)) {
|
||||||
toast.error("Invalid file type", {
|
toast.error("Invalid file type", {
|
||||||
description: "Please upload a PDF, PNG, or JPG file",
|
description: "Please upload a PDF, PNG, or JPG file",
|
||||||
@@ -78,7 +91,7 @@ export function ConsentUploadForm({
|
|||||||
xhr.upload.onprogress = (event) => {
|
xhr.upload.onprogress = (event) => {
|
||||||
if (event.lengthComputable) {
|
if (event.lengthComputable) {
|
||||||
const percentCompleted = Math.round(
|
const percentCompleted = Math.round(
|
||||||
(event.loaded * 100) / event.total
|
(event.loaded * 100) / event.total,
|
||||||
);
|
);
|
||||||
setUploadProgress(percentCompleted);
|
setUploadProgress(percentCompleted);
|
||||||
}
|
}
|
||||||
@@ -104,14 +117,18 @@ export function ConsentUploadForm({
|
|||||||
});
|
});
|
||||||
|
|
||||||
toast.success("Consent Recorded", {
|
toast.success("Consent Recorded", {
|
||||||
description: "The consent form has been uploaded and recorded successfully.",
|
description:
|
||||||
|
"The consent form has been uploaded and recorded successfully.",
|
||||||
});
|
});
|
||||||
|
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Upload failed:", error);
|
console.error("Upload failed:", error);
|
||||||
toast.error("Upload Failed", {
|
toast.error("Upload Failed", {
|
||||||
description: error instanceof Error ? error.message : "An unexpected error occurred",
|
description:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "An unexpected error occurred",
|
||||||
});
|
});
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
}
|
}
|
||||||
@@ -120,11 +137,12 @@ export function ConsentUploadForm({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{!file ? (
|
{!file ? (
|
||||||
<div className="flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-6 bg-muted/5 hover:bg-muted/10 transition-colors">
|
<div className="bg-muted/5 hover:bg-muted/10 flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors">
|
||||||
<Upload className="h-8 w-8 text-muted-foreground mb-4" />
|
<Upload className="text-muted-foreground mb-4 h-8 w-8" />
|
||||||
<h3 className="font-semibold text-sm mb-1">Upload Signed Consent</h3>
|
<h3 className="mb-1 text-sm font-semibold">Upload Signed Consent</h3>
|
||||||
<p className="text-xs text-muted-foreground mb-4 text-center">
|
<p className="text-muted-foreground mb-4 text-center text-xs">
|
||||||
Drag and drop or click to select<br />
|
Drag and drop or click to select
|
||||||
|
<br />
|
||||||
PDF, PNG, JPG up to 10MB
|
PDF, PNG, JPG up to 10MB
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
@@ -134,34 +152,47 @@ export function ConsentUploadForm({
|
|||||||
accept=".pdf,.png,.jpg,.jpeg"
|
accept=".pdf,.png,.jpg,.jpeg"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
/>
|
/>
|
||||||
<Button variant="secondary" size="sm" onClick={() => document.getElementById("consent-file-upload")?.click()}>
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
document.getElementById("consent-file-upload")?.click()
|
||||||
|
}
|
||||||
|
>
|
||||||
Select File
|
Select File
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="border rounded-lg p-4 bg-muted/5">
|
<div className="bg-muted/5 rounded-lg border p-4">
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="mb-4 flex items-start justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="h-10 w-10 bg-primary/10 rounded flex items-center justify-center">
|
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded">
|
||||||
<FileText className="h-5 w-5 text-primary" />
|
<FileText className="text-primary h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium line-clamp-1 break-all">{file.name}</p>
|
<p className="line-clamp-1 text-sm font-medium break-all">
|
||||||
<p className="text-xs text-muted-foreground">
|
{file.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
{(file.size / 1024 / 1024).toFixed(2)} MB
|
{(file.size / 1024 / 1024).toFixed(2)} MB
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!isUploading && (
|
{!isUploading && (
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setFile(null)}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => setFile(null)}
|
||||||
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isUploading && (
|
{isUploading && (
|
||||||
<div className="space-y-2 mb-4">
|
<div className="mb-4 space-y-2">
|
||||||
<div className="flex justify-between text-xs text-muted-foreground">
|
<div className="text-muted-foreground flex justify-between text-xs">
|
||||||
<span>Uploading...</span>
|
<span>Uploading...</span>
|
||||||
<span>{uploadProgress}%</span>
|
<span>{uploadProgress}%</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -169,8 +200,13 @@ export function ConsentUploadForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex justify-end gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={onCancel} disabled={isUploading}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={handleUpload} disabled={isUploading}>
|
<Button size="sm" onClick={handleUpload} disabled={isUploading}>
|
||||||
|
|||||||
235
src/components/participants/DigitalSignatureModal.tsx
Normal file
235
src/components/participants/DigitalSignatureModal.tsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import SignatureCanvas from "react-signature-canvas";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "~/components/ui/dialog";
|
||||||
|
import { PenBox, Eraser, Loader2, CheckCircle } from "lucide-react";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { generatePdfBlobFromHtml } from "~/lib/pdf-generator";
|
||||||
|
import { Editor, EditorContent, useEditor } from "@tiptap/react";
|
||||||
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
|
import { Markdown } from "tiptap-markdown";
|
||||||
|
import { Table } from "@tiptap/extension-table";
|
||||||
|
import TableRow from "@tiptap/extension-table-row";
|
||||||
|
import TableCell from "@tiptap/extension-table-cell";
|
||||||
|
import TableHeader from "@tiptap/extension-table-header";
|
||||||
|
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||||
|
|
||||||
|
interface DigitalSignatureModalProps {
|
||||||
|
studyId: string;
|
||||||
|
participantId: string;
|
||||||
|
participantName?: string | null;
|
||||||
|
participantCode: string;
|
||||||
|
activeForm: { id: string; content: string; version: number };
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DigitalSignatureModal({
|
||||||
|
studyId,
|
||||||
|
participantId,
|
||||||
|
participantName,
|
||||||
|
participantCode,
|
||||||
|
activeForm,
|
||||||
|
onSuccess,
|
||||||
|
}: DigitalSignatureModalProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const sigCanvas = useRef<any>(null);
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const getUploadUrlMutation = api.participants.getConsentUploadUrl.useMutation();
|
||||||
|
const recordConsentMutation = api.participants.recordConsent.useMutation();
|
||||||
|
|
||||||
|
// Create a preview version of the text
|
||||||
|
let previewMd = activeForm.content;
|
||||||
|
previewMd = previewMd.replace(/{{PARTICIPANT_NAME}}/g, participantName ?? "_________________");
|
||||||
|
previewMd = previewMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
|
||||||
|
const today = new Date().toLocaleDateString();
|
||||||
|
previewMd = previewMd.replace(/{{DATE}}/g, today);
|
||||||
|
previewMd = previewMd.replace(/{{SIGNATURE_IMAGE}}/g, "_[Signature Here]_");
|
||||||
|
|
||||||
|
const previewEditor = useEditor({
|
||||||
|
extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown],
|
||||||
|
content: previewMd,
|
||||||
|
editable: false,
|
||||||
|
immediatelyRender: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
sigCanvas.current?.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (sigCanvas.current?.isEmpty()) {
|
||||||
|
toast.error("Signature required", { description: "Please sign the document before submitting." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
toast.loading("Generating Signed Document...", { id: "sig-upload" });
|
||||||
|
|
||||||
|
// 1. Get Signature Image Data URL
|
||||||
|
const signatureDataUrl = sigCanvas.current.getTrimmedCanvas().toDataURL("image/png");
|
||||||
|
|
||||||
|
// 2. Prepare final Markdown and HTML
|
||||||
|
let finalMd = activeForm.content;
|
||||||
|
finalMd = finalMd.replace(/{{PARTICIPANT_NAME}}/g, participantName ?? "_________________");
|
||||||
|
finalMd = finalMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
|
||||||
|
finalMd = finalMd.replace(/{{DATE}}/g, today);
|
||||||
|
finalMd = finalMd.replace(/{{SIGNATURE_IMAGE}}/g, `<img src="${signatureDataUrl}" style="height: 60px; max-width: 250px;" />`);
|
||||||
|
|
||||||
|
const headlessEditor = new Editor({
|
||||||
|
extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown],
|
||||||
|
content: finalMd,
|
||||||
|
});
|
||||||
|
const htmlContent = headlessEditor.getHTML();
|
||||||
|
headlessEditor.destroy();
|
||||||
|
|
||||||
|
// 3. Generate PDF Blob
|
||||||
|
const filename = `Signed_Consent_${participantCode}_v${activeForm.version}.pdf`;
|
||||||
|
const pdfBlob = await generatePdfBlobFromHtml(htmlContent, { filename });
|
||||||
|
const file = new File([pdfBlob], filename, { type: "application/pdf" });
|
||||||
|
|
||||||
|
// 4. Get Presigned URL
|
||||||
|
toast.loading("Uploading Document...", { id: "sig-upload" });
|
||||||
|
const { url, key } = await getUploadUrlMutation.mutateAsync({
|
||||||
|
studyId,
|
||||||
|
participantId,
|
||||||
|
filename: file.name,
|
||||||
|
contentType: file.type,
|
||||||
|
size: file.size,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Upload to MinIO
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("PUT", url, true);
|
||||||
|
xhr.setRequestHeader("Content-Type", file.type);
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
||||||
|
else reject(new Error(`Upload failed with status ${xhr.status}`));
|
||||||
|
};
|
||||||
|
xhr.onerror = () => reject(new Error("Network error during upload"));
|
||||||
|
xhr.send(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Record Consent in DB
|
||||||
|
toast.loading("Finalizing Consent...", { id: "sig-upload" });
|
||||||
|
await recordConsentMutation.mutateAsync({
|
||||||
|
participantId,
|
||||||
|
consentFormId: activeForm.id,
|
||||||
|
storagePath: key,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Consent Successfully Recorded!", { id: "sig-upload" });
|
||||||
|
setIsOpen(false);
|
||||||
|
onSuccess();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error("Failed to submit digital signature", {
|
||||||
|
id: "sig-upload",
|
||||||
|
description: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="default" size="sm" className="bg-primary/90 hover:bg-primary">
|
||||||
|
<PenBox className="mr-2 h-4 w-4" />
|
||||||
|
Sign Digitally
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-6">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Digital Consent Signature</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Please review the document below and provide your digital signature to consent to this study.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
|
||||||
|
{/* Document Preview (Left) */}
|
||||||
|
<div className="flex flex-col border rounded-md overflow-hidden bg-muted/20">
|
||||||
|
<div className="bg-muted px-4 py-2 border-b text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||||
|
Document Preview
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="flex-1 w-full bg-white p-6 shadow-inner">
|
||||||
|
<div className="prose prose-sm max-w-none text-black">
|
||||||
|
<EditorContent editor={previewEditor} />
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Signature Panel (Right) */}
|
||||||
|
<div className="flex flex-col space-y-4">
|
||||||
|
<div className="border rounded-md overflow-hidden bg-white shadow-sm flex flex-col">
|
||||||
|
<div className="bg-muted px-4 py-2 border-b text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||||
|
Digital Signature Pad
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-muted/10 relative">
|
||||||
|
<div className="absolute top-4 right-4">
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleClear} disabled={isSubmitting}>
|
||||||
|
<Eraser className="h-4 w-4 mr-2" />
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="border-2 border-dashed border-input rounded-md bg-white mt-10" style={{ height: "250px" }}>
|
||||||
|
<SignatureCanvas
|
||||||
|
ref={sigCanvas}
|
||||||
|
penColor="black"
|
||||||
|
canvasProps={{ className: "w-full h-full cursor-crosshair rounded-md" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-xs text-muted-foreground mt-2">
|
||||||
|
Draw your signature using your mouse or touch screen inside the box above.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Submission Actions */}
|
||||||
|
<div className="flex flex-col space-y-3 p-4 bg-primary/5 rounded-lg border border-primary/20">
|
||||||
|
<h4 className="flex items-center text-sm font-semibold text-primary">
|
||||||
|
<CheckCircle className="h-4 w-4 mr-2" />
|
||||||
|
Agreement
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
By clicking "Submit Signed Document", you confirm that you have read and understood the information provided in the document preview, and you voluntarily agree to participate in this study.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
className="w-full mt-2"
|
||||||
|
size="lg"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Processing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Submit Signed Document"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,10 +12,25 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "~/components/ui/dialog";
|
} from "~/components/ui/dialog";
|
||||||
import { ConsentUploadForm } from "./ConsentUploadForm";
|
import { ConsentUploadForm } from "./ConsentUploadForm";
|
||||||
import { FileText, Download, CheckCircle, AlertCircle, Upload } from "lucide-react";
|
import {
|
||||||
|
FileText,
|
||||||
|
Download,
|
||||||
|
CheckCircle,
|
||||||
|
AlertCircle,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
import { downloadPdfFromHtml } from "~/lib/pdf-generator";
|
||||||
|
import { Editor } from "@tiptap/core";
|
||||||
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
|
import { Markdown } from "tiptap-markdown";
|
||||||
|
import { Table } from "@tiptap/extension-table";
|
||||||
|
import TableRow from "@tiptap/extension-table-row";
|
||||||
|
import TableCell from "@tiptap/extension-table-cell";
|
||||||
|
import TableHeader from "@tiptap/extension-table-header";
|
||||||
|
import { DigitalSignatureModal } from "./DigitalSignatureModal";
|
||||||
|
|
||||||
interface ParticipantConsentManagerProps {
|
interface ParticipantConsentManagerProps {
|
||||||
studyId: string;
|
studyId: string;
|
||||||
@@ -39,18 +54,22 @@ export function ParticipantConsentManager({
|
|||||||
consentGiven,
|
consentGiven,
|
||||||
consentDate,
|
consentDate,
|
||||||
existingConsent,
|
existingConsent,
|
||||||
}: ParticipantConsentManagerProps) {
|
participantName,
|
||||||
|
participantCode,
|
||||||
|
}: ParticipantConsentManagerProps & { participantName?: string | null; participantCode: string }) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
// Fetch active consent forms to know which form to sign/upload against
|
// Fetch active consent forms to know which form to sign/upload against
|
||||||
const { data: consentForms } = api.participants.getConsentForms.useQuery({ studyId });
|
const { data: consentForms } = api.participants.getConsentForms.useQuery({
|
||||||
|
studyId,
|
||||||
|
});
|
||||||
const activeForm = consentForms?.find((f) => f.active) ?? consentForms?.[0];
|
const activeForm = consentForms?.find((f) => f.active) ?? consentForms?.[0];
|
||||||
|
|
||||||
// Helper to get download URL
|
// Helper to get download URL
|
||||||
const { refetch: fetchDownloadUrl } = api.files.getDownloadUrl.useQuery(
|
const { refetch: fetchDownloadUrl } = api.files.getDownloadUrl.useQuery(
|
||||||
{ storagePath: existingConsent?.storagePath ?? "" },
|
{ storagePath: existingConsent?.storagePath ?? "" },
|
||||||
{ enabled: false }
|
{ enabled: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
@@ -73,15 +92,47 @@ export function ParticipantConsentManager({
|
|||||||
toast.success("Success", { description: "Consent recorded successfully" });
|
toast.success("Success", { description: "Consent recorded successfully" });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDownloadUnsigned = async () => {
|
||||||
|
if (!activeForm) return;
|
||||||
|
try {
|
||||||
|
toast.loading("Generating custom document...", { id: "pdf-gen" });
|
||||||
|
|
||||||
|
// Substitute placeholders in markdown
|
||||||
|
let customMd = activeForm.content;
|
||||||
|
customMd = customMd.replace(/{{PARTICIPANT_NAME}}/g, participantName ?? "_________________");
|
||||||
|
customMd = customMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
|
||||||
|
customMd = customMd.replace(/{{DATE}}/g, "_________________");
|
||||||
|
customMd = customMd.replace(/{{SIGNATURE_IMAGE}}/g, ""); // Blank ready for physical signature
|
||||||
|
|
||||||
|
// Use headless Tiptap to parse MD to HTML via same extensions
|
||||||
|
const editor = new Editor({
|
||||||
|
extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown],
|
||||||
|
content: customMd,
|
||||||
|
});
|
||||||
|
|
||||||
|
const htmlContent = editor.getHTML();
|
||||||
|
editor.destroy();
|
||||||
|
|
||||||
|
await downloadPdfFromHtml(htmlContent, {
|
||||||
|
filename: `Consent_${participantCode}_${activeForm.version}.pdf`,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Document Downloaded", { id: "pdf-gen" });
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Error generating customized PDF", { id: "pdf-gen" });
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-card text-card-foreground shadow-sm">
|
<div className="bg-card text-card-foreground rounded-lg border shadow-sm">
|
||||||
<div className="p-6 flex flex-row items-center justify-between space-y-0 pb-2">
|
<div className="flex flex-row items-center justify-between space-y-0 p-6 pb-2">
|
||||||
<div className="flex flex-col space-y-1.5">
|
<div className="flex flex-col space-y-1.5">
|
||||||
<h3 className="font-semibold leading-none tracking-tight flex items-center gap-2">
|
<h3 className="flex items-center gap-2 leading-none font-semibold tracking-tight">
|
||||||
<FileText className="h-5 w-5" />
|
<FileText className="h-5 w-5" />
|
||||||
Consent Status
|
Consent Status
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground text-sm">
|
||||||
Manage participant consent and forms.
|
Manage participant consent and forms.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,16 +147,20 @@ export function ParticipantConsentManager({
|
|||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2 text-sm font-medium">
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||||
Signed on {consentDate ? new Date(consentDate).toLocaleDateString() : "Unknown date"}
|
Signed on{" "}
|
||||||
|
{consentDate
|
||||||
|
? new Date(consentDate).toLocaleDateString()
|
||||||
|
: "Unknown date"}
|
||||||
</div>
|
</div>
|
||||||
{existingConsent && (
|
{existingConsent && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-muted-foreground text-xs">
|
||||||
Form: {existingConsent.consentForm.title} (v{existingConsent.consentForm.version})
|
Form: {existingConsent.consentForm.title} (v
|
||||||
|
{existingConsent.consentForm.version})
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
No consent recorded for this participant.
|
No consent recorded for this participant.
|
||||||
</div>
|
</div>
|
||||||
@@ -121,18 +176,39 @@ export function ParticipantConsentManager({
|
|||||||
|
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button size="sm" variant={consentGiven ? "secondary" : "default"}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={consentGiven ? "secondary" : "default"}
|
||||||
|
>
|
||||||
<Upload className="mr-2 h-4 w-4" />
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
{consentGiven ? "Update Consent" : "Record Consent"}
|
{consentGiven ? "Update Consent" : "Upload Consent"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
|
{!consentGiven && activeForm && (
|
||||||
|
<>
|
||||||
|
<DigitalSignatureModal
|
||||||
|
studyId={studyId}
|
||||||
|
participantId={participantId}
|
||||||
|
participantName={participantName}
|
||||||
|
participantCode={participantCode}
|
||||||
|
activeForm={activeForm}
|
||||||
|
onSuccess={handleSuccess}
|
||||||
|
/>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleDownloadUnsigned}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Print Empty Form
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Upload Signed Consent Form</DialogTitle>
|
<DialogTitle>Upload Signed Consent Form</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Upload the signed PDF or image of the consent form for this participant.
|
Upload the signed PDF or image of the consent form for this
|
||||||
|
participant.
|
||||||
{activeForm && (
|
{activeForm && (
|
||||||
<span className="block mt-1 font-medium text-foreground">
|
<span className="text-foreground mt-1 block font-medium">
|
||||||
Active Form: {activeForm.title} (v{activeForm.version})
|
Active Form: {activeForm.title} (v{activeForm.version})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -147,8 +223,9 @@ export function ParticipantConsentManager({
|
|||||||
onCancel={() => setIsOpen(false)}
|
onCancel={() => setIsOpen(false)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-4 text-center text-muted-foreground">
|
<div className="text-muted-foreground py-4 text-center">
|
||||||
No active consent form found for this study. Please create one first.
|
No active consent form found for this study. Please create
|
||||||
|
one first.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "~/components/ui/select";
|
} from "~/components/ui/select";
|
||||||
import { useStudyContext } from "~/lib/study-context";
|
import { useStudyContext } from "~/lib/study-context";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { useTour } from "~/components/onboarding/TourProvider";
|
import { useTour } from "~/components/onboarding/TourProvider";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
@@ -94,6 +95,7 @@ export function ParticipantForm({
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
consentGiven: false,
|
consentGiven: false,
|
||||||
studyId: contextStudyId ?? "",
|
studyId: contextStudyId ?? "",
|
||||||
|
participantCode: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -183,6 +185,20 @@ export function ParticipantForm({
|
|||||||
}
|
}
|
||||||
}, [contextStudyId, mode, form]);
|
}, [contextStudyId, mode, form]);
|
||||||
|
|
||||||
|
// Fetch next participant code
|
||||||
|
const { data: nextCode, isLoading: isNextCodeLoading } =
|
||||||
|
api.participants.getNextCode.useQuery(
|
||||||
|
{ studyId: contextStudyId! },
|
||||||
|
{ enabled: mode === "create" && !!contextStudyId },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update default value if we switch modes or remount
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === "create" && nextCode) {
|
||||||
|
form.setValue("participantCode", nextCode, { shouldValidate: true });
|
||||||
|
}
|
||||||
|
}, [mode, nextCode, form]);
|
||||||
|
|
||||||
const createParticipantMutation = api.participants.create.useMutation();
|
const createParticipantMutation = api.participants.create.useMutation();
|
||||||
const updateParticipantMutation = api.participants.update.useMutation();
|
const updateParticipantMutation = api.participants.update.useMutation();
|
||||||
const deleteParticipantMutation = api.participants.delete.useMutation();
|
const deleteParticipantMutation = api.participants.delete.useMutation();
|
||||||
@@ -206,7 +222,9 @@ export function ParticipantForm({
|
|||||||
email: data.email ?? undefined,
|
email: data.email ?? undefined,
|
||||||
demographics,
|
demographics,
|
||||||
});
|
});
|
||||||
router.push(`/studies/${data.studyId}/participants/${newParticipant.id}`);
|
router.push(
|
||||||
|
`/studies/${data.studyId}/participants/${newParticipant.id}`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
const updatedParticipant = await updateParticipantMutation.mutateAsync({
|
const updatedParticipant = await updateParticipantMutation.mutateAsync({
|
||||||
id: participantId!,
|
id: participantId!,
|
||||||
@@ -215,7 +233,9 @@ export function ParticipantForm({
|
|||||||
email: data.email ?? undefined,
|
email: data.email ?? undefined,
|
||||||
demographics,
|
demographics,
|
||||||
});
|
});
|
||||||
router.push(`/studies/${contextStudyId}/participants/${updatedParticipant.id}`);
|
router.push(
|
||||||
|
`/studies/${contextStudyId}/participants/${updatedParticipant.id}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(
|
setError(
|
||||||
@@ -261,16 +281,18 @@ export function ParticipantForm({
|
|||||||
title="Participant Information"
|
title="Participant Information"
|
||||||
description="Basic identity and study association."
|
description="Basic identity and study association."
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||||
<FormField>
|
<FormField>
|
||||||
<Label htmlFor="participantCode">Participant Code *</Label>
|
<Label htmlFor="participantCode">Participant Code *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="tour-participant-code"
|
id="tour-participant-code"
|
||||||
{...form.register("participantCode")}
|
{...form.register("participantCode")}
|
||||||
placeholder="e.g., P001"
|
placeholder={isNextCodeLoading ? "Generating..." : "e.g., P001"}
|
||||||
className={
|
readOnly={true}
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-muted-foreground",
|
||||||
form.formState.errors.participantCode ? "border-red-500" : ""
|
form.formState.errors.participantCode ? "border-red-500" : ""
|
||||||
}
|
)}
|
||||||
/>
|
/>
|
||||||
{form.formState.errors.participantCode && (
|
{form.formState.errors.participantCode && (
|
||||||
<p className="text-sm text-red-600">
|
<p className="text-sm text-red-600">
|
||||||
@@ -315,12 +337,15 @@ export function ParticipantForm({
|
|||||||
<div className="my-6" />
|
<div className="my-6" />
|
||||||
|
|
||||||
<FormSection
|
<FormSection
|
||||||
title="Demographics & Study"
|
title={contextStudyId ? "Demographics" : "Demographics & Study"}
|
||||||
description="study association and demographic details."
|
description={contextStudyId ? "Participant demographic details." : "Study association and demographic details."}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||||
|
{!contextStudyId && (
|
||||||
<FormField>
|
<FormField>
|
||||||
<Label htmlFor="studyId" id="tour-participant-study-label">Study *</Label>
|
<Label htmlFor="studyId" id="tour-participant-study-label">
|
||||||
|
Study *
|
||||||
|
</Label>
|
||||||
<div id="tour-participant-study-container">
|
<div id="tour-participant-study-container">
|
||||||
<Select
|
<Select
|
||||||
value={form.watch("studyId")}
|
value={form.watch("studyId")}
|
||||||
@@ -333,9 +358,7 @@ export function ParticipantForm({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectValue
|
<SelectValue
|
||||||
placeholder={
|
placeholder={studiesLoading ? "Loading..." : "Select study"}
|
||||||
studiesLoading ? "Loading..." : "Select study"
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -353,6 +376,7 @@ export function ParticipantForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormField>
|
<FormField>
|
||||||
<Label htmlFor="age">Age</Label>
|
<Label htmlFor="age">Age</Label>
|
||||||
@@ -503,10 +527,16 @@ export function ParticipantForm({
|
|||||||
submitButtonId="tour-participant-submit"
|
submitButtonId="tour-participant-submit"
|
||||||
extraActions={
|
extraActions={
|
||||||
mode === "create" ? (
|
mode === "create" ? (
|
||||||
<Button variant="ghost" size="sm" onClick={() => startTour("participant_creation")}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => startTour("participant_creation")}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-muted-foreground">Help</span>
|
<span className="text-muted-foreground">Help</span>
|
||||||
<div className="flex h-5 w-5 items-center justify-center rounded-full border text-xs text-muted-foreground">?</div>
|
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center rounded-full border text-xs">
|
||||||
|
?
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
) : undefined
|
) : undefined
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export const columns: ColumnDef<Participant>[] = [
|
|||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div>
|
<div>
|
||||||
<div className="truncate font-medium max-w-[200px]">
|
<div className="max-w-[200px] truncate font-medium">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span>{String(name) || "No name provided"}</span>
|
<span>{String(name) || "No name provided"}</span>
|
||||||
@@ -120,7 +120,7 @@ export const columns: ColumnDef<Participant>[] = [
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
{email && (
|
{email && (
|
||||||
<div className="text-muted-foreground truncate text-sm max-w-[200px]">
|
<div className="text-muted-foreground max-w-[200px] truncate text-sm">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span>{email}</span>
|
<span>{email}</span>
|
||||||
@@ -214,18 +214,20 @@ export const columns: ColumnDef<Participant>[] = [
|
|||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href={`/studies/${studyId}/participants/${participant.id}/edit`}>
|
<Link
|
||||||
|
href={`/studies/${studyId}/participants/${participant.id}/edit`}
|
||||||
|
>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Edit participant
|
Edit participant
|
||||||
</Link >
|
</Link>
|
||||||
</DropdownMenuItem >
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem className="text-red-600">
|
<DropdownMenuItem className="text-red-600">
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
Remove
|
Remove
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent >
|
</DropdownMenuContent>
|
||||||
</DropdownMenu >
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "~/components/ui/select";
|
} from "~/components/ui/select";
|
||||||
|
|
||||||
import { Mail, Plus, UserPlus } from "lucide-react";
|
import { Mail, Plus, UserPlus, Microscope, Wand2, Eye } from "lucide-react";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { useStudyManagement } from "~/hooks/useStudyManagement";
|
import { useStudyManagement } from "~/hooks/useStudyManagement";
|
||||||
|
|
||||||
@@ -54,17 +54,17 @@ const roleDescriptions = {
|
|||||||
researcher: {
|
researcher: {
|
||||||
label: "Researcher",
|
label: "Researcher",
|
||||||
description: "Can manage experiments, view all data, and invite members",
|
description: "Can manage experiments, view all data, and invite members",
|
||||||
icon: "🔬",
|
icon: Microscope,
|
||||||
},
|
},
|
||||||
wizard: {
|
wizard: {
|
||||||
label: "Wizard",
|
label: "Wizard",
|
||||||
description: "Can control trials and execute experiments",
|
description: "Can control trials and execute experiments",
|
||||||
icon: "🎭",
|
icon: Wand2,
|
||||||
},
|
},
|
||||||
observer: {
|
observer: {
|
||||||
label: "Observer",
|
label: "Observer",
|
||||||
description: "Read-only access to view trials and data",
|
description: "Read-only access to view trials and data",
|
||||||
icon: "👁️",
|
icon: Eye,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -167,7 +167,10 @@ export function InviteMemberDialog({
|
|||||||
([value, config]) => (
|
([value, config]) => (
|
||||||
<SelectItem key={value} value={value}>
|
<SelectItem key={value} value={value}>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<span>{config.icon}</span>
|
{(() => {
|
||||||
|
const Icon = config.icon;
|
||||||
|
return <Icon className="h-4 w-4" />;
|
||||||
|
})()}
|
||||||
<span>{config.label}</span>
|
<span>{config.label}</span>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -180,8 +183,18 @@ export function InviteMemberDialog({
|
|||||||
<div className="mt-2 rounded-lg bg-slate-50 p-3">
|
<div className="mt-2 rounded-lg bg-slate-50 p-3">
|
||||||
<div className="mb-1 flex items-center space-x-2">
|
<div className="mb-1 flex items-center space-x-2">
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{roleDescriptions[field.value].icon}{" "}
|
{(() => {
|
||||||
{roleDescriptions[field.value].label}
|
const Icon =
|
||||||
|
roleDescriptions[
|
||||||
|
field.value as keyof typeof roleDescriptions
|
||||||
|
].icon;
|
||||||
|
return <Icon className="mr-1 h-3.5 w-3.5" />;
|
||||||
|
})()}
|
||||||
|
{
|
||||||
|
roleDescriptions[
|
||||||
|
field.value as keyof typeof roleDescriptions
|
||||||
|
].label
|
||||||
|
}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-600">
|
<p className="text-xs text-slate-600">
|
||||||
|
|||||||
@@ -5,7 +5,14 @@ import { ArrowUpDown, MoreHorizontal } from "lucide-react";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
import { AlertCircle, Filter } from "lucide-react";
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Filter,
|
||||||
|
Activity,
|
||||||
|
FileEdit,
|
||||||
|
CheckCircle2,
|
||||||
|
Archive,
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
@@ -19,7 +26,7 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger,
|
||||||
} from "~/components/ui/dropdown-menu";
|
} from "~/components/ui/dropdown-menu";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
|
|
||||||
@@ -69,22 +76,22 @@ const statusConfig = {
|
|||||||
draft: {
|
draft: {
|
||||||
label: "Draft",
|
label: "Draft",
|
||||||
className: "bg-gray-100 text-gray-800",
|
className: "bg-gray-100 text-gray-800",
|
||||||
icon: "📝",
|
icon: FileEdit,
|
||||||
},
|
},
|
||||||
active: {
|
active: {
|
||||||
label: "Active",
|
label: "Active",
|
||||||
className: "bg-green-100 text-green-800",
|
className: "bg-green-100 text-green-800",
|
||||||
icon: "🟢",
|
icon: Activity,
|
||||||
},
|
},
|
||||||
completed: {
|
completed: {
|
||||||
label: "Completed",
|
label: "Completed",
|
||||||
className: "bg-blue-100 text-blue-800",
|
className: "bg-blue-100 text-blue-800",
|
||||||
icon: "✅",
|
icon: CheckCircle2,
|
||||||
},
|
},
|
||||||
archived: {
|
archived: {
|
||||||
label: "Archived",
|
label: "Archived",
|
||||||
className: "bg-orange-100 text-orange-800",
|
className: "bg-orange-100 text-orange-800",
|
||||||
icon: "📦",
|
icon: Archive,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -172,7 +179,7 @@ export const columns: ColumnDef<Study>[] = [
|
|||||||
const statusInfo = statusConfig[status as keyof typeof statusConfig];
|
const statusInfo = statusConfig[status as keyof typeof statusConfig];
|
||||||
return (
|
return (
|
||||||
<Badge className={statusInfo.className}>
|
<Badge className={statusInfo.className}>
|
||||||
<span className="mr-1">{statusInfo.icon}</span>
|
<statusInfo.icon className="mr-1 h-3.5 w-3.5" />
|
||||||
{statusInfo.label}
|
{statusInfo.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
@@ -186,7 +193,9 @@ export const columns: ColumnDef<Study>[] = [
|
|||||||
const isOwner = row.original.isOwner;
|
const isOwner = row.original.isOwner;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge variant={isOwner ? "default" : "secondary"}>{String(userRole)}</Badge>
|
<Badge variant={isOwner ? "default" : "secondary"}>
|
||||||
|
{String(userRole)}
|
||||||
|
</Badge>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -215,7 +224,9 @@ export const columns: ColumnDef<Study>[] = [
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Badge className="bg-blue-100 text-blue-800">{Number(experimentCount)}</Badge>
|
<Badge className="bg-blue-100 text-blue-800">
|
||||||
|
{Number(experimentCount)}
|
||||||
|
</Badge>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -257,7 +268,9 @@ export const columns: ColumnDef<Study>[] = [
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-[120px]">
|
<div className="max-w-[120px]">
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
{formatDistanceToNow(new Date(date as string | number | Date), { addSuffix: true })}
|
{formatDistanceToNow(new Date(date as string | number | Date), {
|
||||||
|
addSuffix: true,
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground truncate text-xs">
|
<div className="text-muted-foreground truncate text-xs">
|
||||||
by {createdBy}
|
by {createdBy}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { CheckCircle2, Activity, FileEdit, Archive } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
@@ -9,7 +10,7 @@ import {
|
|||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle
|
CardTitle,
|
||||||
} from "~/components/ui/card";
|
} from "~/components/ui/card";
|
||||||
import { Separator } from "~/components/ui/separator";
|
import { Separator } from "~/components/ui/separator";
|
||||||
|
|
||||||
@@ -45,22 +46,22 @@ const statusConfig = {
|
|||||||
draft: {
|
draft: {
|
||||||
label: "Draft",
|
label: "Draft",
|
||||||
className: "bg-gray-100 text-gray-800 hover:bg-gray-200",
|
className: "bg-gray-100 text-gray-800 hover:bg-gray-200",
|
||||||
icon: "📝",
|
icon: FileEdit,
|
||||||
},
|
},
|
||||||
active: {
|
active: {
|
||||||
label: "Active",
|
label: "Active",
|
||||||
className: "bg-green-100 text-green-800 hover:bg-green-200",
|
className: "bg-green-100 text-green-800 hover:bg-green-200",
|
||||||
icon: "🟢",
|
icon: Activity,
|
||||||
},
|
},
|
||||||
completed: {
|
completed: {
|
||||||
label: "Completed",
|
label: "Completed",
|
||||||
className: "bg-blue-100 text-blue-800 hover:bg-blue-200",
|
className: "bg-blue-100 text-blue-800 hover:bg-blue-200",
|
||||||
icon: "✅",
|
icon: CheckCircle2,
|
||||||
},
|
},
|
||||||
archived: {
|
archived: {
|
||||||
label: "Archived",
|
label: "Archived",
|
||||||
className: "bg-orange-100 text-orange-800 hover:bg-orange-200",
|
className: "bg-orange-100 text-orange-800 hover:bg-orange-200",
|
||||||
icon: "📦",
|
icon: Archive,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,7 +85,7 @@ export function StudyCard({ study, userRole, isOwner }: StudyCardProps) {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Badge className={statusInfo.className} variant="secondary">
|
<Badge className={statusInfo.className} variant="secondary">
|
||||||
<span className="mr-1">{statusInfo.icon}</span>
|
<statusInfo.icon className="mr-1 h-3.5 w-3.5" />
|
||||||
{statusInfo.label}
|
{statusInfo.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,10 +31,7 @@ import { Button } from "../ui/button";
|
|||||||
|
|
||||||
const studySchema = z.object({
|
const studySchema = z.object({
|
||||||
name: z.string().min(1, "Study name is required").max(255, "Name too long"),
|
name: z.string().min(1, "Study name is required").max(255, "Name too long"),
|
||||||
description: z
|
description: z.string().max(1000, "Description too long").optional(),
|
||||||
.string()
|
|
||||||
.min(10, "Description must be at least 10 characters")
|
|
||||||
.max(1000, "Description too long"),
|
|
||||||
institution: z
|
institution: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, "Institution is required")
|
.min(1, "Institution is required")
|
||||||
@@ -115,7 +112,7 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
|
|||||||
institution: data.institution,
|
institution: data.institution,
|
||||||
irbProtocol: data.irbProtocolNumber ?? undefined,
|
irbProtocol: data.irbProtocolNumber ?? undefined,
|
||||||
});
|
});
|
||||||
router.push(`/studies/${newStudy.id}`);
|
router.push(`/studies/${newStudy.id}/participants/new`);
|
||||||
} else {
|
} else {
|
||||||
const updatedStudy = await updateStudyMutation.mutateAsync({
|
const updatedStudy = await updateStudyMutation.mutateAsync({
|
||||||
id: studyId!,
|
id: studyId!,
|
||||||
@@ -171,7 +168,7 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
|
|||||||
title="Study Details"
|
title="Study Details"
|
||||||
description="Basic information and status of your research study."
|
description="Basic information and status of your research study."
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
<FormField>
|
<FormField>
|
||||||
<Label htmlFor="tour-study-name">Study Name *</Label>
|
<Label htmlFor="tour-study-name">Study Name *</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -202,7 +199,9 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
|
|||||||
<SelectValue placeholder="Select status" />
|
<SelectValue placeholder="Select status" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="draft">Draft - Study in preparation</SelectItem>
|
<SelectItem value="draft">
|
||||||
|
Draft - Study in preparation
|
||||||
|
</SelectItem>
|
||||||
<SelectItem value="active">
|
<SelectItem value="active">
|
||||||
Active - Currently recruiting/running
|
Active - Currently recruiting/running
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -218,7 +217,7 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
|
|||||||
|
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<FormField>
|
<FormField>
|
||||||
<Label htmlFor="tour-study-description">Description *</Label>
|
<Label htmlFor="tour-study-description">Description</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="tour-study-description"
|
id="tour-study-description"
|
||||||
{...form.register("description")}
|
{...form.register("description")}
|
||||||
@@ -244,7 +243,7 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
|
|||||||
title="Configuration"
|
title="Configuration"
|
||||||
description="Institutional details and ethics approval."
|
description="Institutional details and ethics approval."
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
<FormField>
|
<FormField>
|
||||||
<Label htmlFor="institution">Institution *</Label>
|
<Label htmlFor="institution">Institution *</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -349,10 +348,16 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
|
|||||||
sidebar={mode === "create" ? sidebar : undefined}
|
sidebar={mode === "create" ? sidebar : undefined}
|
||||||
submitButtonId="tour-study-submit"
|
submitButtonId="tour-study-submit"
|
||||||
extraActions={
|
extraActions={
|
||||||
<Button variant="ghost" size="sm" onClick={() => startTour("study_creation")}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => startTour("study_creation")}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-muted-foreground">Help</span>
|
<span className="text-muted-foreground">Help</span>
|
||||||
<div className="flex h-5 w-5 items-center justify-center rounded-full border text-xs text-muted-foreground">?</div>
|
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center rounded-full border text-xs">
|
||||||
|
?
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger,
|
||||||
} from "~/components/ui/dropdown-menu";
|
} from "~/components/ui/dropdown-menu";
|
||||||
import { useTheme } from "./theme-provider";
|
import { useTheme } from "./theme-provider";
|
||||||
|
|
||||||
@@ -18,8 +18,8 @@ export function ThemeToggle() {
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" size="icon">
|
<Button variant="outline" size="icon">
|
||||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
|
||||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
|
||||||
<span className="sr-only">Toggle theme</span>
|
<span className="sr-only">Toggle theme</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|||||||
@@ -85,7 +85,9 @@ function DateTimePicker({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label htmlFor="date-picker" className="text-xs">Date</Label>
|
<Label htmlFor="date-picker" className="text-xs">
|
||||||
|
Date
|
||||||
|
</Label>
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -93,7 +95,7 @@ function DateTimePicker({
|
|||||||
id="date-picker"
|
id="date-picker"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-[240px] justify-start text-left font-normal",
|
"w-[240px] justify-start text-left font-normal",
|
||||||
!value && "text-muted-foreground"
|
!value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
@@ -112,7 +114,9 @@ function DateTimePicker({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label htmlFor="time-picker" className="text-xs">Time</Label>
|
<Label htmlFor="time-picker" className="text-xs">
|
||||||
|
Time
|
||||||
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
id="time-picker"
|
id="time-picker"
|
||||||
@@ -122,7 +126,7 @@ function DateTimePicker({
|
|||||||
disabled={!value}
|
disabled={!value}
|
||||||
className="w-[120px]"
|
className="w-[120px]"
|
||||||
/>
|
/>
|
||||||
<Clock className="absolute right-3 top-2.5 h-4 w-4 text-muted-foreground pointer-events-none" />
|
<Clock className="text-muted-foreground pointer-events-none absolute top-2.5 right-3 h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -197,8 +201,8 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
|||||||
{ participantId: selectedParticipantId },
|
{ participantId: selectedParticipantId },
|
||||||
{
|
{
|
||||||
enabled: !!selectedParticipantId && mode === "create",
|
enabled: !!selectedParticipantId && mode === "create",
|
||||||
refetchOnWindowFocus: false
|
refetchOnWindowFocus: false,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -250,7 +254,9 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
|||||||
form.reset({
|
form.reset({
|
||||||
experimentId: trial.experimentId,
|
experimentId: trial.experimentId,
|
||||||
participantId: trial?.participantId ?? "",
|
participantId: trial?.participantId ?? "",
|
||||||
scheduledAt: trial.scheduledAt ? new Date(trial.scheduledAt) : undefined,
|
scheduledAt: trial.scheduledAt
|
||||||
|
? new Date(trial.scheduledAt)
|
||||||
|
: undefined,
|
||||||
wizardId: trial.wizardId ?? undefined,
|
wizardId: trial.wizardId ?? undefined,
|
||||||
notes: trial.notes ?? "",
|
notes: trial.notes ?? "",
|
||||||
sessionNumber: trial.sessionNumber ?? 1,
|
sessionNumber: trial.sessionNumber ?? 1,
|
||||||
@@ -334,9 +340,9 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
|||||||
submitText={mode === "create" ? "Schedule Trial" : "Save Changes"}
|
submitText={mode === "create" ? "Schedule Trial" : "Save Changes"}
|
||||||
layout="full-width"
|
layout="full-width"
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||||
{/* Left Column: Main Info (Spans 2) */}
|
{/* Left Column: Main Info (Spans 2) */}
|
||||||
<div className="md:col-span-2 space-y-6">
|
<div className="space-y-6 md:col-span-2">
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
<FormField>
|
<FormField>
|
||||||
<Label htmlFor="experimentId">Experiment *</Label>
|
<Label htmlFor="experimentId">Experiment *</Label>
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { type ColumnDef } from "@tanstack/react-table";
|
import { type ColumnDef } from "@tanstack/react-table";
|
||||||
import { ArrowUpDown, ChevronDown, MoreHorizontal, Play, Gamepad2, LineChart, Ban, Printer } from "lucide-react";
|
import {
|
||||||
|
ArrowUpDown,
|
||||||
|
ChevronDown,
|
||||||
|
MoreHorizontal,
|
||||||
|
Play,
|
||||||
|
Gamepad2,
|
||||||
|
LineChart,
|
||||||
|
Ban,
|
||||||
|
Printer,
|
||||||
|
} from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { format, formatDistanceToNow } from "date-fns";
|
import { format, formatDistanceToNow } from "date-fns";
|
||||||
@@ -125,10 +134,7 @@ export const columns: ColumnDef<Trial>[] = [
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="font-mono text-sm">
|
<div className="font-mono text-sm">
|
||||||
<Link
|
<Link href={href} className="hover:underline">
|
||||||
href={href}
|
|
||||||
className="hover:underline"
|
|
||||||
>
|
|
||||||
#{Number(sessionNumber)}
|
#{Number(sessionNumber)}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -240,12 +246,7 @@ export const columns: ColumnDef<Trial>[] = [
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <Badge className={statusInfo.className}>{statusInfo.label}</Badge>;
|
||||||
<Badge className={statusInfo.className}>
|
|
||||||
{statusInfo.label}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -363,7 +364,7 @@ function ActionsCell({ row }: { row: { original: Trial } }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 justify-end">
|
<div className="flex items-center justify-end gap-2">
|
||||||
{trial.status === "scheduled" && (
|
{trial.status === "scheduled" && (
|
||||||
<Button size="sm" asChild>
|
<Button size="sm" asChild>
|
||||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}>
|
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}>
|
||||||
@@ -383,14 +384,18 @@ function ActionsCell({ row }: { row: { original: Trial } }) {
|
|||||||
{trial.status === "completed" && (
|
{trial.status === "completed" && (
|
||||||
<>
|
<>
|
||||||
<Button size="sm" variant="outline" asChild>
|
<Button size="sm" variant="outline" asChild>
|
||||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/analysis`}>
|
<Link
|
||||||
|
href={`/studies/${trial.studyId}/trials/${trial.id}/analysis`}
|
||||||
|
>
|
||||||
<LineChart className="mr-1.5 h-3.5 w-3.5" />
|
<LineChart className="mr-1.5 h-3.5 w-3.5" />
|
||||||
View
|
View
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" asChild>
|
<Button size="sm" variant="outline" asChild>
|
||||||
{/* We link to the analysis page with a query param to trigger print/export */}
|
{/* We link to the analysis page with a query param to trigger print/export */}
|
||||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/analysis?export=true`}>
|
<Link
|
||||||
|
href={`/studies/${trial.studyId}/trials/${trial.id}/analysis?export=true`}
|
||||||
|
>
|
||||||
<Printer className="mr-1.5 h-3.5 w-3.5" />
|
<Printer className="mr-1.5 h-3.5 w-3.5" />
|
||||||
Export
|
Export
|
||||||
</Link>
|
</Link>
|
||||||
@@ -398,7 +403,11 @@ function ActionsCell({ row }: { row: { original: Trial } }) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{(trial.status === "scheduled" || trial.status === "failed") && (
|
{(trial.status === "scheduled" || trial.status === "failed") && (
|
||||||
<Button size="sm" variant="ghost" className="h-8 w-8 p-0 text-muted-foreground hover:text-red-600">
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-muted-foreground h-8 w-8 p-0 hover:text-red-600"
|
||||||
|
>
|
||||||
<Ban className="h-4 w-4" />
|
<Ban className="h-4 w-4" />
|
||||||
<span className="sr-only">Cancel</span>
|
<span className="sr-only">Cancel</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -3,7 +3,16 @@
|
|||||||
import { type ColumnDef } from "@tanstack/react-table";
|
import { type ColumnDef } from "@tanstack/react-table";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { CheckCircle, AlertTriangle, Info, Bot, User, Flag, MessageSquare, Activity } from "lucide-react";
|
import {
|
||||||
|
CheckCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
Info,
|
||||||
|
Bot,
|
||||||
|
User,
|
||||||
|
Flag,
|
||||||
|
MessageSquare,
|
||||||
|
Activity,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
// Define the shape of our data (matching schema)
|
// Define the shape of our data (matching schema)
|
||||||
export interface TrialEvent {
|
export interface TrialEvent {
|
||||||
@@ -41,16 +50,16 @@ export const eventsColumns = (startTime?: Date): ColumnDef<TrialEvent>[] => [
|
|||||||
accessorKey: "timestamp",
|
accessorKey: "timestamp",
|
||||||
size: 90,
|
size: 90,
|
||||||
meta: {
|
meta: {
|
||||||
style: { width: '90px', minWidth: '90px' }
|
style: { width: "90px", minWidth: "90px" },
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const date = new Date(row.original.timestamp);
|
const date = new Date(row.original.timestamp);
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col py-0.5">
|
<div className="flex flex-col py-0.5">
|
||||||
<span className="font-mono font-medium text-xs">
|
<span className="font-mono text-xs font-medium">
|
||||||
{formatRelativeTime(row.original.timestamp, startTime)}
|
{formatRelativeTime(row.original.timestamp, startTime)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-muted-foreground hidden group-hover:block">
|
<span className="text-muted-foreground hidden text-[10px] group-hover:block">
|
||||||
{date.toLocaleTimeString()}
|
{date.toLocaleTimeString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,7 +71,7 @@ export const eventsColumns = (startTime?: Date): ColumnDef<TrialEvent>[] => [
|
|||||||
header: "Event Type",
|
header: "Event Type",
|
||||||
size: 160,
|
size: 160,
|
||||||
meta: {
|
meta: {
|
||||||
style: { width: '160px', minWidth: '160px' }
|
style: { width: "160px", minWidth: "160px" },
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const type = row.getValue("eventType") as string;
|
const type = row.getValue("eventType") as string;
|
||||||
@@ -71,29 +80,41 @@ export const eventsColumns = (startTime?: Date): ColumnDef<TrialEvent>[] => [
|
|||||||
const isRobot = type.includes("robot");
|
const isRobot = type.includes("robot");
|
||||||
const isStep = type.includes("step");
|
const isStep = type.includes("step");
|
||||||
|
|
||||||
const isObservation = type.includes("annotation") || type.includes("note");
|
const isObservation =
|
||||||
|
type.includes("annotation") || type.includes("note");
|
||||||
const isJump = type.includes("jump"); // intervention_step_jump
|
const isJump = type.includes("jump"); // intervention_step_jump
|
||||||
const isActionComplete = type.includes("marked_complete");
|
const isActionComplete = type.includes("marked_complete");
|
||||||
|
|
||||||
let Icon = Activity;
|
let Icon = Activity;
|
||||||
if (isError) Icon = AlertTriangle;
|
if (isError) Icon = AlertTriangle;
|
||||||
else if (isIntervention || isJump) Icon = User; // Jumps are interventions
|
else if (isIntervention || isJump)
|
||||||
|
Icon = User; // Jumps are interventions
|
||||||
else if (isRobot) Icon = Bot;
|
else if (isRobot) Icon = Bot;
|
||||||
else if (isStep) Icon = Flag;
|
else if (isStep) Icon = Flag;
|
||||||
else if (isObservation) Icon = MessageSquare;
|
else if (isObservation) Icon = MessageSquare;
|
||||||
else if (type.includes("completed") || isActionComplete) Icon = CheckCircle;
|
else if (type.includes("completed") || isActionComplete)
|
||||||
|
Icon = CheckCircle;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center py-0.5">
|
<div className="flex items-center py-0.5">
|
||||||
<Badge variant="outline" className={cn(
|
<Badge
|
||||||
"capitalize font-medium flex w-fit items-center gap-1.5 px-2 py-0.5 text-[10px]",
|
variant="outline"
|
||||||
isError && "border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400",
|
className={cn(
|
||||||
(isIntervention || isJump) && "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/50 dark:bg-orange-900/20 dark:text-orange-400",
|
"flex w-fit items-center gap-1.5 px-2 py-0.5 text-[10px] font-medium capitalize",
|
||||||
isRobot && "border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-900/50 dark:bg-purple-900/20 dark:text-purple-400",
|
isError &&
|
||||||
isStep && "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-400",
|
"border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400",
|
||||||
isObservation && "border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-900/50 dark:bg-yellow-900/20 dark:text-yellow-400",
|
(isIntervention || isJump) &&
|
||||||
isActionComplete && "border-green-200 bg-green-50 text-green-700 dark:border-green-900/50 dark:bg-green-900/20 dark:text-green-400"
|
"border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/50 dark:bg-orange-900/20 dark:text-orange-400",
|
||||||
)}>
|
isRobot &&
|
||||||
|
"border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-900/50 dark:bg-purple-900/20 dark:text-purple-400",
|
||||||
|
isStep &&
|
||||||
|
"border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-400",
|
||||||
|
isObservation &&
|
||||||
|
"border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-900/50 dark:bg-yellow-900/20 dark:text-yellow-400",
|
||||||
|
isActionComplete &&
|
||||||
|
"border-green-200 bg-green-50 text-green-700 dark:border-green-900/50 dark:bg-green-900/20 dark:text-green-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Icon className="h-3 w-3" />
|
<Icon className="h-3 w-3" />
|
||||||
{type.replace(/_/g, " ")}
|
{type.replace(/_/g, " ")}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -113,36 +134,65 @@ export const eventsColumns = (startTime?: Date): ColumnDef<TrialEvent>[] => [
|
|||||||
|
|
||||||
// Wrapper for density and alignment
|
// Wrapper for density and alignment
|
||||||
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
<div className="py-0.5 min-w-[300px] whitespace-normal break-words text-xs leading-normal">
|
<div className="min-w-[300px] py-0.5 text-xs leading-normal break-words whitespace-normal">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!data || Object.keys(data).length === 0) return <Wrapper><span className="text-muted-foreground">-</span></Wrapper>;
|
if (!data || Object.keys(data).length === 0)
|
||||||
|
return (
|
||||||
|
<Wrapper>
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
|
||||||
// Smart Formatting
|
// Smart Formatting
|
||||||
if (type.includes("jump")) {
|
if (type.includes("jump")) {
|
||||||
return (
|
return (
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
Jumped to step <strong>{data.stepName || (data.toIndex !== undefined ? data.toIndex + 1 : "?")}</strong>
|
Jumped to step{" "}
|
||||||
|
<strong>
|
||||||
|
{data.stepName ||
|
||||||
|
(data.toIndex !== undefined ? data.toIndex + 1 : "?")}
|
||||||
|
</strong>
|
||||||
<span className="text-muted-foreground ml-1">(Manual)</span>
|
<span className="text-muted-foreground ml-1">(Manual)</span>
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (type.includes("skipped")) {
|
if (type.includes("skipped")) {
|
||||||
return <Wrapper><span className="text-orange-600 dark:text-orange-400">Skipped: {data.actionId}</span></Wrapper>;
|
return (
|
||||||
|
<Wrapper>
|
||||||
|
<span className="text-orange-600 dark:text-orange-400">
|
||||||
|
Skipped: {data.actionId}
|
||||||
|
</span>
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (type.includes("marked_complete")) {
|
if (type.includes("marked_complete")) {
|
||||||
return <Wrapper><span className="text-green-600 dark:text-green-400">Manually marked complete</span></Wrapper>;
|
return (
|
||||||
|
<Wrapper>
|
||||||
|
<span className="text-green-600 dark:text-green-400">
|
||||||
|
Manually marked complete
|
||||||
|
</span>
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (type.includes("annotation") || type.includes("note")) {
|
if (type.includes("annotation") || type.includes("note")) {
|
||||||
return <Wrapper><span className="italic text-foreground/80">{data.description || data.note || data.message || "No content"}</span></Wrapper>;
|
return (
|
||||||
|
<Wrapper>
|
||||||
|
<span className="text-foreground/80 italic">
|
||||||
|
{data.description || data.note || data.message || "No content"}
|
||||||
|
</span>
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
<code className="font-mono text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded border inline-block max-w-full truncate align-middle">
|
<code className="text-muted-foreground bg-muted/50 inline-block max-w-full truncate rounded border px-1.5 py-0.5 align-middle font-mono">
|
||||||
{JSON.stringify(data).replace(/[{""}]/g, " ").trim()}
|
{JSON.stringify(data)
|
||||||
|
.replace(/[{""}]/g, " ")
|
||||||
|
.trim()}
|
||||||
</code>
|
</code>
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,11 +7,17 @@ import {
|
|||||||
TableCell,
|
TableCell,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow
|
TableRow,
|
||||||
} from "~/components/ui/table";
|
} from "~/components/ui/table";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
import { usePlayback } from "../playback/PlaybackContext";
|
import { usePlayback } from "../playback/PlaybackContext";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import {
|
import {
|
||||||
@@ -22,7 +28,7 @@ import {
|
|||||||
Flag,
|
Flag,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Activity,
|
Activity,
|
||||||
Video
|
Video,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { type TrialEvent } from "./events-columns";
|
import { type TrialEvent } from "./events-columns";
|
||||||
|
|
||||||
@@ -58,9 +64,12 @@ export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
|
|||||||
|
|
||||||
// Enhanced filtering logic
|
// Enhanced filtering logic
|
||||||
const filteredData = React.useMemo(() => {
|
const filteredData = React.useMemo(() => {
|
||||||
return data.filter(event => {
|
return data.filter((event) => {
|
||||||
// Type filter
|
// Type filter
|
||||||
if (eventTypeFilter !== "all" && !event.eventType.includes(eventTypeFilter)) {
|
if (
|
||||||
|
eventTypeFilter !== "all" &&
|
||||||
|
!event.eventType.includes(eventTypeFilter)
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +78,9 @@ export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
|
|||||||
const searchLower = globalFilter.toLowerCase();
|
const searchLower = globalFilter.toLowerCase();
|
||||||
const typeMatch = event.eventType.toLowerCase().includes(searchLower);
|
const typeMatch = event.eventType.toLowerCase().includes(searchLower);
|
||||||
// Safe JSON stringify check
|
// Safe JSON stringify check
|
||||||
const dataString = event.data ? JSON.stringify(event.data).toLowerCase() : "";
|
const dataString = event.data
|
||||||
|
? JSON.stringify(event.data).toLowerCase()
|
||||||
|
: "";
|
||||||
const dataMatch = dataString.includes(searchLower);
|
const dataMatch = dataString.includes(searchLower);
|
||||||
|
|
||||||
return typeMatch || dataMatch;
|
return typeMatch || dataMatch;
|
||||||
@@ -92,7 +103,9 @@ export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
|
|||||||
return null;
|
return null;
|
||||||
}, [events, currentEventIndex]);
|
}, [events, currentEventIndex]);
|
||||||
|
|
||||||
const rowRefs = React.useRef<{ [key: string]: HTMLTableRowElement | null }>({});
|
const rowRefs = React.useRef<{ [key: string]: HTMLTableRowElement | null }>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (activeEventId && rowRefs.current[activeEventId]) {
|
if (activeEventId && rowRefs.current[activeEventId]) {
|
||||||
@@ -137,15 +150,18 @@ export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground mr-2">
|
<div className="text-muted-foreground mr-2 text-xs">
|
||||||
{filteredData.length} events
|
{filteredData.length} events
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="tour-analytics-table" className="rounded-md border bg-background">
|
<div
|
||||||
|
id="tour-analytics-table"
|
||||||
|
className="bg-background rounded-md border"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<Table className="w-full">
|
<Table className="w-full">
|
||||||
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
|
<TableHeader className="bg-background sticky top-0 z-10 shadow-sm">
|
||||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||||
<TableHead className="w-[100px]">Time</TableHead>
|
<TableHead className="w-[100px]">Time</TableHead>
|
||||||
<TableHead className="w-[180px]">Event Type</TableHead>
|
<TableHead className="w-[180px]">Event Type</TableHead>
|
||||||
@@ -169,7 +185,8 @@ export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
|
|||||||
const isIntervention = type.includes("intervention");
|
const isIntervention = type.includes("intervention");
|
||||||
const isRobot = type.includes("robot");
|
const isRobot = type.includes("robot");
|
||||||
const isStep = type.includes("step");
|
const isStep = type.includes("step");
|
||||||
const isObservation = type.includes("annotation") || type.includes("note");
|
const isObservation =
|
||||||
|
type.includes("annotation") || type.includes("note");
|
||||||
const isJump = type.includes("jump");
|
const isJump = type.includes("jump");
|
||||||
const isActionComplete = type.includes("marked_complete");
|
const isActionComplete = type.includes("marked_complete");
|
||||||
const isCamera = type.includes("camera");
|
const isCamera = type.includes("camera");
|
||||||
@@ -181,7 +198,8 @@ export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
|
|||||||
else if (isStep) Icon = Flag;
|
else if (isStep) Icon = Flag;
|
||||||
else if (isObservation) Icon = MessageSquare;
|
else if (isObservation) Icon = MessageSquare;
|
||||||
else if (isCamera) Icon = Video;
|
else if (isCamera) Icon = Video;
|
||||||
else if (type.includes("completed") || isActionComplete) Icon = CheckCircle;
|
else if (type.includes("completed") || isActionComplete)
|
||||||
|
Icon = CheckCircle;
|
||||||
|
|
||||||
// Details Logic
|
// Details Logic
|
||||||
let detailsContent;
|
let detailsContent;
|
||||||
@@ -190,48 +208,130 @@ export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
|
|||||||
|
|
||||||
if (type.includes("jump")) {
|
if (type.includes("jump")) {
|
||||||
detailsContent = (
|
detailsContent = (
|
||||||
<>Jumped to step <strong>{d?.stepName || (d?.toIndex !== undefined ? d.toIndex + 1 : "?")}</strong> <span className="text-muted-foreground ml-1">(Manual)</span></>
|
<>
|
||||||
|
Jumped to step{" "}
|
||||||
|
<strong>
|
||||||
|
{d?.stepName ||
|
||||||
|
(d?.toIndex !== undefined ? d.toIndex + 1 : "?")}
|
||||||
|
</strong>{" "}
|
||||||
|
<span className="text-muted-foreground ml-1">
|
||||||
|
(Manual)
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
} else if (type.includes("skipped")) {
|
} else if (type.includes("skipped")) {
|
||||||
detailsContent = <span className="text-orange-600 dark:text-orange-400">Skipped: {d?.actionId}</span>;
|
detailsContent = (
|
||||||
|
<span className="text-orange-600 dark:text-orange-400">
|
||||||
|
Skipped: {d?.actionId}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
} else if (type.includes("marked_complete")) {
|
} else if (type.includes("marked_complete")) {
|
||||||
detailsContent = <span className="text-green-600 dark:text-green-400">Manually marked complete</span>;
|
detailsContent = (
|
||||||
} else if (type.includes("annotation") || type.includes("note")) {
|
<span className="text-green-600 dark:text-green-400">
|
||||||
detailsContent = <span className="italic text-foreground/80">{d?.description || d?.note || d?.message || "No content"}</span>;
|
Manually marked complete
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
type.includes("annotation") ||
|
||||||
|
type.includes("note")
|
||||||
|
) {
|
||||||
|
detailsContent = (
|
||||||
|
<span className="text-foreground/80 italic">
|
||||||
|
{d?.description ||
|
||||||
|
d?.note ||
|
||||||
|
d?.message ||
|
||||||
|
"No content"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
} else if (type.includes("step")) {
|
} else if (type.includes("step")) {
|
||||||
detailsContent = <span>Step: <strong>{d?.stepName || d?.name || (d?.index !== undefined ? `Index ${d.index}` : "")}</strong></span>;
|
detailsContent = (
|
||||||
|
<span>
|
||||||
|
Step:{" "}
|
||||||
|
<strong>
|
||||||
|
{d?.stepName ||
|
||||||
|
d?.name ||
|
||||||
|
(d?.index !== undefined ? `Index ${d.index}` : "")}
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
} else if (type.includes("action_executed")) {
|
} else if (type.includes("action_executed")) {
|
||||||
const name = d?.actionName || d?.actionId;
|
const name = d?.actionName || d?.actionId;
|
||||||
const meta = d?.actionType ? `(${d.actionType})` : d?.type ? `(${d.type})` : "";
|
const meta = d?.actionType
|
||||||
detailsContent = <span>Executed: <strong>{name}</strong> <span className="text-muted-foreground text-[10px] ml-1">{meta}</span></span>;
|
? `(${d.actionType})`
|
||||||
} else if (type.includes("robot") || type.includes("say") || type.includes("speech")) {
|
: d?.type
|
||||||
|
? `(${d.type})`
|
||||||
|
: "";
|
||||||
|
detailsContent = (
|
||||||
|
<span>
|
||||||
|
Executed: <strong>{name}</strong>{" "}
|
||||||
|
<span className="text-muted-foreground ml-1 text-[10px]">
|
||||||
|
{meta}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
type.includes("robot") ||
|
||||||
|
type.includes("say") ||
|
||||||
|
type.includes("speech")
|
||||||
|
) {
|
||||||
const text = d?.text || d?.message || d?.data?.text;
|
const text = d?.text || d?.message || d?.data?.text;
|
||||||
detailsContent = (
|
detailsContent = (
|
||||||
<span>
|
<span>
|
||||||
Robot: <strong>{d?.command || d?.type || "Action"}</strong>
|
Robot:{" "}
|
||||||
{text && <span className="text-muted-foreground ml-1">"{text}"</span>}
|
<strong>{d?.command || d?.type || "Action"}</strong>
|
||||||
|
{text && (
|
||||||
|
<span className="text-muted-foreground ml-1">
|
||||||
|
"{text}"
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
} else if (type.includes("intervention")) {
|
} else if (type.includes("intervention")) {
|
||||||
detailsContent = <span className="text-orange-600 dark:text-orange-400">Intervention: {d?.type || "Manual Action"}</span>;
|
detailsContent = (
|
||||||
|
<span className="text-orange-600 dark:text-orange-400">
|
||||||
|
Intervention: {d?.type || "Manual Action"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
} else if (type === "trial_started") {
|
} else if (type === "trial_started") {
|
||||||
detailsContent = <span className="text-green-600 font-medium">Trial Started</span>;
|
detailsContent = (
|
||||||
|
<span className="font-medium text-green-600">
|
||||||
|
Trial Started
|
||||||
|
</span>
|
||||||
|
);
|
||||||
} else if (type === "trial_completed") {
|
} else if (type === "trial_completed") {
|
||||||
detailsContent = <span className="text-blue-600 font-medium">Trial Completed</span>;
|
detailsContent = (
|
||||||
|
<span className="font-medium text-blue-600">
|
||||||
|
Trial Completed
|
||||||
|
</span>
|
||||||
|
);
|
||||||
} else if (type === "trial_paused") {
|
} else if (type === "trial_paused") {
|
||||||
detailsContent = <span className="text-yellow-600 font-medium">Trial Paused</span>;
|
detailsContent = (
|
||||||
|
<span className="font-medium text-yellow-600">
|
||||||
|
Trial Paused
|
||||||
|
</span>
|
||||||
|
);
|
||||||
} else if (isCamera) {
|
} else if (isCamera) {
|
||||||
detailsContent = <span className="font-medium text-teal-600 dark:text-teal-400">{type === "camera_started" ? "Recording Started" : type === "camera_stopped" ? "Recording Stopped" : "Camera Event"}</span>;
|
detailsContent = (
|
||||||
|
<span className="font-medium text-teal-600 dark:text-teal-400">
|
||||||
|
{type === "camera_started"
|
||||||
|
? "Recording Started"
|
||||||
|
: type === "camera_stopped"
|
||||||
|
? "Recording Stopped"
|
||||||
|
: "Camera Event"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// Default
|
// Default
|
||||||
if (d && Object.keys(d).length > 0) {
|
if (d && Object.keys(d).length > 0) {
|
||||||
detailsContent = (
|
detailsContent = (
|
||||||
<code className="font-mono text-muted-foreground bg-muted/50 px-1 py-0.5 rounded border inline-block max-w-full truncate align-middle text-[10px]">
|
<code className="text-muted-foreground bg-muted/50 inline-block max-w-full truncate rounded border px-1 py-0.5 align-middle font-mono text-[10px]">
|
||||||
{JSON.stringify(d).replace(/[{"}]/g, " ").trim()}
|
{JSON.stringify(d).replace(/[{"}]/g, " ").trim()}
|
||||||
</code>
|
</code>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
detailsContent = <span className="text-muted-foreground text-xs">-</span>;
|
detailsContent = (
|
||||||
|
<span className="text-muted-foreground text-xs">-</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,42 +344,52 @@ export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
|
|||||||
if (event.id) rowRefs.current[event.id] = el;
|
if (event.id) rowRefs.current[event.id] = el;
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-pointer h-auto border-l-2 border-transparent transition-colors",
|
"h-auto cursor-pointer border-l-2 border-transparent transition-colors",
|
||||||
isActive
|
isActive
|
||||||
? "bg-muted border-l-primary"
|
? "bg-muted border-l-primary"
|
||||||
: "hover:bg-muted/50"
|
: "hover:bg-muted/50",
|
||||||
)}
|
)}
|
||||||
onClick={() => handleRowClick(event)}
|
onClick={() => handleRowClick(event)}
|
||||||
>
|
>
|
||||||
<TableCell className="py-1 align-top w-[100px]">
|
<TableCell className="w-[100px] py-1 align-top">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-mono font-medium text-xs">
|
<span className="font-mono text-xs font-medium">
|
||||||
{formatRelativeTime(event.timestamp, startTime)}
|
{formatRelativeTime(event.timestamp, startTime)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-muted-foreground hidden group-hover:block">
|
<span className="text-muted-foreground hidden text-[10px] group-hover:block">
|
||||||
{new Date(event.timestamp).toLocaleTimeString()}
|
{new Date(event.timestamp).toLocaleTimeString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="py-1 align-top w-[180px]">
|
<TableCell className="w-[180px] py-1 align-top">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Badge variant="outline" className={cn(
|
<Badge
|
||||||
"capitalize font-medium flex w-fit items-center gap-1.5 px-2 py-0.5 text-[10px]",
|
variant="outline"
|
||||||
isError && "border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400",
|
className={cn(
|
||||||
(isIntervention || isJump) && "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/50 dark:bg-orange-900/20 dark:text-orange-400",
|
"flex w-fit items-center gap-1.5 px-2 py-0.5 text-[10px] font-medium capitalize",
|
||||||
isRobot && "border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-900/50 dark:bg-purple-900/20 dark:text-purple-400",
|
isError &&
|
||||||
isCamera && "border-teal-200 bg-teal-50 text-teal-700 dark:border-teal-900/50 dark:bg-teal-900/20 dark:text-teal-400",
|
"border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400",
|
||||||
isStep && "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-400",
|
(isIntervention || isJump) &&
|
||||||
isObservation && "border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-900/50 dark:bg-yellow-900/20 dark:text-yellow-400",
|
"border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/50 dark:bg-orange-900/20 dark:text-orange-400",
|
||||||
isActionComplete && "border-green-200 bg-green-50 text-green-700 dark:border-green-900/50 dark:bg-green-900/20 dark:text-green-400"
|
isRobot &&
|
||||||
)}>
|
"border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-900/50 dark:bg-purple-900/20 dark:text-purple-400",
|
||||||
|
isCamera &&
|
||||||
|
"border-teal-200 bg-teal-50 text-teal-700 dark:border-teal-900/50 dark:bg-teal-900/20 dark:text-teal-400",
|
||||||
|
isStep &&
|
||||||
|
"border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-400",
|
||||||
|
isObservation &&
|
||||||
|
"border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-900/50 dark:bg-yellow-900/20 dark:text-yellow-400",
|
||||||
|
isActionComplete &&
|
||||||
|
"border-green-200 bg-green-50 text-green-700 dark:border-green-900/50 dark:bg-green-900/20 dark:text-green-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Icon className="h-3 w-3" />
|
<Icon className="h-3 w-3" />
|
||||||
{type.replace(/_/g, " ")}
|
{type.replace(/_/g, " ")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="py-1 align-top w-auto">
|
<TableCell className="w-auto py-1 align-top">
|
||||||
<div className="text-xs break-words whitespace-normal leading-normal min-w-0">
|
<div className="min-w-0 text-xs leading-normal break-words whitespace-normal">
|
||||||
{detailsContent}
|
{detailsContent}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ import {
|
|||||||
Circle,
|
Circle,
|
||||||
Bot,
|
Bot,
|
||||||
User,
|
User,
|
||||||
Activity
|
Activity,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger
|
TooltipTrigger,
|
||||||
} from "~/components/ui/tooltip";
|
} from "~/components/ui/tooltip";
|
||||||
|
|
||||||
function formatTime(seconds: number) {
|
function formatTime(seconds: number) {
|
||||||
@@ -33,12 +33,15 @@ export function EventTimeline() {
|
|||||||
currentTime,
|
currentTime,
|
||||||
events,
|
events,
|
||||||
seekTo,
|
seekTo,
|
||||||
startTime: contextStartTime
|
startTime: contextStartTime,
|
||||||
} = usePlayback();
|
} = usePlayback();
|
||||||
|
|
||||||
// Determine effective time range
|
// Determine effective time range
|
||||||
const sortedEvents = useMemo(() => {
|
const sortedEvents = useMemo(() => {
|
||||||
return [...events].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
return [...events].sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||||
|
);
|
||||||
}, [events]);
|
}, [events]);
|
||||||
|
|
||||||
const startTime = useMemo(() => {
|
const startTime = useMemo(() => {
|
||||||
@@ -68,62 +71,86 @@ export function EventTimeline() {
|
|||||||
seekTo(pct * (effectiveDuration / 1000));
|
seekTo(pct * (effectiveDuration / 1000));
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentProgress = (currentTime * 1000 / effectiveDuration) * 100;
|
const currentProgress = ((currentTime * 1000) / effectiveDuration) * 100;
|
||||||
|
|
||||||
// Generate ticks
|
// Generate ticks
|
||||||
const ticks = useMemo(() => {
|
const ticks = useMemo(() => {
|
||||||
const count = 10;
|
const count = 10;
|
||||||
return Array.from({ length: count + 1 }).map((_, i) => ({
|
return Array.from({ length: count + 1 }).map((_, i) => ({
|
||||||
pct: (i / count) * 100,
|
pct: (i / count) * 100,
|
||||||
label: formatTime((effectiveDuration / 1000) * (i / count))
|
label: formatTime((effectiveDuration / 1000) * (i / count)),
|
||||||
}));
|
}));
|
||||||
}, [effectiveDuration]);
|
}, [effectiveDuration]);
|
||||||
|
|
||||||
const getEventIcon = (type: string) => {
|
const getEventIcon = (type: string) => {
|
||||||
if (type.includes("intervention") || type.includes("wizard") || type.includes("jump")) return <User className="h-4 w-4" />;
|
if (
|
||||||
if (type.includes("robot") || type.includes("action")) return <Bot className="h-4 w-4" />;
|
type.includes("intervention") ||
|
||||||
|
type.includes("wizard") ||
|
||||||
|
type.includes("jump")
|
||||||
|
)
|
||||||
|
return <User className="h-4 w-4" />;
|
||||||
|
if (type.includes("robot") || type.includes("action"))
|
||||||
|
return <Bot className="h-4 w-4" />;
|
||||||
if (type.includes("completed")) return <CheckCircle className="h-4 w-4" />;
|
if (type.includes("completed")) return <CheckCircle className="h-4 w-4" />;
|
||||||
if (type.includes("start")) return <Flag className="h-4 w-4" />;
|
if (type.includes("start")) return <Flag className="h-4 w-4" />;
|
||||||
if (type.includes("note") || type.includes("annotation")) return <MessageSquare className="h-4 w-4" />;
|
if (type.includes("note") || type.includes("annotation"))
|
||||||
|
return <MessageSquare className="h-4 w-4" />;
|
||||||
if (type.includes("error")) return <AlertTriangle className="h-4 w-4" />;
|
if (type.includes("error")) return <AlertTriangle className="h-4 w-4" />;
|
||||||
return <Activity className="h-4 w-4" />;
|
return <Activity className="h-4 w-4" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getEventColor = (type: string) => {
|
const getEventColor = (type: string) => {
|
||||||
if (type.includes("intervention") || type.includes("wizard") || type.includes("jump")) return "bg-orange-100 text-orange-600 border-orange-200";
|
if (
|
||||||
if (type.includes("robot") || type.includes("action")) return "bg-purple-100 text-purple-600 border-purple-200";
|
type.includes("intervention") ||
|
||||||
if (type.includes("completed")) return "bg-green-100 text-green-600 border-green-200";
|
type.includes("wizard") ||
|
||||||
if (type.includes("start")) return "bg-blue-100 text-blue-600 border-blue-200";
|
type.includes("jump")
|
||||||
if (type.includes("note") || type.includes("annotation")) return "bg-yellow-100 text-yellow-600 border-yellow-200";
|
)
|
||||||
|
return "bg-orange-100 text-orange-600 border-orange-200";
|
||||||
|
if (type.includes("robot") || type.includes("action"))
|
||||||
|
return "bg-purple-100 text-purple-600 border-purple-200";
|
||||||
|
if (type.includes("completed"))
|
||||||
|
return "bg-green-100 text-green-600 border-green-200";
|
||||||
|
if (type.includes("start"))
|
||||||
|
return "bg-blue-100 text-blue-600 border-blue-200";
|
||||||
|
if (type.includes("note") || type.includes("annotation"))
|
||||||
|
return "bg-yellow-100 text-yellow-600 border-yellow-200";
|
||||||
if (type.includes("error")) return "bg-red-100 text-red-600 border-red-200";
|
if (type.includes("error")) return "bg-red-100 text-red-600 border-red-200";
|
||||||
return "bg-slate-100 text-slate-600 border-slate-200";
|
return "bg-slate-100 text-slate-600 border-slate-200";
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-28 flex flex-col justify-center px-8 select-none">
|
<div className="flex h-28 w-full flex-col justify-center px-8 select-none">
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
{/* Main Interactive Area */}
|
{/* Main Interactive Area */}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="relative w-full h-16 flex items-center cursor-pointer group"
|
className="group relative flex h-16 w-full cursor-pointer items-center"
|
||||||
onClick={handleSeek}
|
onClick={handleSeek}
|
||||||
>
|
>
|
||||||
{/* The Timeline Line (Horizontal) */}
|
{/* The Timeline Line (Horizontal) */}
|
||||||
<div className="absolute left-0 right-0 h-0.5 top-1/2 -mt-px bg-border group-hover:bg-border/80 transition-colors" />
|
<div className="bg-border group-hover:bg-border/80 absolute top-1/2 right-0 left-0 -mt-px h-0.5 transition-colors" />
|
||||||
|
|
||||||
{/* Progress Fill */}
|
{/* Progress Fill */}
|
||||||
<div
|
<div
|
||||||
className="absolute left-0 h-0.5 bg-primary/30 pointer-events-none"
|
className="bg-primary/30 pointer-events-none absolute left-0 h-0.5"
|
||||||
style={{ width: `${currentProgress}%`, top: '50%', marginTop: '-1px' }}
|
style={{
|
||||||
|
width: `${currentProgress}%`,
|
||||||
|
top: "50%",
|
||||||
|
marginTop: "-1px",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Playhead (Scanner) */}
|
{/* Playhead (Scanner) */}
|
||||||
<div
|
<div
|
||||||
className="absolute h-16 w-px bg-red-500 z-30 pointer-events-none transition-all duration-75"
|
className="pointer-events-none absolute z-30 h-16 w-px bg-red-500 transition-all duration-75"
|
||||||
style={{ left: `${currentProgress}%`, top: '50%', transform: 'translateY(-50%)' }}
|
style={{
|
||||||
|
left: `${currentProgress}%`,
|
||||||
|
top: "50%",
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Knob */}
|
{/* Knob */}
|
||||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-3 h-3 bg-red-500 rounded-full shadow border border-white" />
|
<div className="absolute top-1/2 left-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white bg-red-500 shadow" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Events (Avatars/Dots) */}
|
{/* Events (Avatars/Dots) */}
|
||||||
@@ -136,20 +163,30 @@ export function EventTimeline() {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const d = data as any;
|
const d = data as any;
|
||||||
|
|
||||||
if (eventType.includes("jump")) return `Jumped to step ${d?.stepName || d?.toIndex + 1 || "?"} (Manual)`;
|
if (eventType.includes("jump"))
|
||||||
if (eventType.includes("skipped")) return `Skipped: ${d?.actionId}`;
|
return `Jumped to step ${d?.stepName || d?.toIndex + 1 || "?"} (Manual)`;
|
||||||
if (eventType.includes("marked_complete")) return "Manually marked complete";
|
if (eventType.includes("skipped"))
|
||||||
if (eventType.includes("annotation") || eventType.includes("note")) return d?.description || d?.note || d?.message || "Note";
|
return `Skipped: ${d?.actionId}`;
|
||||||
|
if (eventType.includes("marked_complete"))
|
||||||
|
return "Manually marked complete";
|
||||||
|
if (
|
||||||
|
eventType.includes("annotation") ||
|
||||||
|
eventType.includes("note")
|
||||||
|
)
|
||||||
|
return d?.description || d?.note || d?.message || "Note";
|
||||||
|
|
||||||
if (!d || Object.keys(d).length === 0) return null;
|
if (!d || Object.keys(d).length === 0) return null;
|
||||||
return JSON.stringify(d).slice(0, 100).replace(/[{""}]/g, " ").trim();
|
return JSON.stringify(d)
|
||||||
|
.slice(0, 100)
|
||||||
|
.replace(/[{""}]/g, " ")
|
||||||
|
.trim();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip key={i}>
|
<Tooltip key={i}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div
|
||||||
className="absolute z-20 top-1/2 left-0 transform -translate-x-1/2 -translate-y-1/2 flex flex-col items-center group/event cursor-pointer p-2"
|
className="group/event absolute top-1/2 left-0 z-20 flex -translate-x-1/2 -translate-y-1/2 transform cursor-pointer flex-col items-center p-2"
|
||||||
style={{ left: `${pct}%` }}
|
style={{ left: `${pct}%` }}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -159,21 +196,25 @@ export function EventTimeline() {
|
|||||||
seekTo(Math.max(0, seekSeconds));
|
seekTo(Math.max(0, seekSeconds));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className={cn(
|
<div
|
||||||
"flex h-7 w-7 items-center justify-center rounded-full border shadow-sm transition-transform hover:scale-125 hover:z-50 bg-background relative z-20",
|
className={cn(
|
||||||
getEventColor(event.eventType)
|
"bg-background relative z-20 flex h-7 w-7 items-center justify-center rounded-full border shadow-sm transition-transform hover:z-50 hover:scale-125",
|
||||||
)}>
|
getEventColor(event.eventType),
|
||||||
|
)}
|
||||||
|
>
|
||||||
{getEventIcon(event.eventType)}
|
{getEventIcon(event.eventType)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
<div className="text-xs font-semibold uppercase tracking-wider mb-0.5">{event.eventType.replace(/_/g, " ")}</div>
|
<div className="mb-0.5 text-xs font-semibold tracking-wider uppercase">
|
||||||
<div className="text-[10px] font-mono opacity-70 mb-1">
|
{event.eventType.replace(/_/g, " ")}
|
||||||
|
</div>
|
||||||
|
<div className="mb-1 font-mono text-[10px] opacity-70">
|
||||||
{new Date(event.timestamp).toLocaleTimeString()}
|
{new Date(event.timestamp).toLocaleTimeString()}
|
||||||
</div>
|
</div>
|
||||||
{!!details && (
|
{!!details && (
|
||||||
<div className="bg-muted/50 p-1.5 rounded text-[10px] max-w-[220px] break-words whitespace-normal border">
|
<div className="bg-muted/50 max-w-[220px] rounded border p-1.5 text-[10px] break-words whitespace-normal">
|
||||||
{details}
|
{details}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -186,11 +227,11 @@ export function EventTimeline() {
|
|||||||
{ticks.map((tick, i) => (
|
{ticks.map((tick, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="absolute top-10 text-[10px] font-mono text-muted-foreground transform -translate-x-1/2 pointer-events-none flex flex-col items-center"
|
className="text-muted-foreground pointer-events-none absolute top-10 flex -translate-x-1/2 transform flex-col items-center font-mono text-[10px]"
|
||||||
style={{ left: `${tick.pct}%` }}
|
style={{ left: `${tick.pct}%` }}
|
||||||
>
|
>
|
||||||
{/* Tick Mark */}
|
{/* Tick Mark */}
|
||||||
<div className="w-px h-2 bg-border mb-1" />
|
<div className="bg-border mb-1 h-2 w-px" />
|
||||||
{tick.label}
|
{tick.label}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { createContext, useContext, useEffect, useRef, useState } from "react";
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
interface TrialEvent {
|
interface TrialEvent {
|
||||||
eventType: string;
|
eventType: string;
|
||||||
@@ -48,9 +54,17 @@ interface PlaybackProviderProps {
|
|||||||
endTime?: Date;
|
endTime?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlaybackProvider({ children, events = [], startTime, endTime }: PlaybackProviderProps) {
|
export function PlaybackProvider({
|
||||||
|
children,
|
||||||
|
events = [],
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
}: PlaybackProviderProps) {
|
||||||
const trialDuration = React.useMemo(() => {
|
const trialDuration = React.useMemo(() => {
|
||||||
if (startTime && endTime) return (new Date(endTime).getTime() - new Date(startTime).getTime()) / 1000;
|
if (startTime && endTime)
|
||||||
|
return (
|
||||||
|
(new Date(endTime).getTime() - new Date(startTime).getTime()) / 1000
|
||||||
|
);
|
||||||
return 0;
|
return 0;
|
||||||
}, [startTime, endTime]);
|
}, [startTime, endTime]);
|
||||||
|
|
||||||
@@ -91,7 +105,7 @@ export function PlaybackProvider({ children, events = [], startTime, endTime }:
|
|||||||
// Actions
|
// Actions
|
||||||
const play = () => setIsPlaying(true);
|
const play = () => setIsPlaying(true);
|
||||||
const pause = () => setIsPlaying(false);
|
const pause = () => setIsPlaying(false);
|
||||||
const togglePlay = () => setIsPlaying(p => !p);
|
const togglePlay = () => setIsPlaying((p) => !p);
|
||||||
|
|
||||||
const seekTo = (time: number) => {
|
const seekTo = (time: number) => {
|
||||||
setCurrentTime(time);
|
setCurrentTime(time);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function PlaybackPlayer({ src }: PlaybackPlayerProps) {
|
|||||||
setDuration,
|
setDuration,
|
||||||
togglePlay,
|
togglePlay,
|
||||||
play,
|
play,
|
||||||
pause
|
pause,
|
||||||
} = usePlayback();
|
} = usePlayback();
|
||||||
|
|
||||||
const [isBuffering, setIsBuffering] = React.useState(true);
|
const [isBuffering, setIsBuffering] = React.useState(true);
|
||||||
@@ -79,13 +79,13 @@ export function PlaybackPlayer({ src }: PlaybackPlayerProps) {
|
|||||||
const handleEnded = () => pause();
|
const handleEnded = () => pause();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group relative rounded-lg overflow-hidden border bg-black shadow-sm">
|
<div className="group relative overflow-hidden rounded-lg border bg-black shadow-sm">
|
||||||
<AspectRatio ratio={16 / 9}>
|
<AspectRatio ratio={16 / 9}>
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={src}
|
src={src}
|
||||||
muted={muted}
|
muted={muted}
|
||||||
className="w-full h-full object-contain"
|
className="h-full w-full object-contain"
|
||||||
onTimeUpdate={handleTimeUpdate}
|
onTimeUpdate={handleTimeUpdate}
|
||||||
onLoadedMetadata={handleLoadedMetadata}
|
onLoadedMetadata={handleLoadedMetadata}
|
||||||
onWaiting={handleWaiting}
|
onWaiting={handleWaiting}
|
||||||
@@ -94,7 +94,10 @@ export function PlaybackPlayer({ src }: PlaybackPlayerProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Overlay Controls (Visible on Hover/Pause) */}
|
{/* Overlay Controls (Visible on Hover/Pause) */}
|
||||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-4 opacity-0 transition-opacity group-hover:opacity-100 data-[paused=true]:opacity-100" data-paused={!isPlaying}>
|
<div
|
||||||
|
className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-4 opacity-0 transition-opacity group-hover:opacity-100 data-[paused=true]:opacity-100"
|
||||||
|
data-paused={!isPlaying}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -102,7 +105,11 @@ export function PlaybackPlayer({ src }: PlaybackPlayerProps) {
|
|||||||
className="text-white hover:bg-white/20"
|
className="text-white hover:bg-white/20"
|
||||||
onClick={togglePlay}
|
onClick={togglePlay}
|
||||||
>
|
>
|
||||||
{isPlaying ? <Pause className="h-6 w-6" /> : <Play className="h-6 w-6 fill-current" />}
|
{isPlaying ? (
|
||||||
|
<Pause className="h-6 w-6" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-6 w-6 fill-current" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -121,8 +128,9 @@ export function PlaybackPlayer({ src }: PlaybackPlayerProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-xs font-mono text-white/90">
|
<div className="font-mono text-xs text-white/90">
|
||||||
{formatTime(currentTime)} / {formatTime(videoRef.current?.duration || 0)}
|
{formatTime(currentTime)} /{" "}
|
||||||
|
{formatTime(videoRef.current?.duration || 0)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -131,13 +139,17 @@ export function PlaybackPlayer({ src }: PlaybackPlayerProps) {
|
|||||||
className="text-white hover:bg-white/20"
|
className="text-white hover:bg-white/20"
|
||||||
onClick={() => setMuted(!muted)}
|
onClick={() => setMuted(!muted)}
|
||||||
>
|
>
|
||||||
{muted || volume === 0 ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
|
{muted || volume === 0 ? (
|
||||||
|
<VolumeX className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<Volume2 className="h-5 w-5" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isBuffering && (
|
{isBuffering && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 pointer-events-none">
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/20">
|
||||||
<Loader2 className="h-10 w-10 animate-spin text-white/80" />
|
<Loader2 className="h-10 w-10 animate-spin text-white/80" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,7 +4,15 @@ import React, { useState } from "react";
|
|||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Card, CardContent } from "~/components/ui/card";
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||||
import { Flag, CheckCircle, Bot, User, MessageSquare, AlertTriangle, Activity } from "lucide-react";
|
import {
|
||||||
|
Flag,
|
||||||
|
CheckCircle,
|
||||||
|
Bot,
|
||||||
|
User,
|
||||||
|
MessageSquare,
|
||||||
|
AlertTriangle,
|
||||||
|
Activity,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
interface TimelineEvent {
|
interface TimelineEvent {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -19,19 +27,25 @@ interface HorizontalTimelineProps {
|
|||||||
endTime?: Date;
|
endTime?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HorizontalTimeline({ events, startTime, endTime }: HorizontalTimelineProps) {
|
export function HorizontalTimeline({
|
||||||
const [selectedEvent, setSelectedEvent] = useState<TimelineEvent | null>(null);
|
events,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
}: HorizontalTimelineProps) {
|
||||||
|
const [selectedEvent, setSelectedEvent] = useState<TimelineEvent | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center text-sm text-muted-foreground py-8">
|
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||||
No events recorded yet
|
No events recorded yet
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate time range
|
// Calculate time range
|
||||||
const timestamps = events.map(e => e.timestamp.getTime());
|
const timestamps = events.map((e) => e.timestamp.getTime());
|
||||||
const minTime = startTime?.getTime() ?? Math.min(...timestamps);
|
const minTime = startTime?.getTime() ?? Math.min(...timestamps);
|
||||||
const maxTime = endTime?.getTime() ?? Math.max(...timestamps);
|
const maxTime = endTime?.getTime() ?? Math.max(...timestamps);
|
||||||
const duration = maxTime - minTime;
|
const duration = maxTime - minTime;
|
||||||
@@ -39,7 +53,8 @@ export function HorizontalTimeline({ events, startTime, endTime }: HorizontalTim
|
|||||||
// Generate time markers (every 10 seconds or appropriate interval)
|
// Generate time markers (every 10 seconds or appropriate interval)
|
||||||
const getTimeMarkers = () => {
|
const getTimeMarkers = () => {
|
||||||
const markers: Date[] = [];
|
const markers: Date[] = [];
|
||||||
const interval = duration > 300000 ? 60000 : duration > 60000 ? 30000 : 10000; // 1min, 30s, or 10s intervals
|
const interval =
|
||||||
|
duration > 300000 ? 60000 : duration > 60000 ? 30000 : 10000; // 1min, 30s, or 10s intervals
|
||||||
|
|
||||||
for (let time = minTime; time <= maxTime; time += interval) {
|
for (let time = minTime; time <= maxTime; time += interval) {
|
||||||
markers.push(new Date(time));
|
markers.push(new Date(time));
|
||||||
@@ -62,11 +77,17 @@ export function HorizontalTimeline({ events, startTime, endTime }: HorizontalTim
|
|||||||
const getEventStyle = (eventType: string) => {
|
const getEventStyle = (eventType: string) => {
|
||||||
if (eventType.includes("start") || eventType === "trial_started") {
|
if (eventType.includes("start") || eventType === "trial_started") {
|
||||||
return { color: "bg-blue-500", Icon: Flag };
|
return { color: "bg-blue-500", Icon: Flag };
|
||||||
} else if (eventType.includes("complete") || eventType === "trial_completed") {
|
} else if (
|
||||||
|
eventType.includes("complete") ||
|
||||||
|
eventType === "trial_completed"
|
||||||
|
) {
|
||||||
return { color: "bg-green-500", Icon: CheckCircle };
|
return { color: "bg-green-500", Icon: CheckCircle };
|
||||||
} else if (eventType.includes("robot") || eventType.includes("action")) {
|
} else if (eventType.includes("robot") || eventType.includes("action")) {
|
||||||
return { color: "bg-purple-500", Icon: Bot };
|
return { color: "bg-purple-500", Icon: Bot };
|
||||||
} else if (eventType.includes("wizard") || eventType.includes("intervention")) {
|
} else if (
|
||||||
|
eventType.includes("wizard") ||
|
||||||
|
eventType.includes("intervention")
|
||||||
|
) {
|
||||||
return { color: "bg-orange-500", Icon: User };
|
return { color: "bg-orange-500", Icon: User };
|
||||||
} else if (eventType.includes("note") || eventType.includes("annotation")) {
|
} else if (eventType.includes("note") || eventType.includes("annotation")) {
|
||||||
return { color: "bg-yellow-500", Icon: MessageSquare };
|
return { color: "bg-yellow-500", Icon: MessageSquare };
|
||||||
@@ -83,9 +104,12 @@ export function HorizontalTimeline({ events, startTime, endTime }: HorizontalTim
|
|||||||
<ScrollArea className="w-full">
|
<ScrollArea className="w-full">
|
||||||
<div className="min-w-[800px] px-4 py-8">
|
<div className="min-w-[800px] px-4 py-8">
|
||||||
{/* Time markers */}
|
{/* Time markers */}
|
||||||
<div className="relative h-20 mb-8">
|
<div className="relative mb-8 h-20">
|
||||||
{/* Main horizontal line */}
|
{/* Main horizontal line */}
|
||||||
<div className="absolute top-1/2 left-0 right-0 h-0.5 bg-border" style={{ transform: 'translateY(-50%)' }} />
|
<div
|
||||||
|
className="bg-border absolute top-1/2 right-0 left-0 h-0.5"
|
||||||
|
style={{ transform: "translateY(-50%)" }}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Time labels */}
|
{/* Time labels */}
|
||||||
{timeMarkers.map((marker, i) => {
|
{timeMarkers.map((marker, i) => {
|
||||||
@@ -94,18 +118,22 @@ export function HorizontalTimeline({ events, startTime, endTime }: HorizontalTim
|
|||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="absolute"
|
className="absolute"
|
||||||
style={{ left: `${pos}%`, top: '50%', transform: 'translate(-50%, -50%)' }}
|
style={{
|
||||||
|
left: `${pos}%`,
|
||||||
|
top: "50%",
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="text-[10px] font-mono text-muted-foreground mb-2">
|
<div className="text-muted-foreground mb-2 font-mono text-[10px]">
|
||||||
{marker.toLocaleTimeString([], {
|
{marker.toLocaleTimeString([], {
|
||||||
hour12: false,
|
hour12: false,
|
||||||
hour: '2-digit',
|
hour: "2-digit",
|
||||||
minute: '2-digit',
|
minute: "2-digit",
|
||||||
second: '2-digit'
|
second: "2-digit",
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-px h-4 bg-border" />
|
<div className="bg-border h-4 w-px" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -115,7 +143,7 @@ export function HorizontalTimeline({ events, startTime, endTime }: HorizontalTim
|
|||||||
{/* Event markers */}
|
{/* Event markers */}
|
||||||
<div className="relative h-40">
|
<div className="relative h-40">
|
||||||
{/* Timeline line for events */}
|
{/* Timeline line for events */}
|
||||||
<div className="absolute top-20 left-0 right-0 h-0.5 bg-border" />
|
<div className="bg-border absolute top-20 right-0 left-0 h-0.5" />
|
||||||
|
|
||||||
{events.map((event, i) => {
|
{events.map((event, i) => {
|
||||||
const pos = getPosition(event.timestamp);
|
const pos = getPosition(event.timestamp);
|
||||||
@@ -128,30 +156,30 @@ export function HorizontalTimeline({ events, startTime, endTime }: HorizontalTim
|
|||||||
className="absolute"
|
className="absolute"
|
||||||
style={{
|
style={{
|
||||||
left: `${pos}%`,
|
left: `${pos}%`,
|
||||||
top: '50%',
|
top: "50%",
|
||||||
transform: 'translate(-50%, -50%)'
|
transform: "translate(-50%, -50%)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Clickable marker group */}
|
{/* Clickable marker group */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedEvent(isSelected ? null : event)}
|
onClick={() =>
|
||||||
className="flex flex-col items-center gap-1 cursor-pointer group"
|
setSelectedEvent(isSelected ? null : event)
|
||||||
|
}
|
||||||
|
className="group flex cursor-pointer flex-col items-center gap-1"
|
||||||
title={event.message || event.type}
|
title={event.message || event.type}
|
||||||
>
|
>
|
||||||
{/* Vertical dash */}
|
{/* Vertical dash */}
|
||||||
<div className={`
|
<div
|
||||||
w-1 h-20 ${color} rounded-full
|
className={`h-20 w-1 ${color} rounded-full transition-all group-hover:w-1.5 ${isSelected ? "ring-offset-background ring-primary w-1.5 ring-2 ring-offset-2" : ""} `}
|
||||||
group-hover:w-1.5 transition-all
|
/>
|
||||||
${isSelected ? 'w-1.5 ring-2 ring-offset-2 ring-offset-background ring-primary' : ''}
|
|
||||||
`} />
|
|
||||||
|
|
||||||
{/* Icon indicator */}
|
{/* Icon indicator */}
|
||||||
<div className={`
|
<div
|
||||||
p-1.5 rounded-full ${color} bg-opacity-20
|
className={`rounded-full p-1.5 ${color} bg-opacity-20 group-hover:bg-opacity-30 transition-all ${isSelected ? "ring-primary bg-opacity-40 ring-2" : ""} `}
|
||||||
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-")}`}
|
||||||
<Icon className={`h-3.5 w-3.5 ${color.replace('bg-', 'text-')}`} />
|
/>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,25 +199,26 @@ export function HorizontalTimeline({ events, startTime, endTime }: HorizontalTim
|
|||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{selectedEvent.type.replace(/_/g, " ")}
|
{selectedEvent.type.replace(/_/g, " ")}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-xs font-mono text-muted-foreground">
|
<span className="text-muted-foreground font-mono text-xs">
|
||||||
{selectedEvent.timestamp.toLocaleTimeString([], {
|
{selectedEvent.timestamp.toLocaleTimeString([], {
|
||||||
hour12: false,
|
hour12: false,
|
||||||
hour: '2-digit',
|
hour: "2-digit",
|
||||||
minute: '2-digit',
|
minute: "2-digit",
|
||||||
second: '2-digit',
|
second: "2-digit",
|
||||||
fractionalSecondDigits: 3
|
fractionalSecondDigits: 3,
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{selectedEvent.message && (
|
{selectedEvent.message && (
|
||||||
<p className="text-sm">{selectedEvent.message}</p>
|
<p className="text-sm">{selectedEvent.message}</p>
|
||||||
)}
|
)}
|
||||||
{selectedEvent.data !== undefined && selectedEvent.data !== null && (
|
{selectedEvent.data !== undefined &&
|
||||||
|
selectedEvent.data !== null && (
|
||||||
<details className="text-xs">
|
<details className="text-xs">
|
||||||
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
<summary className="text-muted-foreground hover:text-foreground cursor-pointer">
|
||||||
Event data
|
Event data
|
||||||
</summary>
|
</summary>
|
||||||
<pre className="mt-2 p-2 bg-muted rounded text-[10px] overflow-auto">
|
<pre className="bg-muted mt-2 overflow-auto rounded p-2 text-[10px]">
|
||||||
{JSON.stringify(selectedEvent.data, null, 2)}
|
{JSON.stringify(selectedEvent.data, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { PageHeader } from "~/components/ui/page-header";
|
import { PageHeader } from "~/components/ui/page-header";
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
@@ -6,7 +5,21 @@ import { Badge } from "~/components/ui/badge";
|
|||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { LineChart, BarChart, Printer, Clock, Database, FileText, AlertTriangle, CheckCircle, VideoOff, Info, Bot, Activity, ArrowLeft } from "lucide-react";
|
import {
|
||||||
|
LineChart,
|
||||||
|
BarChart,
|
||||||
|
Printer,
|
||||||
|
Clock,
|
||||||
|
Database,
|
||||||
|
FileText,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
VideoOff,
|
||||||
|
Info,
|
||||||
|
Bot,
|
||||||
|
Activity,
|
||||||
|
ArrowLeft,
|
||||||
|
} from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { PlaybackProvider } from "../playback/PlaybackContext";
|
import { PlaybackProvider } from "../playback/PlaybackContext";
|
||||||
import { PlaybackPlayer } from "../playback/PlaybackPlayer";
|
import { PlaybackPlayer } from "../playback/PlaybackPlayer";
|
||||||
@@ -32,24 +45,32 @@ interface TrialAnalysisViewProps {
|
|||||||
participant: { participantCode: string };
|
participant: { participantCode: string };
|
||||||
eventCount?: number;
|
eventCount?: number;
|
||||||
mediaCount?: number;
|
mediaCount?: number;
|
||||||
media?: { url: string; mediaType: string; format?: string; contentType?: string }[];
|
media?: {
|
||||||
|
url: string;
|
||||||
|
mediaType: string;
|
||||||
|
format?: string;
|
||||||
|
contentType?: string;
|
||||||
|
}[];
|
||||||
};
|
};
|
||||||
backHref: string;
|
backHref: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
||||||
// Fetch events for timeline
|
// Fetch events for timeline
|
||||||
const { data: events = [] } = api.trials.getEvents.useQuery({
|
const { data: events = [] } = api.trials.getEvents.useQuery(
|
||||||
|
{
|
||||||
trialId: trial.id,
|
trialId: trial.id,
|
||||||
limit: 1000
|
limit: 1000,
|
||||||
}, {
|
},
|
||||||
refetchInterval: 5000
|
{
|
||||||
});
|
refetchInterval: 5000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Auto-print effect
|
// Auto-print effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
if (searchParams.get('export') === 'true') {
|
if (searchParams.get("export") === "true") {
|
||||||
// Small delay to ensure rendering
|
// Small delay to ensure rendering
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.print();
|
window.print();
|
||||||
@@ -57,27 +78,40 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const videoMedia = trial.media?.find(m => m.mediaType === "video" || (m as any).contentType?.startsWith("video/"));
|
const videoMedia = trial.media?.find(
|
||||||
|
(m) =>
|
||||||
|
m.mediaType === "video" || (m as any).contentType?.startsWith("video/"),
|
||||||
|
);
|
||||||
const videoUrl = videoMedia?.url;
|
const videoUrl = videoMedia?.url;
|
||||||
|
|
||||||
// Metrics
|
// Metrics
|
||||||
const interventionCount = events.filter(e => e.eventType.includes("intervention")).length;
|
const interventionCount = events.filter((e) =>
|
||||||
const errorCount = events.filter(e => e.eventType.includes("error")).length;
|
e.eventType.includes("intervention"),
|
||||||
const robotActionCount = events.filter(e => e.eventType.includes("robot_action")).length;
|
).length;
|
||||||
|
const errorCount = events.filter((e) => e.eventType.includes("error")).length;
|
||||||
|
const robotActionCount = events.filter((e) =>
|
||||||
|
e.eventType.includes("robot_action"),
|
||||||
|
).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlaybackProvider events={events} startTime={trial.startedAt ?? undefined}>
|
<PlaybackProvider events={events} startTime={trial.startedAt ?? undefined}>
|
||||||
<div id="trial-analysis-content" className="flex h-full flex-col gap-2 p-3 text-sm">
|
<div
|
||||||
|
id="trial-analysis-content"
|
||||||
|
className="flex h-full flex-col gap-2 p-3 text-sm"
|
||||||
|
>
|
||||||
{/* Header Context */}
|
{/* Header Context */}
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={trial.experiment.name}
|
title={trial.experiment.name}
|
||||||
description={`Session ${trial.id.slice(0, 8)} • ${trial.startedAt?.toLocaleDateString() ?? 'Unknown Date'} ${trial.startedAt?.toLocaleTimeString() ?? ''}`}
|
description={`Session ${trial.id.slice(0, 8)} • ${trial.startedAt?.toLocaleDateString() ?? "Unknown Date"} ${trial.startedAt?.toLocaleTimeString() ?? ""}`}
|
||||||
badges={[
|
badges={[
|
||||||
{
|
{
|
||||||
label: trial.status.toUpperCase(),
|
label: trial.status.toUpperCase(),
|
||||||
variant: trial.status === 'completed' ? 'default' : 'secondary',
|
variant: trial.status === "completed" ? "default" : "secondary",
|
||||||
className: trial.status === 'completed' ? 'bg-green-500 hover:bg-green-600' : ''
|
className:
|
||||||
}
|
trial.status === "completed"
|
||||||
|
? "bg-green-500 hover:bg-green-600"
|
||||||
|
: "",
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
actions={
|
actions={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -98,7 +132,8 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
|||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
/* Show only our content */
|
/* Show only our content */
|
||||||
#trial-analysis-content, #trial-analysis-content * {
|
#trial-analysis-content,
|
||||||
|
#trial-analysis-content * {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
#trial-analysis-content {
|
#trial-analysis-content {
|
||||||
@@ -189,40 +224,52 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Top Section: Metrics & Optional Video Grid */}
|
{/* Top Section: Metrics & Optional Video Grid */}
|
||||||
<div className="flex flex-col xl:flex-row gap-3 shrink-0">
|
<div className="flex shrink-0 flex-col gap-3 xl:flex-row">
|
||||||
<Card id="tour-trial-metrics" className="shadow-sm flex-1">
|
<Card id="tour-trial-metrics" className="flex-1 shadow-sm">
|
||||||
<CardContent className="p-0 h-full">
|
<CardContent className="h-full p-0">
|
||||||
<div className="grid grid-cols-2 grid-rows-2 h-full divide-x divide-y">
|
<div className="grid h-full grid-cols-2 grid-rows-2 divide-x divide-y">
|
||||||
<div className="flex flex-col p-4 md:p-6 justify-center">
|
<div className="flex flex-col justify-center p-4 md:p-6">
|
||||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-2">
|
<p className="text-muted-foreground mb-2 flex items-center gap-1.5 text-sm font-medium">
|
||||||
<Clock className="h-4 w-4 text-blue-500" /> Duration
|
<Clock className="h-4 w-4 text-blue-500" /> Duration
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-bold">
|
<p className="text-2xl font-bold">
|
||||||
{trial.duration ? <span>{Math.floor(trial.duration / 60)}m {trial.duration % 60}s</span> : "--:--"}
|
{trial.duration ? (
|
||||||
|
<span>
|
||||||
|
{Math.floor(trial.duration / 60)}m {trial.duration % 60}
|
||||||
|
s
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"--:--"
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col p-4 md:p-6 justify-center border-t-0">
|
<div className="flex flex-col justify-center border-t-0 p-4 md:p-6">
|
||||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-2">
|
<p className="text-muted-foreground mb-2 flex items-center gap-1.5 text-sm font-medium">
|
||||||
<Bot className="h-4 w-4 text-purple-500" /> Robot Actions
|
<Bot className="h-4 w-4 text-purple-500" /> Robot Actions
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-bold">{robotActionCount}</p>
|
<p className="text-2xl font-bold">{robotActionCount}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col p-4 md:p-6 justify-center">
|
<div className="flex flex-col justify-center p-4 md:p-6">
|
||||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-2">
|
<p className="text-muted-foreground mb-2 flex items-center gap-1.5 text-sm font-medium">
|
||||||
<AlertTriangle className="h-4 w-4 text-orange-500" /> Interventions
|
<AlertTriangle className="h-4 w-4 text-orange-500" />{" "}
|
||||||
|
Interventions
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-bold">{interventionCount}</p>
|
<p className="text-2xl font-bold">{interventionCount}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col p-4 md:p-6 justify-center">
|
<div className="flex flex-col justify-center p-4 md:p-6">
|
||||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-2">
|
<p className="text-muted-foreground mb-2 flex items-center gap-1.5 text-sm font-medium">
|
||||||
<Activity className="h-4 w-4 text-green-500" /> Completeness
|
<Activity className="h-4 w-4 text-green-500" /> Completeness
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 text-2xl font-bold">
|
<div className="flex items-center gap-2 text-2xl font-bold">
|
||||||
<span className={cn(
|
<span
|
||||||
|
className={cn(
|
||||||
"inline-block h-3 w-3 rounded-full",
|
"inline-block h-3 w-3 rounded-full",
|
||||||
trial.status === 'completed' ? "bg-green-500" : "bg-yellow-500"
|
trial.status === "completed"
|
||||||
)} />
|
? "bg-green-500"
|
||||||
{trial.status === 'completed' ? '100%' : 'Incomplete'}
|
: "bg-yellow-500",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{trial.status === "completed" ? "100%" : "Incomplete"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -230,8 +277,11 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{videoUrl && (
|
{videoUrl && (
|
||||||
<Card id="tour-trial-video" className="shadow-sm w-full xl:w-[500px] overflow-hidden shrink-0 bg-black/5 dark:bg-black/40 border">
|
<Card
|
||||||
<div className="aspect-video w-full h-full relative flex items-center justify-center bg-black">
|
id="tour-trial-video"
|
||||||
|
className="w-full shrink-0 overflow-hidden border bg-black/5 shadow-sm xl:w-[500px] dark:bg-black/40"
|
||||||
|
>
|
||||||
|
<div className="relative flex aspect-video h-full w-full items-center justify-center bg-black">
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<PlaybackPlayer src={videoUrl} />
|
<PlaybackPlayer src={videoUrl} />
|
||||||
</div>
|
</div>
|
||||||
@@ -241,21 +291,38 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Workspace: Vertical Layout */}
|
{/* Main Workspace: Vertical Layout */}
|
||||||
<div className="flex-1 min-h-0 rounded-xl border shadow-sm overflow-hidden bg-background flex flex-col">
|
<div className="bg-background flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border shadow-sm">
|
||||||
|
|
||||||
{/* FIXED TIMELINE: Always visible at top */}
|
{/* FIXED TIMELINE: Always visible at top */}
|
||||||
<div id="tour-trial-timeline" className="shrink-0 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 p-1">
|
<div
|
||||||
|
id="tour-trial-timeline"
|
||||||
|
className="bg-background/95 supports-[backdrop-filter]:bg-background/60 shrink-0 border-b p-1 backdrop-blur"
|
||||||
|
>
|
||||||
<EventTimeline />
|
<EventTimeline />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* BOTTOM: Events Table */}
|
{/* BOTTOM: Events Table */}
|
||||||
<div className="flex-1 flex flex-col min-h-0 bg-background" id="tour-trial-events">
|
<div
|
||||||
<Tabs defaultValue="events" className="flex flex-col h-full">
|
className="bg-background flex min-h-0 flex-1 flex-col"
|
||||||
<div className="flex items-center justify-between px-3 py-2 border-b shrink-0 bg-muted/10">
|
id="tour-trial-events"
|
||||||
|
>
|
||||||
|
<Tabs defaultValue="events" className="flex h-full flex-col">
|
||||||
|
<div className="bg-muted/10 flex shrink-0 items-center justify-between border-b px-3 py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<TabsList className="h-8">
|
<TabsList className="h-8">
|
||||||
<TabsTrigger value="events" className="text-xs">All Events</TabsTrigger>
|
<TabsTrigger value="events" className="text-xs">
|
||||||
<TabsTrigger value="observations" className="text-xs">Observations ({events.filter(e => e.eventType.startsWith('annotation') || e.eventType === 'wizard_note').length})</TabsTrigger>
|
All Events
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="observations" className="text-xs">
|
||||||
|
Observations (
|
||||||
|
{
|
||||||
|
events.filter(
|
||||||
|
(e) =>
|
||||||
|
e.eventType.startsWith("annotation") ||
|
||||||
|
e.eventType === "wizard_note",
|
||||||
|
).length
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -263,54 +330,85 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
|||||||
placeholder="Filter..."
|
placeholder="Filter..."
|
||||||
className="h-7 w-[150px] text-xs"
|
className="h-7 w-[150px] text-xs"
|
||||||
disabled
|
disabled
|
||||||
style={{ display: 'none' }}
|
style={{ display: "none" }}
|
||||||
/>
|
/>
|
||||||
<Badge variant="outline" className="text-[10px] font-normal">{events.length} Total</Badge>
|
<Badge variant="outline" className="text-[10px] font-normal">
|
||||||
|
{events.length} Total
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TabsContent value="events" className="flex-1 min-h-0 mt-0">
|
<TabsContent value="events" className="mt-0 min-h-0 flex-1">
|
||||||
<ScrollArea className="h-full">
|
<ScrollArea className="h-full">
|
||||||
<div className="p-0">
|
<div className="p-0">
|
||||||
<EventsDataTable
|
<EventsDataTable
|
||||||
data={events.map(e => ({ ...e, timestamp: new Date(e.timestamp) }))}
|
data={events.map((e) => ({
|
||||||
|
...e,
|
||||||
|
timestamp: new Date(e.timestamp),
|
||||||
|
}))}
|
||||||
startTime={trial.startedAt ?? undefined}
|
startTime={trial.startedAt ?? undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="observations" className="flex-1 min-h-0 mt-0 bg-muted/5">
|
<TabsContent
|
||||||
|
value="observations"
|
||||||
|
className="bg-muted/5 mt-0 min-h-0 flex-1"
|
||||||
|
>
|
||||||
<ScrollArea className="h-full">
|
<ScrollArea className="h-full">
|
||||||
<div className="p-4 space-y-3 max-w-2xl mx-auto">
|
<div className="mx-auto max-w-2xl space-y-3 p-4">
|
||||||
{events.filter(e => e.eventType.startsWith('annotation') || e.eventType === 'wizard_note').length > 0 ? (
|
{events.filter(
|
||||||
|
(e) =>
|
||||||
|
e.eventType.startsWith("annotation") ||
|
||||||
|
e.eventType === "wizard_note",
|
||||||
|
).length > 0 ? (
|
||||||
events
|
events
|
||||||
.filter(e => e.eventType.startsWith('annotation') || e.eventType === 'wizard_note')
|
.filter(
|
||||||
|
(e) =>
|
||||||
|
e.eventType.startsWith("annotation") ||
|
||||||
|
e.eventType === "wizard_note",
|
||||||
|
)
|
||||||
.map((e, i) => {
|
.map((e, i) => {
|
||||||
const data = e.data as any;
|
const data = e.data as any;
|
||||||
return (
|
return (
|
||||||
<Card key={i} className="border shadow-none">
|
<Card key={i} className="border shadow-none">
|
||||||
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between space-y-0">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-3 pb-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-200">
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-yellow-200 bg-yellow-50 text-yellow-700"
|
||||||
|
>
|
||||||
{data?.category || "Note"}
|
{data?.category || "Note"}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-xs text-muted-foreground font-mono">
|
<span className="text-muted-foreground font-mono text-xs">
|
||||||
{trial.startedAt ? formatTime(new Date(e.timestamp).getTime() - new Date(trial.startedAt).getTime()) : '--:--'}
|
{trial.startedAt
|
||||||
|
? formatTime(
|
||||||
|
new Date(e.timestamp).getTime() -
|
||||||
|
new Date(trial.startedAt).getTime(),
|
||||||
|
)
|
||||||
|
: "--:--"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] text-muted-foreground">
|
<span className="text-muted-foreground text-[10px]">
|
||||||
{new Date(e.timestamp).toLocaleTimeString()}
|
{new Date(e.timestamp).toLocaleTimeString()}
|
||||||
</span>
|
</span>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-3 pt-2">
|
<CardContent className="p-3 pt-2">
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
{data?.description || data?.note || data?.message || "No content"}
|
{data?.description ||
|
||||||
|
data?.note ||
|
||||||
|
data?.message ||
|
||||||
|
"No content"}
|
||||||
</p>
|
</p>
|
||||||
{data?.tags && data.tags.length > 0 && (
|
{data?.tags && data.tags.length > 0 && (
|
||||||
<div className="flex gap-1 mt-2">
|
<div className="mt-2 flex gap-1">
|
||||||
{data.tags.map((t: string, ti: number) => (
|
{data.tags.map((t: string, ti: number) => (
|
||||||
<Badge key={ti} variant="secondary" className="text-[10px] h-5 px-1.5">
|
<Badge
|
||||||
|
key={ti}
|
||||||
|
variant="secondary"
|
||||||
|
className="h-5 px-1.5 text-[10px]"
|
||||||
|
>
|
||||||
{t}
|
{t}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
@@ -321,8 +419,8 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-12 text-muted-foreground text-sm">
|
<div className="text-muted-foreground py-12 text-center text-sm">
|
||||||
<Info className="h-8 w-8 mx-auto mb-2 opacity-20" />
|
<Info className="mx-auto mb-2 h-8 w-8 opacity-20" />
|
||||||
No observations recorded for this session.
|
No observations recorded for this session.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -347,4 +445,3 @@ function formatTime(ms: number) {
|
|||||||
const s = Math.floor(totalSeconds % 60);
|
const s = Math.floor(totalSeconds % 60);
|
||||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
import { Switch } from "~/components/ui/switch";
|
import { Switch } from "~/components/ui/switch";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
import { Separator } from "~/components/ui/separator";
|
import { Separator } from "~/components/ui/separator";
|
||||||
import { Loader2, Settings2 } from "lucide-react";
|
import { Loader2, Settings2 } from "lucide-react";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
@@ -49,9 +62,10 @@ export function RobotSettingsModal({
|
|||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
// Fetch current settings
|
// Fetch current settings
|
||||||
const { data: currentSettings, isLoading } = api.studies.getPluginConfiguration.useQuery(
|
const { data: currentSettings, isLoading } =
|
||||||
|
api.studies.getPluginConfiguration.useQuery(
|
||||||
{ studyId, pluginId },
|
{ studyId, pluginId },
|
||||||
{ enabled: open }
|
{ enabled: open },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update settings mutation
|
// Update settings mutation
|
||||||
@@ -86,7 +100,11 @@ export function RobotSettingsModal({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderField = (key: string, schema: PropertySchema, parentPath: string = "") => {
|
const renderField = (
|
||||||
|
key: string,
|
||||||
|
schema: PropertySchema,
|
||||||
|
parentPath: string = "",
|
||||||
|
) => {
|
||||||
const fullPath = parentPath ? `${parentPath}.${key}` : key;
|
const fullPath = parentPath ? `${parentPath}.${key}` : key;
|
||||||
const value = getNestedValue(settings, fullPath);
|
const value = getNestedValue(settings, fullPath);
|
||||||
const defaultValue = schema.default;
|
const defaultValue = schema.default;
|
||||||
@@ -102,12 +120,14 @@ export function RobotSettingsModal({
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h4 className="text-sm font-semibold">{schema.title || key}</h4>
|
<h4 className="text-sm font-semibold">{schema.title || key}</h4>
|
||||||
{schema.description && (
|
{schema.description && (
|
||||||
<p className="text-xs text-muted-foreground">{schema.description}</p>
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{schema.description}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4 space-y-3">
|
<div className="ml-4 space-y-3">
|
||||||
{Object.entries(schema.properties).map(([subKey, subSchema]) =>
|
{Object.entries(schema.properties).map(([subKey, subSchema]) =>
|
||||||
renderField(subKey, subSchema, fullPath)
|
renderField(subKey, subSchema, fullPath),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,11 +137,16 @@ export function RobotSettingsModal({
|
|||||||
// Boolean type - render switch
|
// Boolean type - render switch
|
||||||
if (schema.type === "boolean") {
|
if (schema.type === "boolean") {
|
||||||
return (
|
return (
|
||||||
<div key={fullPath} className="flex items-center justify-between space-x-2">
|
<div
|
||||||
<div className="space-y-0.5 flex-1">
|
key={fullPath}
|
||||||
|
className="flex items-center justify-between space-x-2"
|
||||||
|
>
|
||||||
|
<div className="flex-1 space-y-0.5">
|
||||||
<Label htmlFor={fullPath}>{schema.title || key}</Label>
|
<Label htmlFor={fullPath}>{schema.title || key}</Label>
|
||||||
{schema.description && (
|
{schema.description && (
|
||||||
<p className="text-xs text-muted-foreground">{schema.description}</p>
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{schema.description}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -139,7 +164,9 @@ export function RobotSettingsModal({
|
|||||||
<div key={fullPath} className="space-y-2">
|
<div key={fullPath} className="space-y-2">
|
||||||
<Label htmlFor={fullPath}>{schema.title || key}</Label>
|
<Label htmlFor={fullPath}>{schema.title || key}</Label>
|
||||||
{schema.description && (
|
{schema.description && (
|
||||||
<p className="text-xs text-muted-foreground">{schema.description}</p>
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{schema.description}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
<Select
|
<Select
|
||||||
value={(value ?? defaultValue) as string}
|
value={(value ?? defaultValue) as string}
|
||||||
@@ -166,7 +193,9 @@ export function RobotSettingsModal({
|
|||||||
<div key={fullPath} className="space-y-2">
|
<div key={fullPath} className="space-y-2">
|
||||||
<Label htmlFor={fullPath}>{schema.title || key}</Label>
|
<Label htmlFor={fullPath}>{schema.title || key}</Label>
|
||||||
{schema.description && (
|
{schema.description && (
|
||||||
<p className="text-xs text-muted-foreground">{schema.description}</p>
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{schema.description}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
<Input
|
<Input
|
||||||
id={fullPath}
|
id={fullPath}
|
||||||
@@ -176,7 +205,8 @@ export function RobotSettingsModal({
|
|||||||
step={schema.type === "integer" ? 1 : 0.1}
|
step={schema.type === "integer" ? 1 : 0.1}
|
||||||
value={(value ?? defaultValue) as number}
|
value={(value ?? defaultValue) as number}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newValue = schema.type === "integer"
|
const newValue =
|
||||||
|
schema.type === "integer"
|
||||||
? parseInt(e.target.value, 10)
|
? parseInt(e.target.value, 10)
|
||||||
: parseFloat(e.target.value);
|
: parseFloat(e.target.value);
|
||||||
updateValue(isNaN(newValue) ? defaultValue : newValue);
|
updateValue(isNaN(newValue) ? defaultValue : newValue);
|
||||||
@@ -191,7 +221,7 @@ export function RobotSettingsModal({
|
|||||||
<div key={fullPath} className="space-y-2">
|
<div key={fullPath} className="space-y-2">
|
||||||
<Label htmlFor={fullPath}>{schema.title || key}</Label>
|
<Label htmlFor={fullPath}>{schema.title || key}</Label>
|
||||||
{schema.description && (
|
{schema.description && (
|
||||||
<p className="text-xs text-muted-foreground">{schema.description}</p>
|
<p className="text-muted-foreground text-xs">{schema.description}</p>
|
||||||
)}
|
)}
|
||||||
<Input
|
<Input
|
||||||
id={fullPath}
|
id={fullPath}
|
||||||
@@ -210,7 +240,7 @@ export function RobotSettingsModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
<DialogContent className="max-h-[80vh] max-w-2xl overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Settings2 className="h-5 w-5" />
|
<Settings2 className="h-5 w-5" />
|
||||||
@@ -223,23 +253,29 @@ export function RobotSettingsModal({
|
|||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6 py-4">
|
<div className="space-y-6 py-4">
|
||||||
{Object.entries(settingsSchema.properties).map(([key, schema], idx) => (
|
{Object.entries(settingsSchema.properties).map(
|
||||||
|
([key, schema], idx) => (
|
||||||
<div key={key}>
|
<div key={key}>
|
||||||
{renderField(key, schema)}
|
{renderField(key, schema)}
|
||||||
{idx < Object.keys(settingsSchema.properties).length - 1 && (
|
{idx < Object.keys(settingsSchema.properties).length - 1 && (
|
||||||
<Separator className="mt-6" />
|
<Separator className="mt-6" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
),
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={isSaving || isLoading}>
|
<Button onClick={handleSave} disabled={isSaving || isLoading}>
|
||||||
@@ -255,11 +291,17 @@ export function RobotSettingsModal({
|
|||||||
// Helper functions for nested object access
|
// Helper functions for nested object access
|
||||||
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||||
return path.split(".").reduce((current, key) => {
|
return path.split(".").reduce((current, key) => {
|
||||||
return current && typeof current === "object" ? (current as Record<string, unknown>)[key] : undefined;
|
return current && typeof current === "object"
|
||||||
|
? (current as Record<string, unknown>)[key]
|
||||||
|
: undefined;
|
||||||
}, obj as unknown);
|
}, obj as unknown);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): Record<string, unknown> {
|
function setNestedValue(
|
||||||
|
obj: Record<string, unknown>,
|
||||||
|
path: string,
|
||||||
|
value: unknown,
|
||||||
|
): Record<string, unknown> {
|
||||||
const keys = path.split(".");
|
const keys = path.split(".");
|
||||||
const lastKey = keys.pop()!;
|
const lastKey = keys.pop()!;
|
||||||
const target = keys.reduce((current, key) => {
|
const target = keys.reduce((current, key) => {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
Play,
|
Play,
|
||||||
Target,
|
Target,
|
||||||
Users,
|
Users,
|
||||||
SkipForward
|
SkipForward,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
@@ -118,7 +118,8 @@ export function TrialProgress({
|
|||||||
return "pending";
|
return "pending";
|
||||||
|
|
||||||
// Default fallback if jumping around without explicitly adding to sets
|
// Default fallback if jumping around without explicitly adding to sets
|
||||||
if (index < currentStepIndex && !skippedSteps.has(index)) return "completed";
|
if (index < currentStepIndex && !skippedSteps.has(index))
|
||||||
|
return "completed";
|
||||||
|
|
||||||
return "upcoming";
|
return "upcoming";
|
||||||
};
|
};
|
||||||
@@ -211,7 +212,8 @@ export function TrialProgress({
|
|||||||
</div>
|
</div>
|
||||||
<Progress
|
<Progress
|
||||||
value={progress}
|
value={progress}
|
||||||
className={`h-2 ${trialStatus === "completed"
|
className={`h-2 ${
|
||||||
|
trialStatus === "completed"
|
||||||
? "bg-green-100"
|
? "bg-green-100"
|
||||||
: trialStatus === "aborted" || trialStatus === "failed"
|
: trialStatus === "aborted" || trialStatus === "failed"
|
||||||
? "bg-red-100"
|
? "bg-red-100"
|
||||||
@@ -255,7 +257,8 @@ export function TrialProgress({
|
|||||||
{/* Connection Line */}
|
{/* Connection Line */}
|
||||||
{index < steps.length - 1 && (
|
{index < steps.length - 1 && (
|
||||||
<div
|
<div
|
||||||
className={`absolute top-12 left-6 h-6 w-0.5 ${getStepStatus(index + 1) === "completed" ||
|
className={`absolute top-12 left-6 h-6 w-0.5 ${
|
||||||
|
getStepStatus(index + 1) === "completed" ||
|
||||||
(getStepStatus(index + 1) === "active" &&
|
(getStepStatus(index + 1) === "active" &&
|
||||||
status === "completed")
|
status === "completed")
|
||||||
? "bg-green-300"
|
? "bg-green-300"
|
||||||
@@ -266,7 +269,8 @@ export function TrialProgress({
|
|||||||
|
|
||||||
{/* Step Card */}
|
{/* Step Card */}
|
||||||
<div
|
<div
|
||||||
className={`flex items-start space-x-3 rounded-lg border p-3 transition-all ${status === "active"
|
className={`flex items-start space-x-3 rounded-lg border p-3 transition-all ${
|
||||||
|
status === "active"
|
||||||
? `${statusConfig.bgColor} ${statusConfig.borderColor} shadow-md ring-2 ring-blue-200`
|
? `${statusConfig.bgColor} ${statusConfig.borderColor} shadow-md ring-2 ring-blue-200`
|
||||||
: status === "completed"
|
: status === "completed"
|
||||||
? `${statusConfig.bgColor} ${statusConfig.borderColor}`
|
? `${statusConfig.bgColor} ${statusConfig.borderColor}`
|
||||||
@@ -278,7 +282,8 @@ export function TrialProgress({
|
|||||||
{/* Step Number & Status */}
|
{/* Step Number & Status */}
|
||||||
<div className="flex-shrink-0 space-y-1">
|
<div className="flex-shrink-0 space-y-1">
|
||||||
<div
|
<div
|
||||||
className={`flex h-8 w-12 items-center justify-center rounded-lg ${status === "active"
|
className={`flex h-8 w-12 items-center justify-center rounded-lg ${
|
||||||
|
status === "active"
|
||||||
? statusConfig.bgColor
|
? statusConfig.bgColor
|
||||||
: status === "completed"
|
: status === "completed"
|
||||||
? "bg-green-100"
|
? "bg-green-100"
|
||||||
@@ -288,7 +293,8 @@ export function TrialProgress({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`text-sm font-medium ${status === "active"
|
className={`text-sm font-medium ${
|
||||||
|
status === "active"
|
||||||
? statusConfig.textColor
|
? statusConfig.textColor
|
||||||
: status === "completed"
|
: status === "completed"
|
||||||
? "text-green-700"
|
? "text-green-700"
|
||||||
@@ -312,7 +318,8 @@ export function TrialProgress({
|
|||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h5
|
<h5
|
||||||
className={`truncate font-medium ${status === "active"
|
className={`truncate font-medium ${
|
||||||
|
status === "active"
|
||||||
? "text-slate-900"
|
? "text-slate-900"
|
||||||
: status === "completed"
|
: status === "completed"
|
||||||
? "text-green-900"
|
? "text-green-900"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Pause,
|
Pause,
|
||||||
SkipForward
|
SkipForward,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
@@ -78,11 +78,7 @@ interface StepData {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
type:
|
type: "wizard_action" | "robot_action" | "parallel_steps" | "conditional";
|
||||||
| "wizard_action"
|
|
||||||
| "robot_action"
|
|
||||||
| "parallel_steps"
|
|
||||||
| "conditional";
|
|
||||||
parameters: Record<string, unknown>;
|
parameters: Record<string, unknown>;
|
||||||
conditions?: {
|
conditions?: {
|
||||||
nextStepId?: string;
|
nextStepId?: string;
|
||||||
@@ -91,7 +87,13 @@ interface StepData {
|
|||||||
value: string;
|
value: string;
|
||||||
nextStepId?: string;
|
nextStepId?: string;
|
||||||
nextStepIndex?: number;
|
nextStepIndex?: number;
|
||||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
|
variant?:
|
||||||
|
| "default"
|
||||||
|
| "destructive"
|
||||||
|
| "outline"
|
||||||
|
| "secondary"
|
||||||
|
| "ghost"
|
||||||
|
| "link";
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
order: number;
|
order: number;
|
||||||
@@ -112,7 +114,9 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// UI State
|
// UI State
|
||||||
const [executionPanelTab, setExecutionPanelTab] = useState<"current" | "timeline" | "events">("timeline");
|
const [executionPanelTab, setExecutionPanelTab] = useState<
|
||||||
|
"current" | "timeline" | "events"
|
||||||
|
>("timeline");
|
||||||
|
|
||||||
const [isExecutingAction, setIsExecutingAction] = useState(false);
|
const [isExecutingAction, setIsExecutingAction] = useState(false);
|
||||||
const [monitoringPanelTab, setMonitoringPanelTab] = useState<
|
const [monitoringPanelTab, setMonitoringPanelTab] = useState<
|
||||||
@@ -189,11 +193,14 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
toast.success(`Robot action completed: ${execution.actionId}`);
|
toast.success(`Robot action completed: ${execution.actionId}`);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onActionFailed = useCallback((execution: { actionId: string; error?: string }) => {
|
const onActionFailed = useCallback(
|
||||||
|
(execution: { actionId: string; error?: string }) => {
|
||||||
toast.error(`Robot action failed: ${execution.actionId}`, {
|
toast.error(`Robot action failed: ${execution.actionId}`, {
|
||||||
description: execution.error,
|
description: execution.error,
|
||||||
});
|
});
|
||||||
}, []);
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// ROS WebSocket connection for robot control
|
// ROS WebSocket connection for robot control
|
||||||
const {
|
const {
|
||||||
@@ -218,7 +225,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
async (enabled: boolean) => {
|
async (enabled: boolean) => {
|
||||||
return setAutonomousLifeRaw(enabled);
|
return setAutonomousLifeRaw(enabled);
|
||||||
},
|
},
|
||||||
[setAutonomousLifeRaw]
|
[setAutonomousLifeRaw],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use polling for trial status updates (no trial WebSocket server exists)
|
// Use polling for trial status updates (no trial WebSocket server exists)
|
||||||
@@ -237,7 +244,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
{
|
{
|
||||||
refetchInterval: 3000,
|
refetchInterval: 3000,
|
||||||
staleTime: 1000,
|
staleTime: 1000,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update local trial state from polling only if changed
|
// Update local trial state from polling only if changed
|
||||||
@@ -245,15 +252,18 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
if (pollingData && JSON.stringify(pollingData) !== JSON.stringify(trial)) {
|
if (pollingData && JSON.stringify(pollingData) !== JSON.stringify(trial)) {
|
||||||
// Only update if specific fields we care about have changed to avoid
|
// Only update if specific fields we care about have changed to avoid
|
||||||
// unnecessary re-renders that might cause UI flashing
|
// unnecessary re-renders that might cause UI flashing
|
||||||
if (pollingData.status !== trial.status ||
|
if (
|
||||||
|
pollingData.status !== trial.status ||
|
||||||
pollingData.startedAt?.getTime() !== trial.startedAt?.getTime() ||
|
pollingData.startedAt?.getTime() !== trial.startedAt?.getTime() ||
|
||||||
pollingData.completedAt?.getTime() !== trial.completedAt?.getTime()) {
|
pollingData.completedAt?.getTime() !== trial.completedAt?.getTime()
|
||||||
|
) {
|
||||||
setTrial((prev) => {
|
setTrial((prev) => {
|
||||||
// Double check inside setter to be safe
|
// Double check inside setter to be safe
|
||||||
if (prev.status === pollingData.status &&
|
if (
|
||||||
|
prev.status === pollingData.status &&
|
||||||
prev.startedAt?.getTime() === pollingData.startedAt?.getTime() &&
|
prev.startedAt?.getTime() === pollingData.startedAt?.getTime() &&
|
||||||
prev.completedAt?.getTime() === pollingData.completedAt?.getTime()) {
|
prev.completedAt?.getTime() === pollingData.completedAt?.getTime()
|
||||||
|
) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -288,27 +298,38 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
message?: string;
|
message?: string;
|
||||||
}>
|
}>
|
||||||
>(() => {
|
>(() => {
|
||||||
return (fetchedEvents ?? []).map(event => {
|
return (fetchedEvents ?? [])
|
||||||
|
.map((event) => {
|
||||||
let message: string | undefined;
|
let message: string | undefined;
|
||||||
const eventData = event.data as any;
|
const eventData = event.data as any;
|
||||||
|
|
||||||
// Extract or generate message based on event type
|
// Extract or generate message based on event type
|
||||||
if (event.eventType.startsWith('annotation_')) {
|
if (event.eventType.startsWith("annotation_")) {
|
||||||
message = eventData?.description || eventData?.label || 'Annotation added';
|
message =
|
||||||
} else if (event.eventType.startsWith('robot_action_')) {
|
eventData?.description || eventData?.label || "Annotation added";
|
||||||
const actionName = event.eventType.replace('robot_action_', '').replace(/_/g, ' ');
|
} else if (event.eventType.startsWith("robot_action_")) {
|
||||||
|
const actionName = event.eventType
|
||||||
|
.replace("robot_action_", "")
|
||||||
|
.replace(/_/g, " ");
|
||||||
message = `Robot action: ${actionName}`;
|
message = `Robot action: ${actionName}`;
|
||||||
} else if (event.eventType === 'trial_started') {
|
} else if (event.eventType === "trial_started") {
|
||||||
message = 'Trial started';
|
message = "Trial started";
|
||||||
} else if (event.eventType === 'trial_completed') {
|
} else if (event.eventType === "trial_completed") {
|
||||||
message = 'Trial completed';
|
message = "Trial completed";
|
||||||
} else if (event.eventType === 'step_changed') {
|
} else if (event.eventType === "step_changed") {
|
||||||
message = `Step changed to: ${eventData?.stepName || 'next step'}`;
|
message = `Step changed to: ${eventData?.stepName || "next step"}`;
|
||||||
} else if (event.eventType.startsWith('wizard_')) {
|
} else if (event.eventType.startsWith("wizard_")) {
|
||||||
message = eventData?.notes || eventData?.message || event.eventType.replace('wizard_', '').replace(/_/g, ' ');
|
message =
|
||||||
|
eventData?.notes ||
|
||||||
|
eventData?.message ||
|
||||||
|
event.eventType.replace("wizard_", "").replace(/_/g, " ");
|
||||||
} else {
|
} else {
|
||||||
// Generic fallback
|
// Generic fallback
|
||||||
message = eventData?.notes || eventData?.message || eventData?.description || event.eventType.replace(/_/g, ' ');
|
message =
|
||||||
|
eventData?.notes ||
|
||||||
|
eventData?.message ||
|
||||||
|
eventData?.description ||
|
||||||
|
event.eventType.replace(/_/g, " ");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -317,21 +338,29 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
data: event.data,
|
data: event.data,
|
||||||
message,
|
message,
|
||||||
};
|
};
|
||||||
}).sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); // Newest first
|
})
|
||||||
|
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); // Newest first
|
||||||
}, [fetchedEvents]);
|
}, [fetchedEvents]);
|
||||||
|
|
||||||
// Transform experiment steps to component format
|
// Transform experiment steps to component format
|
||||||
const steps: StepData[] = useMemo(() =>
|
const steps: StepData[] = useMemo(
|
||||||
|
() =>
|
||||||
experimentSteps?.map((step, index) => ({
|
experimentSteps?.map((step, index) => ({
|
||||||
id: step.id,
|
id: step.id,
|
||||||
name: step.name ?? `Step ${index + 1}`,
|
name: step.name ?? `Step ${index + 1}`,
|
||||||
description: step.description,
|
description: step.description,
|
||||||
type: mapStepType(step.type),
|
type: mapStepType(step.type),
|
||||||
// Fix: Conditions are at root level from API
|
// Fix: Conditions are at root level from API
|
||||||
conditions: (step as any).conditions ?? (step as any).trigger?.conditions ?? undefined,
|
conditions:
|
||||||
|
(step as any).conditions ??
|
||||||
|
(step as any).trigger?.conditions ??
|
||||||
|
undefined,
|
||||||
parameters: step.parameters ?? {},
|
parameters: step.parameters ?? {},
|
||||||
order: step.order ?? index,
|
order: step.order ?? index,
|
||||||
actions: step.actions?.filter(a => a.type !== 'branch').map((action) => ({
|
actions:
|
||||||
|
step.actions
|
||||||
|
?.filter((a) => a.type !== "branch")
|
||||||
|
.map((action) => ({
|
||||||
id: action.id,
|
id: action.id,
|
||||||
name: action.name,
|
name: action.name,
|
||||||
description: action.description,
|
description: action.description,
|
||||||
@@ -340,8 +369,9 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
order: action.order,
|
order: action.order,
|
||||||
pluginId: action.pluginId,
|
pluginId: action.pluginId,
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
})) ?? [], [experimentSteps]);
|
})) ?? [],
|
||||||
|
[experimentSteps],
|
||||||
|
);
|
||||||
|
|
||||||
const currentStep = steps[currentStepIndex] ?? null;
|
const currentStep = steps[currentStepIndex] ?? null;
|
||||||
const totalSteps = steps.length;
|
const totalSteps = steps.length;
|
||||||
@@ -416,7 +446,9 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
completedAt: data.completedAt,
|
completedAt: data.completedAt,
|
||||||
});
|
});
|
||||||
toast.success("Trial completed! Redirecting to analysis...");
|
toast.success("Trial completed! Redirecting to analysis...");
|
||||||
router.push(`/studies/${trial.experiment.studyId}/trials/${trial.id}/analysis`);
|
router.push(
|
||||||
|
`/studies/${trial.experiment.studyId}/trials/${trial.id}/analysis`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -472,8 +504,6 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
const result = await startTrialMutation.mutateAsync({ id: trial.id });
|
const result = await startTrialMutation.mutateAsync({ id: trial.id });
|
||||||
console.log("[WizardInterface] Trial started successfully", result);
|
console.log("[WizardInterface] Trial started successfully", result);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Update local state immediately
|
// Update local state immediately
|
||||||
setTrial((prev) => ({
|
setTrial((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -506,7 +536,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
logEventMutation.mutate({
|
logEventMutation.mutate({
|
||||||
trialId: trial.id,
|
trialId: trial.id,
|
||||||
type: "trial_resumed",
|
type: "trial_resumed",
|
||||||
data: { timestamp: new Date() }
|
data: { timestamp: new Date() },
|
||||||
});
|
});
|
||||||
setIsPaused(false);
|
setIsPaused(false);
|
||||||
toast.success("Trial resumed");
|
toast.success("Trial resumed");
|
||||||
@@ -517,7 +547,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
|
|
||||||
const handleNextStep = (targetIndex?: number) => {
|
const handleNextStep = (targetIndex?: number) => {
|
||||||
// If explicit target provided (from branching choice), use it
|
// If explicit target provided (from branching choice), use it
|
||||||
if (typeof targetIndex === 'number') {
|
if (typeof targetIndex === "number") {
|
||||||
// Find step by index to ensure safety
|
// Find step by index to ensure safety
|
||||||
if (targetIndex >= 0 && targetIndex < steps.length) {
|
if (targetIndex >= 0 && targetIndex < steps.length) {
|
||||||
console.log(`[WizardInterface] Manual jump to step ${targetIndex}`);
|
console.log(`[WizardInterface] Manual jump to step ${targetIndex}`);
|
||||||
@@ -531,8 +561,8 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
toIndex: targetIndex,
|
toIndex: targetIndex,
|
||||||
fromStepId: steps[currentStepIndex]?.id,
|
fromStepId: steps[currentStepIndex]?.id,
|
||||||
toStepId: steps[targetIndex]?.id,
|
toStepId: steps[targetIndex]?.id,
|
||||||
reason: "manual_choice"
|
reason: "manual_choice",
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
setCompletedActionsCount(0);
|
setCompletedActionsCount(0);
|
||||||
@@ -546,13 +576,23 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
const currentStep = steps[currentStepIndex];
|
const currentStep = steps[currentStepIndex];
|
||||||
|
|
||||||
// Check if we have a stored response that dictates the next step
|
// Check if we have a stored response that dictates the next step
|
||||||
if (currentStep?.type === 'conditional' && currentStep.conditions?.options && lastResponse) {
|
if (
|
||||||
const matchedOption = currentStep.conditions.options.find(opt => opt.value === lastResponse);
|
currentStep?.type === "conditional" &&
|
||||||
|
currentStep.conditions?.options &&
|
||||||
|
lastResponse
|
||||||
|
) {
|
||||||
|
const matchedOption = currentStep.conditions.options.find(
|
||||||
|
(opt) => opt.value === lastResponse,
|
||||||
|
);
|
||||||
if (matchedOption && matchedOption.nextStepId) {
|
if (matchedOption && matchedOption.nextStepId) {
|
||||||
// Find index of the target step
|
// Find index of the target step
|
||||||
const targetIndex = steps.findIndex(s => s.id === matchedOption.nextStepId);
|
const targetIndex = steps.findIndex(
|
||||||
|
(s) => s.id === matchedOption.nextStepId,
|
||||||
|
);
|
||||||
if (targetIndex !== -1) {
|
if (targetIndex !== -1) {
|
||||||
console.log(`[WizardInterface] Branching to step ${targetIndex} (${matchedOption.label})`);
|
console.log(
|
||||||
|
`[WizardInterface] Branching to step ${targetIndex} (${matchedOption.label})`,
|
||||||
|
);
|
||||||
|
|
||||||
logEventMutation.mutate({
|
logEventMutation.mutate({
|
||||||
trialId: trial.id,
|
trialId: trial.id,
|
||||||
@@ -561,8 +601,8 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
fromIndex: currentStepIndex,
|
fromIndex: currentStepIndex,
|
||||||
toIndex: targetIndex,
|
toIndex: targetIndex,
|
||||||
condition: matchedOption.label,
|
condition: matchedOption.label,
|
||||||
value: lastResponse
|
value: lastResponse,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
setCurrentStepIndex(targetIndex);
|
setCurrentStepIndex(targetIndex);
|
||||||
@@ -573,12 +613,17 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for explicit nextStepId in conditions (e.g. for end of branch)
|
// Check for explicit nextStepId in conditions (e.g. for end of branch)
|
||||||
console.log("[WizardInterface] Checking for nextStepId condition:", currentStep?.conditions);
|
console.log(
|
||||||
|
"[WizardInterface] Checking for nextStepId condition:",
|
||||||
|
currentStep?.conditions,
|
||||||
|
);
|
||||||
if (currentStep?.conditions?.nextStepId) {
|
if (currentStep?.conditions?.nextStepId) {
|
||||||
const nextId = String(currentStep.conditions.nextStepId);
|
const nextId = String(currentStep.conditions.nextStepId);
|
||||||
const targetIndex = steps.findIndex(s => s.id === nextId);
|
const targetIndex = steps.findIndex((s) => s.id === nextId);
|
||||||
if (targetIndex !== -1) {
|
if (targetIndex !== -1) {
|
||||||
console.log(`[WizardInterface] Condition-based jump to step ${targetIndex} (${nextId})`);
|
console.log(
|
||||||
|
`[WizardInterface] Condition-based jump to step ${targetIndex} (${nextId})`,
|
||||||
|
);
|
||||||
|
|
||||||
logEventMutation.mutate({
|
logEventMutation.mutate({
|
||||||
trialId: trial.id,
|
trialId: trial.id,
|
||||||
@@ -586,12 +631,12 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
data: {
|
data: {
|
||||||
fromIndex: currentStepIndex,
|
fromIndex: currentStepIndex,
|
||||||
toIndex: targetIndex,
|
toIndex: targetIndex,
|
||||||
reason: "condition_next_step"
|
reason: "condition_next_step",
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mark steps as skipped
|
// Mark steps as skipped
|
||||||
setSkippedSteps(prev => {
|
setSkippedSteps((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
for (let i = currentStepIndex + 1; i < targetIndex; i++) {
|
for (let i = currentStepIndex + 1; i < targetIndex; i++) {
|
||||||
if (!completedSteps.has(i)) {
|
if (!completedSteps.has(i)) {
|
||||||
@@ -602,7 +647,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Mark current as complete
|
// Mark current as complete
|
||||||
setCompletedSteps(prev => {
|
setCompletedSteps((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.add(currentStepIndex);
|
next.add(currentStepIndex);
|
||||||
return next;
|
return next;
|
||||||
@@ -612,17 +657,21 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
setCompletedActionsCount(0);
|
setCompletedActionsCount(0);
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
console.warn(`[WizardInterface] Targeted nextStepId ${nextId} not found in steps list.`);
|
console.warn(
|
||||||
|
`[WizardInterface] Targeted nextStepId ${nextId} not found in steps list.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log("[WizardInterface] No nextStepId found in conditions, proceeding linearly.");
|
console.log(
|
||||||
|
"[WizardInterface] No nextStepId found in conditions, proceeding linearly.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: Linear progression
|
// Default: Linear progression
|
||||||
const nextIndex = currentStepIndex + 1;
|
const nextIndex = currentStepIndex + 1;
|
||||||
if (nextIndex < steps.length) {
|
if (nextIndex < steps.length) {
|
||||||
// Mark current step as complete
|
// Mark current step as complete
|
||||||
setCompletedSteps(prev => {
|
setCompletedSteps((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.add(currentStepIndex);
|
next.add(currentStepIndex);
|
||||||
return next;
|
return next;
|
||||||
@@ -638,8 +687,8 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
fromStepId: currentStep?.id,
|
fromStepId: currentStep?.id,
|
||||||
toStepId: steps[nextIndex]?.id,
|
toStepId: steps[nextIndex]?.id,
|
||||||
stepName: steps[nextIndex]?.name,
|
stepName: steps[nextIndex]?.name,
|
||||||
method: "auto"
|
method: "auto",
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
setCurrentStepIndex(nextIndex);
|
setCurrentStepIndex(nextIndex);
|
||||||
@@ -661,8 +710,8 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
fromStepId: currentStep?.id,
|
fromStepId: currentStep?.id,
|
||||||
toStepId: steps[index]?.id,
|
toStepId: steps[index]?.id,
|
||||||
stepName: steps[index]?.name,
|
stepName: steps[index]?.name,
|
||||||
method: "manual"
|
method: "manual",
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mark current as complete if leaving it?
|
// Mark current as complete if leaving it?
|
||||||
@@ -676,7 +725,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
const handleCompleteTrial = async () => {
|
const handleCompleteTrial = async () => {
|
||||||
try {
|
try {
|
||||||
// Mark final step as complete
|
// Mark final step as complete
|
||||||
setCompletedSteps(prev => {
|
setCompletedSteps((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.add(currentStepIndex);
|
next.add(currentStepIndex);
|
||||||
return next;
|
return next;
|
||||||
@@ -692,7 +741,9 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
archiveTrialMutation.mutate({ id: trial.id });
|
archiveTrialMutation.mutate({ id: trial.id });
|
||||||
|
|
||||||
// Immediately navigate to analysis
|
// Immediately navigate to analysis
|
||||||
router.push(`/studies/${trial.experiment.studyId}/trials/${trial.id}/analysis`);
|
router.push(
|
||||||
|
`/studies/${trial.experiment.studyId}/trials/${trial.id}/analysis`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to complete trial:", error);
|
console.error("Failed to complete trial:", error);
|
||||||
}
|
}
|
||||||
@@ -701,8 +752,6 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
const handleAbortTrial = async () => {
|
const handleAbortTrial = async () => {
|
||||||
try {
|
try {
|
||||||
await abortTrialMutation.mutateAsync({ id: trial.id });
|
await abortTrialMutation.mutateAsync({ id: trial.id });
|
||||||
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to abort trial:", error);
|
console.error("Failed to abort trial:", error);
|
||||||
}
|
}
|
||||||
@@ -731,8 +780,6 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Mutation for interventions
|
// Mutation for interventions
|
||||||
const addInterventionMutation = api.trials.addIntervention.useMutation({
|
const addInterventionMutation = api.trials.addIntervention.useMutation({
|
||||||
onSuccess: () => toast.success("Intervention logged"),
|
onSuccess: () => toast.success("Intervention logged"),
|
||||||
@@ -753,9 +800,11 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
// If nextStepId is provided, jump immediately
|
// If nextStepId is provided, jump immediately
|
||||||
if (parameters.nextStepId) {
|
if (parameters.nextStepId) {
|
||||||
const nextId = String(parameters.nextStepId);
|
const nextId = String(parameters.nextStepId);
|
||||||
const targetIndex = steps.findIndex(s => s.id === nextId);
|
const targetIndex = steps.findIndex((s) => s.id === nextId);
|
||||||
if (targetIndex !== -1) {
|
if (targetIndex !== -1) {
|
||||||
console.log(`[WizardInterface] Choice-based jump to step ${targetIndex} (${nextId})`);
|
console.log(
|
||||||
|
`[WizardInterface] Choice-based jump to step ${targetIndex} (${nextId})`,
|
||||||
|
);
|
||||||
handleNextStep(targetIndex);
|
handleNextStep(targetIndex);
|
||||||
return; // Exit after jump
|
return; // Exit after jump
|
||||||
}
|
}
|
||||||
@@ -780,7 +829,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
await addAnnotationMutation.mutateAsync({
|
await addAnnotationMutation.mutateAsync({
|
||||||
trialId: trial.id,
|
trialId: trial.id,
|
||||||
description: String(parameters?.content || "Quick note"),
|
description: String(parameters?.content || "Quick note"),
|
||||||
category: String(parameters?.category || "quick_note")
|
category: String(parameters?.category || "quick_note"),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Generic action logging - now with more details
|
// Generic action logging - now with more details
|
||||||
@@ -789,11 +838,17 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
let actionType = "unknown";
|
let actionType = "unknown";
|
||||||
|
|
||||||
// Helper to search recursively
|
// Helper to search recursively
|
||||||
const findAction = (actions: ActionData[], id: string): ActionData | undefined => {
|
const findAction = (
|
||||||
|
actions: ActionData[],
|
||||||
|
id: string,
|
||||||
|
): ActionData | undefined => {
|
||||||
for (const action of actions) {
|
for (const action of actions) {
|
||||||
if (action.id === id) return action;
|
if (action.id === id) return action;
|
||||||
if (action.parameters?.children) {
|
if (action.parameters?.children) {
|
||||||
const found = findAction(action.parameters.children as ActionData[], id);
|
const found = findAction(
|
||||||
|
action.parameters.children as ActionData[],
|
||||||
|
id,
|
||||||
|
);
|
||||||
if (found) return found;
|
if (found) return found;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -821,10 +876,13 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
actionType = foundAction.type;
|
actionType = foundAction.type;
|
||||||
} else {
|
} else {
|
||||||
// Fallback for Wizard Actions (often have label/value in parameters)
|
// Fallback for Wizard Actions (often have label/value in parameters)
|
||||||
if (parameters?.label && typeof parameters.label === 'string') {
|
if (parameters?.label && typeof parameters.label === "string") {
|
||||||
actionName = parameters.label;
|
actionName = parameters.label;
|
||||||
actionType = "wizard_button";
|
actionType = "wizard_button";
|
||||||
} else if (parameters?.value && typeof parameters.value === 'string') {
|
} else if (
|
||||||
|
parameters?.value &&
|
||||||
|
typeof parameters.value === "string"
|
||||||
|
) {
|
||||||
actionName = parameters.value;
|
actionName = parameters.value;
|
||||||
actionType = "wizard_input";
|
actionType = "wizard_input";
|
||||||
}
|
}
|
||||||
@@ -837,8 +895,8 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
actionId,
|
actionId,
|
||||||
actionName,
|
actionName,
|
||||||
actionType,
|
actionType,
|
||||||
parameters
|
parameters,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -877,7 +935,11 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
// Try direct WebSocket execution first for better performance
|
// Try direct WebSocket execution first for better performance
|
||||||
if (rosConnected) {
|
if (rosConnected) {
|
||||||
try {
|
try {
|
||||||
const result = await executeRosAction(pluginName, actionId, parameters);
|
const result = await executeRosAction(
|
||||||
|
pluginName,
|
||||||
|
actionId,
|
||||||
|
parameters,
|
||||||
|
);
|
||||||
|
|
||||||
const duration =
|
const duration =
|
||||||
result.endTime && result.startTime
|
result.endTime && result.startTime
|
||||||
@@ -962,8 +1024,8 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
type: "intervention_action_skipped",
|
type: "intervention_action_skipped",
|
||||||
data: {
|
data: {
|
||||||
actionId,
|
actionId,
|
||||||
parameters
|
parameters,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -979,18 +1041,19 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
[logRobotActionMutation, trial.id, logEventMutation, handleNextStep],
|
[logRobotActionMutation, trial.id, logEventMutation, handleNextStep],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleLogEvent = useCallback((type: string, data?: any) => {
|
const handleLogEvent = useCallback(
|
||||||
|
(type: string, data?: any) => {
|
||||||
logEventMutation.mutate({
|
logEventMutation.mutate({
|
||||||
trialId: trial.id,
|
trialId: trial.id,
|
||||||
type,
|
type,
|
||||||
data
|
data,
|
||||||
});
|
});
|
||||||
}, [logEventMutation, trial.id]);
|
},
|
||||||
|
[logEventMutation, trial.id],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-5rem)] w-full flex-col overflow-hidden bg-background">
|
<div className="bg-background flex h-[calc(100vh-5rem)] w-full flex-col overflow-hidden">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Trial Execution"
|
title="Trial Execution"
|
||||||
description={`Session ${trial.sessionNumber} • Participant ${trial.participant.participantCode}`}
|
description={`Session ${trial.sessionNumber} • Participant ${trial.participant.participantCode}`}
|
||||||
@@ -998,11 +1061,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
actions={
|
actions={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{trial.status === "scheduled" && (
|
{trial.status === "scheduled" && (
|
||||||
<Button
|
<Button onClick={handleStartTrial} size="sm" className="gap-2">
|
||||||
onClick={handleStartTrial}
|
|
||||||
size="sm"
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<Play className="h-4 w-4" />
|
<Play className="h-4 w-4" />
|
||||||
Start Trial
|
Start Trial
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1016,7 +1075,11 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
onClick={isPaused ? handleResumeTrial : handlePauseTrial}
|
onClick={isPaused ? handleResumeTrial : handlePauseTrial}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
{isPaused ? <Play className="h-4 w-4" /> : <Pause className="h-4 w-4" />}
|
{isPaused ? (
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Pause className="h-4 w-4" />
|
||||||
|
)}
|
||||||
{isPaused ? "Resume" : "Pause"}
|
{isPaused ? "Resume" : "Pause"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -1065,11 +1128,10 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main Grid - Single Row */}
|
{/* Main Grid - Single Row */}
|
||||||
<div className="flex-1 min-h-0 flex gap-2 px-2 pb-2">
|
<div className="flex min-h-0 flex-1 gap-2 px-2 pb-2">
|
||||||
|
|
||||||
{/* Center - Execution Workspace */}
|
{/* Center - Execution Workspace */}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm">
|
<div className="bg-background flex flex-1 flex-col overflow-hidden rounded-lg border shadow-sm">
|
||||||
<div className="flex items-center border-b px-3 py-2 bg-muted/30 min-h-[45px]">
|
<div className="bg-muted/30 flex min-h-[45px] items-center border-b px-3 py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium">Trial Execution</span>
|
<span className="text-sm font-medium">Trial Execution</span>
|
||||||
{currentStep && (
|
{currentStep && (
|
||||||
@@ -1081,7 +1143,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
<div className="mr-2 text-xs text-muted-foreground font-medium">
|
<div className="text-muted-foreground mr-2 text-xs font-medium">
|
||||||
Step {currentStepIndex + 1} / {steps.length}
|
Step {currentStepIndex + 1} / {steps.length}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1097,7 +1159,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto bg-muted/10 pb-0">
|
<div className="bg-muted/10 flex-1 overflow-auto pb-0">
|
||||||
<div id="tour-wizard-timeline" className="h-full">
|
<div id="tour-wizard-timeline" className="h-full">
|
||||||
<WizardExecutionPanel
|
<WizardExecutionPanel
|
||||||
trial={trial}
|
trial={trial}
|
||||||
@@ -1116,9 +1178,11 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
isExecuting={isExecutingAction}
|
isExecuting={isExecutingAction}
|
||||||
onNextStep={handleNextStep}
|
onNextStep={handleNextStep}
|
||||||
completedActionsCount={completedActionsCount}
|
completedActionsCount={completedActionsCount}
|
||||||
onActionCompleted={() => setCompletedActionsCount(c => c + 1)}
|
onActionCompleted={() => setCompletedActionsCount((c) => c + 1)}
|
||||||
onCompleteTrial={handleCompleteTrial}
|
onCompleteTrial={handleCompleteTrial}
|
||||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
readOnly={
|
||||||
|
trial.status === "completed" || _userRole === "observer"
|
||||||
|
}
|
||||||
rosConnected={rosConnected}
|
rosConnected={rosConnected}
|
||||||
onLogEvent={handleLogEvent}
|
onLogEvent={handleLogEvent}
|
||||||
/>
|
/>
|
||||||
@@ -1127,11 +1191,13 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Sidebar - Tools Tabs (Collapsible) */}
|
{/* Right Sidebar - Tools Tabs (Collapsible) */}
|
||||||
<div className={cn(
|
<div
|
||||||
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm w-[350px] lg:w-[400px]",
|
className={cn(
|
||||||
rightCollapsed && "hidden"
|
"bg-background flex w-[350px] flex-col overflow-hidden rounded-lg border shadow-sm lg:w-[400px]",
|
||||||
)}>
|
rightCollapsed && "hidden",
|
||||||
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30 shrink-0">
|
)}
|
||||||
|
>
|
||||||
|
<div className="bg-muted/30 flex shrink-0 items-center justify-between border-b px-3 py-2">
|
||||||
<span className="text-sm font-medium">Tools</span>
|
<span className="text-sm font-medium">Tools</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -1142,29 +1208,46 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
<PanelRightClose className="h-4 w-4" />
|
<PanelRightClose className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-hidden bg-background">
|
<div className="bg-background flex-1 overflow-hidden">
|
||||||
<Tabs defaultValue="camera_obs" className="flex flex-col h-full w-full">
|
<Tabs
|
||||||
<TabsList className="w-full justify-start rounded-none border-b bg-muted/30 px-3 py-1 shrink-0 h-10">
|
defaultValue="camera_obs"
|
||||||
<TabsTrigger value="camera_obs" className="text-xs flex-1">Camera & Obs</TabsTrigger>
|
className="flex h-full w-full flex-col"
|
||||||
<TabsTrigger value="robot" className="text-xs flex-1">Robot Control</TabsTrigger>
|
>
|
||||||
|
<TabsList className="bg-muted/30 h-10 w-full shrink-0 justify-start rounded-none border-b px-3 py-1">
|
||||||
|
<TabsTrigger value="camera_obs" className="flex-1 text-xs">
|
||||||
|
Camera & Obs
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="robot" className="flex-1 text-xs">
|
||||||
|
Robot Control
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="camera_obs" className="flex-1 flex-col m-0 p-0 h-full overflow-hidden min-h-0 data-[state=active]:flex">
|
<TabsContent
|
||||||
<div className="flex-none bg-muted/30 border-b h-48 sm:h-56 relative group shrink-0">
|
value="camera_obs"
|
||||||
<WebcamPanel readOnly={trial.status === 'completed'} trialId={trial.id} trialStatus={trial.status} />
|
className="m-0 h-full min-h-0 flex-1 flex-col overflow-hidden p-0 data-[state=active]:flex"
|
||||||
|
>
|
||||||
|
<div className="bg-muted/30 group relative h-48 flex-none shrink-0 border-b sm:h-56">
|
||||||
|
<WebcamPanel
|
||||||
|
readOnly={trial.status === "completed"}
|
||||||
|
trialId={trial.id}
|
||||||
|
trialStatus={trial.status}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto min-h-0 bg-muted/10">
|
<div className="bg-muted/10 min-h-0 flex-1 overflow-auto">
|
||||||
<WizardObservationPane
|
<WizardObservationPane
|
||||||
onAddAnnotation={handleAddAnnotation}
|
onAddAnnotation={handleAddAnnotation}
|
||||||
onFlagIntervention={() => handleExecuteAction("intervene")}
|
onFlagIntervention={() => handleExecuteAction("intervene")}
|
||||||
isSubmitting={addAnnotationMutation.isPending}
|
isSubmitting={addAnnotationMutation.isPending}
|
||||||
trialEvents={trialEvents}
|
trialEvents={trialEvents}
|
||||||
readOnly={trial.status === 'completed'}
|
readOnly={trial.status === "completed"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="robot" className="flex-1 flex-col m-0 p-0 h-full overflow-hidden min-h-0 data-[state=active]:flex">
|
<TabsContent
|
||||||
|
value="robot"
|
||||||
|
className="m-0 h-full min-h-0 flex-1 flex-col overflow-hidden p-0 data-[state=active]:flex"
|
||||||
|
>
|
||||||
<WizardMonitoringPanel
|
<WizardMonitoringPanel
|
||||||
rosConnected={rosConnected}
|
rosConnected={rosConnected}
|
||||||
rosConnecting={rosConnecting}
|
rosConnecting={rosConnecting}
|
||||||
@@ -1178,7 +1261,9 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
studyId={trial.experiment.studyId}
|
studyId={trial.experiment.studyId}
|
||||||
trialId={trial.id}
|
trialId={trial.id}
|
||||||
trialStatus={trial.status}
|
trialStatus={trial.status}
|
||||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
readOnly={
|
||||||
|
trial.status === "completed" || _userRole === "observer"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -61,14 +61,16 @@ export function TrialStatusBar({
|
|||||||
>
|
>
|
||||||
{/* Step Progress */}
|
{/* Step Progress */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
<span className="text-muted-foreground flex items-center gap-1.5">
|
||||||
<GitBranch className="h-3.5 w-3.5 opacity-70" />
|
<GitBranch className="h-3.5 w-3.5 opacity-70" />
|
||||||
Step {currentStepIndex + 1}/{totalSteps}
|
Step {currentStepIndex + 1}/{totalSteps}
|
||||||
</span>
|
</span>
|
||||||
<div className="w-20">
|
<div className="w-20">
|
||||||
<Progress value={progressPercentage} className="h-1.5" />
|
<Progress value={progressPercentage} className="h-1.5" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-muted-foreground/70">{Math.round(progressPercentage)}%</span>
|
<span className="text-muted-foreground/70">
|
||||||
|
{Math.round(progressPercentage)}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator orientation="vertical" className="h-4 opacity-50" />
|
<Separator orientation="vertical" className="h-4 opacity-50" />
|
||||||
@@ -77,7 +79,7 @@ export function TrialStatusBar({
|
|||||||
{totalActionsCount > 0 && (
|
{totalActionsCount > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
<span className="text-muted-foreground flex items-center gap-1.5">
|
||||||
<Sparkles className="h-3.5 w-3.5 opacity-70" />
|
<Sparkles className="h-3.5 w-3.5 opacity-70" />
|
||||||
{completedActionsCount}/{totalActionsCount} actions
|
{completedActionsCount}/{totalActionsCount} actions
|
||||||
</span>
|
</span>
|
||||||
@@ -90,19 +92,25 @@ export function TrialStatusBar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Trial Stats */}
|
{/* Trial Stats */}
|
||||||
<div className="flex items-center gap-3 text-muted-foreground">
|
<div className="text-muted-foreground flex items-center gap-3">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<Clock className="h-3.5 w-3.5 opacity-70" />
|
<Clock className="h-3.5 w-3.5 opacity-70" />
|
||||||
{eventsCount} events
|
{eventsCount} events
|
||||||
</span>
|
</span>
|
||||||
{trialStatus === "in_progress" && (
|
{trialStatus === "in_progress" && (
|
||||||
<Badge variant="default" className="h-5 gap-1 bg-emerald-500 px-1.5 text-[10px] font-normal">
|
<Badge
|
||||||
|
variant="default"
|
||||||
|
className="h-5 gap-1 bg-emerald-500 px-1.5 text-[10px] font-normal"
|
||||||
|
>
|
||||||
<Play className="h-2.5 w-2.5" />
|
<Play className="h-2.5 w-2.5" />
|
||||||
Live
|
Live
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{trialStatus === "completed" && (
|
{trialStatus === "completed" && (
|
||||||
<Badge variant="secondary" className="h-5 gap-1 px-1.5 text-[10px] font-normal">
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="h-5 gap-1 px-1.5 text-[10px] font-normal"
|
||||||
|
>
|
||||||
<CheckCircle2 className="h-2.5 w-2.5" />
|
<CheckCircle2 className="h-2.5 w-2.5" />
|
||||||
Completed
|
Completed
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -9,7 +9,15 @@ import { AspectRatio } from "~/components/ui/aspect-ratio";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
|
|
||||||
export function WebcamPanel({ readOnly = false, trialId, trialStatus }: { readOnly?: boolean; trialId?: string; trialStatus?: string }) {
|
export function WebcamPanel({
|
||||||
|
readOnly = false,
|
||||||
|
trialId,
|
||||||
|
trialStatus,
|
||||||
|
}: {
|
||||||
|
readOnly?: boolean;
|
||||||
|
trialId?: string;
|
||||||
|
trialStatus?: string;
|
||||||
|
}) {
|
||||||
const [isCameraEnabled, setIsCameraEnabled] = useState(false);
|
const [isCameraEnabled, setIsCameraEnabled] = useState(false);
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
@@ -70,7 +78,10 @@ export function WebcamPanel({ readOnly = false, trialId, trialStatus }: { readOn
|
|||||||
|
|
||||||
const handleStartRecording = () => {
|
const handleStartRecording = () => {
|
||||||
if (!webcamRef.current?.stream) return;
|
if (!webcamRef.current?.stream) return;
|
||||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state === "recording") {
|
if (
|
||||||
|
mediaRecorderRef.current &&
|
||||||
|
mediaRecorderRef.current.state === "recording"
|
||||||
|
) {
|
||||||
console.log("Already recording, skipping start");
|
console.log("Already recording, skipping start");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -80,7 +91,7 @@ export function WebcamPanel({ readOnly = false, trialId, trialStatus }: { readOn
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const recorder = new MediaRecorder(webcamRef.current.stream, {
|
const recorder = new MediaRecorder(webcamRef.current.stream, {
|
||||||
mimeType: "video/webm"
|
mimeType: "video/webm",
|
||||||
});
|
});
|
||||||
|
|
||||||
recorder.ondataavailable = (event) => {
|
recorder.ondataavailable = (event) => {
|
||||||
@@ -100,7 +111,7 @@ export function WebcamPanel({ readOnly = false, trialId, trialStatus }: { readOn
|
|||||||
logEventMutation.mutate({
|
logEventMutation.mutate({
|
||||||
trialId,
|
trialId,
|
||||||
type: "camera_started",
|
type: "camera_started",
|
||||||
data: { action: "recording_started" }
|
data: { action: "recording_started" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
toast.success("Recording started");
|
toast.success("Recording started");
|
||||||
@@ -112,14 +123,18 @@ export function WebcamPanel({ readOnly = false, trialId, trialStatus }: { readOn
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleStopRecording = () => {
|
const handleStopRecording = () => {
|
||||||
if (mediaRecorderRef.current && isRecording && mediaRecorderRef.current.state === "recording") {
|
if (
|
||||||
|
mediaRecorderRef.current &&
|
||||||
|
isRecording &&
|
||||||
|
mediaRecorderRef.current.state === "recording"
|
||||||
|
) {
|
||||||
mediaRecorderRef.current.stop();
|
mediaRecorderRef.current.stop();
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
if (trialId) {
|
if (trialId) {
|
||||||
logEventMutation.mutate({
|
logEventMutation.mutate({
|
||||||
trialId,
|
trialId,
|
||||||
type: "camera_stopped",
|
type: "camera_stopped",
|
||||||
data: { action: "recording_stopped" }
|
data: { action: "recording_stopped" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,7 +162,9 @@ export function WebcamPanel({ readOnly = false, trialId, trialStatus }: { readOn
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
throw new Error(`Upload failed: ${errorText} | Status: ${response.status}`);
|
throw new Error(
|
||||||
|
`Upload failed: ${errorText} | Status: ${response.status}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Save metadata to DB
|
// 3. Save metadata to DB
|
||||||
@@ -168,7 +185,10 @@ export function WebcamPanel({ readOnly = false, trialId, trialStatus }: { readOn
|
|||||||
toast.error("Video uploaded but failed to link to trial");
|
toast.error("Video uploaded but failed to link to trial");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn("No trialId provided, recording uploaded but not linked. Props:", { trialId });
|
console.warn(
|
||||||
|
"No trialId provided, recording uploaded but not linked. Props:",
|
||||||
|
{ trialId },
|
||||||
|
);
|
||||||
toast.warning("Trial ID missing - recording not linked");
|
toast.warning("Trial ID missing - recording not linked");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,17 +204,15 @@ export function WebcamPanel({ readOnly = false, trialId, trialStatus }: { readOn
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex items-center justify-end border-b px-2 py-1 bg-muted/10 h-10 shrink-0">
|
<div className="bg-muted/10 flex h-10 shrink-0 items-center justify-end border-b px-2 py-1">
|
||||||
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{isCameraEnabled &&
|
||||||
{isCameraEnabled && (
|
(!isRecording ? (
|
||||||
!isRecording ? (
|
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 px-2 text-xs animate-in fade-in"
|
className="animate-in fade-in h-7 px-2 text-xs"
|
||||||
onClick={handleStartRecording}
|
onClick={handleStartRecording}
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
>
|
>
|
||||||
@@ -205,20 +223,19 @@ export function WebcamPanel({ readOnly = false, trialId, trialStatus }: { readOn
|
|||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 px-2 text-xs border-red-500 border text-red-500 hover:bg-red-50"
|
className="h-7 border border-red-500 px-2 text-xs text-red-500 hover:bg-red-50"
|
||||||
onClick={handleStopRecording}
|
onClick={handleStopRecording}
|
||||||
>
|
>
|
||||||
<StopCircle className="mr-1 h-3 w-3 animate-pulse" />
|
<StopCircle className="mr-1 h-3 w-3 animate-pulse" />
|
||||||
Stop Rec
|
Stop Rec
|
||||||
</Button>
|
</Button>
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
|
|
||||||
{isCameraEnabled ? (
|
{isCameraEnabled ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
|
||||||
onClick={handleDisableCamera}
|
onClick={handleDisableCamera}
|
||||||
disabled={isRecording}
|
disabled={isRecording}
|
||||||
>
|
>
|
||||||
@@ -240,9 +257,9 @@ export function WebcamPanel({ readOnly = false, trialId, trialStatus }: { readOn
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden bg-muted/50 p-4 flex items-center justify-center relative">
|
<div className="bg-muted/50 relative flex flex-1 items-center justify-center overflow-hidden p-4">
|
||||||
{isCameraEnabled ? (
|
{isCameraEnabled ? (
|
||||||
<div className="w-full relative rounded-lg overflow-hidden border border-border shadow-sm bg-black">
|
<div className="border-border relative w-full overflow-hidden rounded-lg border bg-black shadow-sm">
|
||||||
<AspectRatio ratio={16 / 9}>
|
<AspectRatio ratio={16 / 9}>
|
||||||
<Webcam
|
<Webcam
|
||||||
ref={webcamRef}
|
ref={webcamRef}
|
||||||
@@ -251,14 +268,14 @@ export function WebcamPanel({ readOnly = false, trialId, trialStatus }: { readOn
|
|||||||
height="100%"
|
height="100%"
|
||||||
onUserMedia={handleUserMedia}
|
onUserMedia={handleUserMedia}
|
||||||
onUserMediaError={(err) => setError(String(err))}
|
onUserMediaError={(err) => setError(String(err))}
|
||||||
className="object-contain w-full h-full"
|
className="h-full w-full object-contain"
|
||||||
/>
|
/>
|
||||||
</AspectRatio>
|
</AspectRatio>
|
||||||
|
|
||||||
{/* Recording Overlay */}
|
{/* Recording Overlay */}
|
||||||
{isRecording && (
|
{isRecording && (
|
||||||
<div className="absolute top-2 right-2 flex items-center gap-2 bg-black/50 px-2 py-1 rounded-full backdrop-blur-sm">
|
<div className="absolute top-2 right-2 flex items-center gap-2 rounded-full bg-black/50 px-2 py-1 backdrop-blur-sm">
|
||||||
<div className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
|
<div className="h-2 w-2 animate-pulse rounded-full bg-red-500" />
|
||||||
<span className="text-[10px] font-medium text-white">REC</span>
|
<span className="text-[10px] font-medium text-white">REC</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -282,8 +299,8 @@ export function WebcamPanel({ readOnly = false, trialId, trialStatus }: { readOn
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center text-muted-foreground/50">
|
<div className="text-muted-foreground/50 text-center">
|
||||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
<div className="bg-muted mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full">
|
||||||
<CameraOff className="h-6 w-6 opacity-50" />
|
<CameraOff className="h-6 w-6 opacity-50" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium">Camera is disabled</p>
|
<p className="text-sm font-medium">Camera is disabled</p>
|
||||||
@@ -298,6 +315,6 @@ export function WebcamPanel({ readOnly = false, trialId, trialStatus }: { readOn
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div >
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,13 +34,13 @@ interface WizardActionItemProps {
|
|||||||
pluginName: string,
|
pluginName: string,
|
||||||
actionId: string,
|
actionId: string,
|
||||||
parameters: Record<string, unknown>,
|
parameters: Record<string, unknown>,
|
||||||
options?: { autoAdvance?: boolean }
|
options?: { autoAdvance?: boolean },
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
onSkip: (
|
onSkip: (
|
||||||
pluginName: string,
|
pluginName: string,
|
||||||
actionId: string,
|
actionId: string,
|
||||||
parameters: Record<string, unknown>,
|
parameters: Record<string, unknown>,
|
||||||
options?: { autoAdvance?: boolean }
|
options?: { autoAdvance?: boolean },
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
onCompleted: () => void;
|
onCompleted: () => void;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
@@ -66,7 +66,9 @@ export function WizardActionItem({
|
|||||||
onLogEvent,
|
onLogEvent,
|
||||||
}: WizardActionItemProps): React.JSX.Element {
|
}: WizardActionItemProps): React.JSX.Element {
|
||||||
// Local state for container children completion
|
// Local state for container children completion
|
||||||
const [completedChildren, setCompletedChildren] = useState<Set<number>>(new Set());
|
const [completedChildren, setCompletedChildren] = useState<Set<number>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
// Local state for loop iterations
|
// Local state for loop iterations
|
||||||
const [currentIteration, setCurrentIteration] = useState(1);
|
const [currentIteration, setCurrentIteration] = useState(1);
|
||||||
// Local state to track execution of this specific item
|
// Local state to track execution of this specific item
|
||||||
@@ -83,8 +85,10 @@ export function WizardActionItem({
|
|||||||
action.type === "loop";
|
action.type === "loop";
|
||||||
|
|
||||||
// Branch support
|
// Branch support
|
||||||
const isBranch = action.type === "hristudio-core.branch" || action.type === "branch";
|
const isBranch =
|
||||||
const isWait = action.type === "hristudio-core.wait" || action.type === "wait";
|
action.type === "hristudio-core.branch" || action.type === "branch";
|
||||||
|
const isWait =
|
||||||
|
action.type === "hristudio-core.wait" || action.type === "wait";
|
||||||
|
|
||||||
// Helper to get children
|
// Helper to get children
|
||||||
const children = (action.parameters.children as ActionData[]) || [];
|
const children = (action.parameters.children as ActionData[]) || [];
|
||||||
@@ -116,12 +120,15 @@ export function WizardActionItem({
|
|||||||
const isButtonDisabled =
|
const isButtonDisabled =
|
||||||
isExecuting ||
|
isExecuting ||
|
||||||
isRunningLocal ||
|
isRunningLocal ||
|
||||||
(!isWait && !isRobotConnected && (action.type === 'robot_action' || !!action.pluginId || (isContainer && containsRobotActions)));
|
(!isWait &&
|
||||||
|
!isRobotConnected &&
|
||||||
|
(action.type === "robot_action" ||
|
||||||
|
!!action.pluginId ||
|
||||||
|
(isContainer && containsRobotActions)));
|
||||||
|
|
||||||
// Handler for child completion
|
// Handler for child completion
|
||||||
const handleChildCompleted = useCallback((childIndex: number) => {
|
const handleChildCompleted = useCallback((childIndex: number) => {
|
||||||
setCompletedChildren(prev => {
|
setCompletedChildren((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.add(childIndex);
|
next.add(childIndex);
|
||||||
return next;
|
return next;
|
||||||
@@ -132,21 +139,23 @@ export function WizardActionItem({
|
|||||||
const handleNextIteration = useCallback(() => {
|
const handleNextIteration = useCallback(() => {
|
||||||
if (currentIteration < iterations) {
|
if (currentIteration < iterations) {
|
||||||
setCompletedChildren(new Set());
|
setCompletedChildren(new Set());
|
||||||
setCurrentIteration(prev => prev + 1);
|
setCurrentIteration((prev) => prev + 1);
|
||||||
} else {
|
} else {
|
||||||
// Loop finished - allow manual completion of the loop action
|
// Loop finished - allow manual completion of the loop action
|
||||||
}
|
}
|
||||||
}, [currentIteration, iterations]);
|
}, [currentIteration, iterations]);
|
||||||
|
|
||||||
// Check if current iteration is complete (all children done)
|
// Check if current iteration is complete (all children done)
|
||||||
const isIterationComplete = children.length > 0 && children.every((_, idx) => completedChildren.has(idx));
|
const isIterationComplete =
|
||||||
|
children.length > 0 &&
|
||||||
|
children.every((_, idx) => completedChildren.has(idx));
|
||||||
const isLoopComplete = isIterationComplete && currentIteration >= iterations;
|
const isLoopComplete = isIterationComplete && currentIteration >= iterations;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative pb-2 last:pb-0 transition-all duration-300",
|
"relative pb-2 transition-all duration-300 last:pb-0",
|
||||||
depth > 0 && "ml-4 mt-2 border-l pl-4 border-l-border/30"
|
depth > 0 && "border-l-border/30 mt-2 ml-4 border-l pl-4",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Visual Connection Line for Root items is handled by parent list,
|
{/* Visual Connection Line for Root items is handled by parent list,
|
||||||
@@ -156,9 +165,9 @@ export function WizardActionItem({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg border transition-all duration-300",
|
"rounded-lg border transition-all duration-300",
|
||||||
isActive
|
isActive
|
||||||
? "bg-card border-primary/50 shadow-md p-4"
|
? "bg-card border-primary/50 p-4 shadow-md"
|
||||||
: "bg-muted/5 border-transparent p-3 opacity-80 hover:opacity-100",
|
: "bg-muted/5 border-transparent p-3 opacity-80 hover:opacity-100",
|
||||||
isContainer && "bg-muted/10 border-border/50"
|
isContainer && "bg-muted/10 border-border/50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -166,15 +175,23 @@ export function WizardActionItem({
|
|||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Icon based on type */}
|
{/* Icon based on type */}
|
||||||
{isContainer && action.type.includes("loop") && <Repeat className="h-4 w-4 text-blue-500 dark:text-blue-400" />}
|
{isContainer && action.type.includes("loop") && (
|
||||||
{isContainer && action.type.includes("parallel") && <Layers className="h-4 w-4 text-purple-500 dark:text-purple-400" />}
|
<Repeat className="h-4 w-4 text-blue-500 dark:text-blue-400" />
|
||||||
{isBranch && <Split className="h-4 w-4 text-orange-500 dark:text-orange-400" />}
|
)}
|
||||||
{isWait && <Clock className="h-4 w-4 text-amber-500 dark:text-amber-400" />}
|
{isContainer && action.type.includes("parallel") && (
|
||||||
|
<Layers className="h-4 w-4 text-purple-500 dark:text-purple-400" />
|
||||||
|
)}
|
||||||
|
{isBranch && (
|
||||||
|
<Split className="h-4 w-4 text-orange-500 dark:text-orange-400" />
|
||||||
|
)}
|
||||||
|
{isWait && (
|
||||||
|
<Clock className="h-4 w-4 text-amber-500 dark:text-amber-400" />
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-base font-medium leading-none",
|
"text-base leading-none font-medium",
|
||||||
isCompleted && "line-through text-muted-foreground"
|
isCompleted && "text-muted-foreground line-through",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{action.name}
|
{action.name}
|
||||||
@@ -182,47 +199,52 @@ export function WizardActionItem({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Completion Badge */}
|
{/* Completion Badge */}
|
||||||
{isCompleted && <CheckCircle className="h-4 w-4 text-green-500 dark:text-green-400" />}
|
{isCompleted && (
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500 dark:text-green-400" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{action.description && (
|
{action.description && (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-muted-foreground text-sm">
|
||||||
{action.description}
|
{action.description}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Details for Control Flow */}
|
{/* Details for Control Flow */}
|
||||||
{isWait && (
|
{isWait && (
|
||||||
<div className="flex items-center gap-2 text-xs text-amber-700 bg-amber-50/80 dark:text-amber-300 dark:bg-amber-900/30 w-fit px-2 py-1 rounded border border-amber-100 dark:border-amber-800/50">
|
<div className="flex w-fit items-center gap-2 rounded border border-amber-100 bg-amber-50/80 px-2 py-1 text-xs text-amber-700 dark:border-amber-800/50 dark:bg-amber-900/30 dark:text-amber-300">
|
||||||
<Clock className="h-3 w-3" />
|
<Clock className="h-3 w-3" />
|
||||||
Wait {String(action.parameters.duration || 1)}s
|
Wait {String(action.parameters.duration || 1)}s
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{action.type.includes("loop") && (
|
{action.type.includes("loop") && (
|
||||||
<div className="flex items-center gap-2 text-xs text-blue-700 bg-blue-50/80 dark:text-blue-300 dark:bg-blue-900/30 w-fit px-2 py-1 rounded border border-blue-100 dark:border-blue-800/50">
|
<div className="flex w-fit items-center gap-2 rounded border border-blue-100 bg-blue-50/80 px-2 py-1 text-xs text-blue-700 dark:border-blue-800/50 dark:bg-blue-900/30 dark:text-blue-300">
|
||||||
<Repeat className="h-3 w-3" />
|
<Repeat className="h-3 w-3" />
|
||||||
{String(action.parameters.iterations || 1)} Iterations
|
{String(action.parameters.iterations || 1)} Iterations
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{
|
||||||
{((!!isContainer && children.length > 0) ? (
|
(!!isContainer && children.length > 0 ? (
|
||||||
<div className="mt-4 space-y-2">
|
<div className="mt-4 space-y-2">
|
||||||
{/* Loop Iteration Status & Controls */}
|
{/* Loop Iteration Status & Controls */}
|
||||||
{action.type.includes("loop") && (
|
{action.type.includes("loop") && (
|
||||||
<div className="flex items-center justify-between bg-blue-50/50 dark:bg-blue-900/20 p-2 rounded mb-2 border border-blue-100 dark:border-blue-800/50">
|
<div className="mb-2 flex items-center justify-between rounded border border-blue-100 bg-blue-50/50 p-2 dark:border-blue-800/50 dark:bg-blue-900/20">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline" className="bg-white dark:bg-zinc-900 dark:text-zinc-100 border-zinc-200 dark:border-zinc-700">
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100"
|
||||||
|
>
|
||||||
Iteration {currentIteration} of {iterations}
|
Iteration {currentIteration} of {iterations}
|
||||||
</Badge>
|
</Badge>
|
||||||
{isIterationComplete && currentIteration < iterations && (
|
{isIterationComplete && currentIteration < iterations && (
|
||||||
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium animate-pulse">
|
<span className="animate-pulse text-xs font-medium text-blue-600 dark:text-blue-400">
|
||||||
All actions complete. Ready for next iteration.
|
All actions complete. Ready for next iteration.
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isLoopComplete && (
|
{isLoopComplete && (
|
||||||
<span className="text-xs text-green-600 dark:text-green-400 font-medium">
|
<span className="text-xs font-medium text-green-600 dark:text-green-400">
|
||||||
Loop complete!
|
Loop complete!
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -235,13 +257,15 @@ export function WizardActionItem({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onCompleted();
|
onCompleted();
|
||||||
}}
|
}}
|
||||||
className="h-7 text-xs bg-green-600 hover:bg-green-700 text-white dark:bg-green-600 dark:hover:bg-green-500"
|
className="h-7 bg-green-600 text-xs text-white hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-500"
|
||||||
>
|
>
|
||||||
<CheckCircle className="mr-1 h-3 w-3" />
|
<CheckCircle className="mr-1 h-3 w-3" />
|
||||||
Finish Loop
|
Finish Loop
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
isIterationComplete && currentIteration < iterations && !readOnly && (
|
isIterationComplete &&
|
||||||
|
currentIteration < iterations &&
|
||||||
|
!readOnly && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -272,7 +296,7 @@ export function WizardActionItem({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
<div className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
|
||||||
{action.type.includes("loop") ? "Loop Body" : "Actions"}
|
{action.type.includes("loop") ? "Loop Body" : "Actions"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -281,13 +305,20 @@ export function WizardActionItem({
|
|||||||
key={`${child.id || idx}-${currentIteration}`}
|
key={`${child.id || idx}-${currentIteration}`}
|
||||||
action={child as ActionData}
|
action={child as ActionData}
|
||||||
index={idx}
|
index={idx}
|
||||||
isActive={isActive && !isCompleted && !completedChildren.has(idx)}
|
isActive={
|
||||||
|
isActive && !isCompleted && !completedChildren.has(idx)
|
||||||
|
}
|
||||||
isCompleted={isCompleted || completedChildren.has(idx)}
|
isCompleted={isCompleted || completedChildren.has(idx)}
|
||||||
onExecute={onExecute}
|
onExecute={onExecute}
|
||||||
onExecuteRobot={onExecuteRobot}
|
onExecuteRobot={onExecuteRobot}
|
||||||
onSkip={onSkip}
|
onSkip={onSkip}
|
||||||
onCompleted={() => handleChildCompleted(idx)}
|
onCompleted={() => handleChildCompleted(idx)}
|
||||||
readOnly={readOnly || isCompleted || completedChildren.has(idx) || (action.type.includes("parallel") && true)}
|
readOnly={
|
||||||
|
readOnly ||
|
||||||
|
isCompleted ||
|
||||||
|
completedChildren.has(idx) ||
|
||||||
|
(action.type.includes("parallel") && true)
|
||||||
|
}
|
||||||
isExecuting={isExecuting}
|
isExecuting={isExecuting}
|
||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
isRobotConnected={isRobotConnected}
|
isRobotConnected={isRobotConnected}
|
||||||
@@ -295,38 +326,46 @@ export function WizardActionItem({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null) as any}
|
) : null) as any
|
||||||
|
}
|
||||||
|
|
||||||
{/* Active Action Controls */}
|
{/* Active Action Controls */}
|
||||||
{(isActive || (isCompleted && !readOnly)) && (
|
{(isActive || (isCompleted && !readOnly)) && (
|
||||||
<div className="pt-3 flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3 pt-3">
|
||||||
{/* Parallel Container Controls */}
|
{/* Parallel Container Controls */}
|
||||||
{isContainer && action.type.includes("parallel") ? (
|
{isContainer && action.type.includes("parallel") ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className={cn(
|
className={cn(
|
||||||
"shadow-sm min-w-[100px]",
|
"min-w-[100px] shadow-sm",
|
||||||
isButtonDisabled && "opacity-50 cursor-not-allowed"
|
isButtonDisabled && "cursor-not-allowed opacity-50",
|
||||||
)}
|
)}
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Run all child robot actions
|
// Run all child robot actions
|
||||||
const children = (action.parameters.children as ActionData[]) || [];
|
const children =
|
||||||
|
(action.parameters.children as ActionData[]) || [];
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
if (child.pluginId) {
|
if (child.pluginId) {
|
||||||
// Fire and forget - don't await sequentially
|
// Fire and forget - don't await sequentially
|
||||||
onExecuteRobot(
|
onExecuteRobot(
|
||||||
child.pluginId,
|
child.pluginId,
|
||||||
child.type.includes(".") ? child.type.split(".").pop()! : child.type,
|
child.type.includes(".")
|
||||||
|
? child.type.split(".").pop()!
|
||||||
|
: child.type,
|
||||||
child.parameters || {},
|
child.parameters || {},
|
||||||
{ autoAdvance: false }
|
{ autoAdvance: false },
|
||||||
).catch(console.error);
|
).catch(console.error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={isButtonDisabled}
|
disabled={isButtonDisabled}
|
||||||
title={isButtonDisabled && !isExecuting ? "Robot disconnected" : undefined}
|
title={
|
||||||
|
isButtonDisabled && !isExecuting
|
||||||
|
? "Robot disconnected"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Play className="mr-2 h-3.5 w-3.5" />
|
<Play className="mr-2 h-3.5 w-3.5" />
|
||||||
{isCompleted ? "Rerun All" : "Run All"}
|
{isCompleted ? "Rerun All" : "Run All"}
|
||||||
@@ -346,31 +385,36 @@ export function WizardActionItem({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : /* Standard Single Action Controls */
|
||||||
/* Standard Single Action Controls */
|
action.pluginId &&
|
||||||
(action.pluginId && !["hristudio-woz"].includes(action.pluginId!) && (action.pluginId !== "hristudio-core" || isWait)) ? (
|
!["hristudio-woz"].includes(action.pluginId!) &&
|
||||||
|
(action.pluginId !== "hristudio-core" || isWait) ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className={cn(
|
className={cn(
|
||||||
"shadow-sm min-w-[100px]",
|
"min-w-[100px] shadow-sm",
|
||||||
isButtonDisabled && "opacity-50 cursor-not-allowed"
|
isButtonDisabled && "cursor-not-allowed opacity-50",
|
||||||
)}
|
)}
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsRunningLocal(true);
|
setIsRunningLocal(true);
|
||||||
|
|
||||||
if (isWait) {
|
if (isWait) {
|
||||||
const duration = Number(action.parameters.duration || 1);
|
const duration = Number(
|
||||||
|
action.parameters.duration || 1,
|
||||||
|
);
|
||||||
setCountdown(Math.ceil(duration));
|
setCountdown(Math.ceil(duration));
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onExecuteRobot(
|
await onExecuteRobot(
|
||||||
action.pluginId!,
|
action.pluginId!,
|
||||||
action.type.includes(".") ? action.type.split(".").pop()! : action.type,
|
action.type.includes(".")
|
||||||
|
? action.type.split(".").pop()!
|
||||||
|
: action.type,
|
||||||
action.parameters || {},
|
action.parameters || {},
|
||||||
{ autoAdvance: false }
|
{ autoAdvance: false },
|
||||||
);
|
);
|
||||||
if (!isCompleted) onCompleted();
|
if (!isCompleted) onCompleted();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -380,13 +424,25 @@ export function WizardActionItem({
|
|||||||
setCountdown(null);
|
setCountdown(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={isExecuting || isRunningLocal || (!isWait && !isRobotConnected)}
|
disabled={
|
||||||
title={!isWait && !isRobotConnected ? "Robot disconnected" : undefined}
|
isExecuting ||
|
||||||
|
isRunningLocal ||
|
||||||
|
(!isWait && !isRobotConnected)
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
!isWait && !isRobotConnected
|
||||||
|
? "Robot disconnected"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isRunningLocal ? (
|
{isRunningLocal ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||||
{isWait ? (countdown !== null && countdown > 0 ? `Wait (${countdown}s)...` : "Finishing...") : "Running..."}
|
{isWait
|
||||||
|
? countdown !== null && countdown > 0
|
||||||
|
? `Wait (${countdown}s)...`
|
||||||
|
: "Finishing..."
|
||||||
|
: "Running..."}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -405,7 +461,7 @@ export function WizardActionItem({
|
|||||||
if (onLogEvent) {
|
if (onLogEvent) {
|
||||||
onLogEvent("action_marked_complete", {
|
onLogEvent("action_marked_complete", {
|
||||||
actionId: action.id,
|
actionId: action.id,
|
||||||
formatted: "Action manually marked complete"
|
formatted: "Action manually marked complete",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
onCompleted();
|
onCompleted();
|
||||||
@@ -423,7 +479,14 @@ export function WizardActionItem({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (onSkip) {
|
if (onSkip) {
|
||||||
onSkip(action.pluginId!, action.type.includes(".") ? action.type.split(".").pop()! : action.type, action.parameters || {}, { autoAdvance: false });
|
onSkip(
|
||||||
|
action.pluginId!,
|
||||||
|
action.type.includes(".")
|
||||||
|
? action.type.split(".").pop()!
|
||||||
|
: action.type,
|
||||||
|
action.parameters || {},
|
||||||
|
{ autoAdvance: false },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
onCompleted();
|
onCompleted();
|
||||||
}}
|
}}
|
||||||
@@ -434,7 +497,9 @@ export function WizardActionItem({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
// Manual/Wizard Actions (Leaf nodes)
|
// Manual/Wizard Actions (Leaf nodes)
|
||||||
!isContainer && action.type !== "wizard_wait_for_response" && !isCompleted && (
|
!isContainer &&
|
||||||
|
action.type !== "wizard_wait_for_response" &&
|
||||||
|
!isCompleted && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -447,7 +512,6 @@ export function WizardActionItem({
|
|||||||
Mark Complete
|
Mark Complete
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -457,17 +521,18 @@ export function WizardActionItem({
|
|||||||
(action.type === "wizard_wait_for_response" || isBranch) &&
|
(action.type === "wizard_wait_for_response" || isBranch) &&
|
||||||
action.parameters?.options &&
|
action.parameters?.options &&
|
||||||
Array.isArray(action.parameters.options) && (
|
Array.isArray(action.parameters.options) && (
|
||||||
<div className="pt-3 grid grid-cols-1 sm:grid-cols-2 gap-2">
|
<div className="grid grid-cols-1 gap-2 pt-3 sm:grid-cols-2">
|
||||||
{(action.parameters.options as any[]).map((opt, optIdx) => {
|
{(action.parameters.options as any[]).map((opt, optIdx) => {
|
||||||
const label = typeof opt === "string" ? opt : opt.label;
|
const label = typeof opt === "string" ? opt : opt.label;
|
||||||
const value = typeof opt === "string" ? opt : opt.value;
|
const value = typeof opt === "string" ? opt : opt.value;
|
||||||
const nextStepId = typeof opt === "object" ? opt.nextStepId : undefined;
|
const nextStepId =
|
||||||
|
typeof opt === "object" ? opt.nextStepId : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={optIdx}
|
key={optIdx}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="justify-start h-auto py-3 px-4 text-left hover:border-primary hover:bg-primary/5"
|
className="hover:border-primary hover:bg-primary/5 h-auto justify-start px-4 py-3 text-left"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onExecute(action.id, { value, label, nextStepId });
|
onExecute(action.id, { value, label, nextStepId });
|
||||||
@@ -486,18 +551,20 @@ export function WizardActionItem({
|
|||||||
|
|
||||||
{/* Retry for failed/completed robot actions */}
|
{/* Retry for failed/completed robot actions */}
|
||||||
{isCompleted && action.pluginId && !isContainer && (
|
{isCompleted && action.pluginId && !isContainer && (
|
||||||
<div className="pt-1 flex items-center gap-1">
|
<div className="flex items-center gap-1 pt-1">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-primary"
|
className="text-muted-foreground hover:text-primary h-7 px-2 text-xs"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onExecuteRobot(
|
onExecuteRobot(
|
||||||
action.pluginId!,
|
action.pluginId!,
|
||||||
action.type.includes(".") ? action.type.split(".").pop()! : action.type,
|
action.type.includes(".")
|
||||||
|
? action.type.split(".").pop()!
|
||||||
|
: action.type,
|
||||||
action.parameters || {},
|
action.parameters || {},
|
||||||
{ autoAdvance: false }
|
{ autoAdvance: false },
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
disabled={isExecuting}
|
disabled={isExecuting}
|
||||||
|
|||||||
@@ -25,11 +25,7 @@ interface StepData {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
type:
|
type: "wizard_action" | "robot_action" | "parallel_steps" | "conditional"; // Updated to match DB enum
|
||||||
| "wizard_action"
|
|
||||||
| "robot_action"
|
|
||||||
| "parallel_steps"
|
|
||||||
| "conditional"; // Updated to match DB enum
|
|
||||||
parameters: Record<string, unknown>;
|
parameters: Record<string, unknown>;
|
||||||
conditions?: {
|
conditions?: {
|
||||||
options?: {
|
options?: {
|
||||||
@@ -37,7 +33,13 @@ interface StepData {
|
|||||||
value: string;
|
value: string;
|
||||||
nextStepId?: string;
|
nextStepId?: string;
|
||||||
nextStepIndex?: number;
|
nextStepIndex?: number;
|
||||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
|
variant?:
|
||||||
|
| "default"
|
||||||
|
| "destructive"
|
||||||
|
| "outline"
|
||||||
|
| "secondary"
|
||||||
|
| "ghost"
|
||||||
|
| "link";
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
order: number;
|
order: number;
|
||||||
@@ -109,12 +111,8 @@ export const WizardControlPanel = React.memo(function WizardControlPanel({
|
|||||||
isStarting = false,
|
isStarting = false,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
}: WizardControlPanelProps) {
|
}: WizardControlPanelProps) {
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col" id="tour-wizard-controls">
|
<div className="flex h-full flex-col" id="tour-wizard-controls">
|
||||||
|
|
||||||
|
|
||||||
<div className="min-h-0 flex-1">
|
<div className="min-h-0 flex-1">
|
||||||
<ScrollArea className="h-full">
|
<ScrollArea className="h-full">
|
||||||
<div className="space-y-4 p-3">
|
<div className="space-y-4 p-3">
|
||||||
@@ -137,7 +135,7 @@ export const WizardControlPanel = React.memo(function WizardControlPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full justify-start border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300 dark:border-yellow-700/50 dark:hover:bg-yellow-900/40"
|
className="w-full justify-start border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800 dark:border-yellow-700/50 dark:bg-yellow-900/20 dark:text-yellow-300 dark:hover:bg-yellow-900/40"
|
||||||
onClick={() => onExecuteAction("intervene")}
|
onClick={() => onExecuteAction("intervene")}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
@@ -149,7 +147,9 @@ export const WizardControlPanel = React.memo(function WizardControlPanel({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full justify-start"
|
className="w-full justify-start"
|
||||||
onClick={() => onExecuteAction("note", { content: "Wizard note" })}
|
onClick={() =>
|
||||||
|
onExecuteAction("note", { content: "Wizard note" })
|
||||||
|
}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
<User className="mr-2 h-3 w-3" />
|
<User className="mr-2 h-3 w-3" />
|
||||||
@@ -170,16 +170,18 @@ export const WizardControlPanel = React.memo(function WizardControlPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-xs text-muted-foreground p-2 text-center border border-dashed rounded-md bg-muted/20">
|
<div className="text-muted-foreground bg-muted/20 rounded-md border border-dashed p-2 text-center text-xs">
|
||||||
Controls available during trial
|
Controls available during trial
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step Navigation */}
|
{/* Step Navigation */}
|
||||||
<div className="pt-4 border-t space-y-2">
|
<div className="space-y-2 border-t pt-4">
|
||||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Navigation</span>
|
<span className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
|
||||||
|
Navigation
|
||||||
|
</span>
|
||||||
<select
|
<select
|
||||||
className="w-full text-xs p-2 rounded-md border bg-background"
|
className="bg-background w-full rounded-md border p-2 text-xs"
|
||||||
value={currentStepIndex}
|
value={currentStepIndex}
|
||||||
onChange={(e) => onNextStep(parseInt(e.target.value, 10))}
|
onChange={(e) => onNextStep(parseInt(e.target.value, 10))}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { WizardActionItem } from "./WizardActionItem";
|
import { WizardActionItem } from "./WizardActionItem";
|
||||||
import {
|
import {
|
||||||
@@ -23,11 +22,7 @@ interface StepData {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
type:
|
type: "wizard_action" | "robot_action" | "parallel_steps" | "conditional";
|
||||||
| "wizard_action"
|
|
||||||
| "robot_action"
|
|
||||||
| "parallel_steps"
|
|
||||||
| "conditional";
|
|
||||||
parameters: Record<string, unknown>;
|
parameters: Record<string, unknown>;
|
||||||
conditions?: {
|
conditions?: {
|
||||||
options?: {
|
options?: {
|
||||||
@@ -35,7 +30,13 @@ interface StepData {
|
|||||||
value: string;
|
value: string;
|
||||||
nextStepId?: string;
|
nextStepId?: string;
|
||||||
nextStepIndex?: number;
|
nextStepIndex?: number;
|
||||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
|
variant?:
|
||||||
|
| "default"
|
||||||
|
| "destructive"
|
||||||
|
| "outline"
|
||||||
|
| "secondary"
|
||||||
|
| "ghost"
|
||||||
|
| "link";
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
order: number;
|
order: number;
|
||||||
@@ -166,7 +167,7 @@ export function WizardExecutionPanel({
|
|||||||
if (trial.status === "scheduled") {
|
if (trial.status === "scheduled") {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex-1 flex items-center justify-center p-6">
|
<div className="flex flex-1 items-center justify-center p-6">
|
||||||
<div className="w-full max-w-md space-y-4 text-center">
|
<div className="w-full max-w-md space-y-4 text-center">
|
||||||
<Clock className="text-muted-foreground mx-auto h-12 w-12 opacity-20" />
|
<Clock className="text-muted-foreground mx-auto h-12 w-12 opacity-20" />
|
||||||
<div>
|
<div>
|
||||||
@@ -219,16 +220,17 @@ export function WizardExecutionPanel({
|
|||||||
|
|
||||||
// Active trial state
|
// Active trial state
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden relative">
|
<div className="relative flex h-full flex-col overflow-hidden">
|
||||||
{/* Paused Overlay */}
|
{/* Paused Overlay */}
|
||||||
{isPaused && (
|
{isPaused && (
|
||||||
<div className="absolute inset-0 z-50 bg-background/60 backdrop-blur-[2px] flex items-center justify-center">
|
<div className="bg-background/60 absolute inset-0 z-50 flex items-center justify-center backdrop-blur-[2px]">
|
||||||
<div className="bg-background border shadow-lg rounded-xl p-8 flex flex-col items-center max-w-sm text-center space-y-4">
|
<div className="bg-background flex max-w-sm flex-col items-center space-y-4 rounded-xl border p-8 text-center shadow-lg">
|
||||||
<AlertCircle className="h-12 w-12 text-muted-foreground" />
|
<AlertCircle className="text-muted-foreground h-12 w-12" />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold tracking-tight">Trial Paused</h2>
|
<h2 className="text-xl font-bold tracking-tight">Trial Paused</h2>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
The trial execution has been paused. Resume from the control bar to continue interacting.
|
The trial execution has been paused. Resume from the control bar
|
||||||
|
to continue interacting.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,48 +238,45 @@ export function WizardExecutionPanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Horizontal Step Progress Bar */}
|
{/* Horizontal Step Progress Bar */}
|
||||||
<div className="flex-none border-b bg-muted/30 p-3">
|
<div className="bg-muted/30 flex-none border-b p-3">
|
||||||
<div className="flex items-center gap-2 overflow-x-auto pb-2">
|
<div className="flex items-center gap-2 overflow-x-auto pb-2">
|
||||||
{steps.map((step, idx) => {
|
{steps.map((step, idx) => {
|
||||||
const isCurrent = idx === currentStepIndex;
|
const isCurrent = idx === currentStepIndex;
|
||||||
const isSkipped = skippedStepIndices.has(idx);
|
const isSkipped = skippedStepIndices.has(idx);
|
||||||
const isCompleted = completedStepIndices.has(idx) || (!isSkipped && idx < currentStepIndex);
|
const isCompleted =
|
||||||
|
completedStepIndices.has(idx) ||
|
||||||
|
(!isSkipped && idx < currentStepIndex);
|
||||||
const isUpcoming = idx > currentStepIndex;
|
const isUpcoming = idx > currentStepIndex;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={step.id}
|
key={step.id}
|
||||||
className="flex items-center gap-2 flex-shrink-0"
|
className="flex flex-shrink-0 items-center gap-2"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => onStepSelect(idx)}
|
onClick={() => onStepSelect(idx)}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
className={`
|
className={`group relative flex items-center gap-2 rounded-lg border-2 px-3 py-2 transition-all ${
|
||||||
group relative flex items-center gap-2 rounded-lg border-2 px-3 py-2 transition-all
|
isCurrent
|
||||||
${isCurrent
|
|
||||||
? "border-primary bg-primary/10 shadow-sm"
|
? "border-primary bg-primary/10 shadow-sm"
|
||||||
: isCompleted
|
: isCompleted
|
||||||
? "border-primary/30 bg-primary/5 hover:bg-primary/10"
|
? "border-primary/30 bg-primary/5 hover:bg-primary/10"
|
||||||
: isSkipped
|
: isSkipped
|
||||||
? "border-muted-foreground/30 bg-muted/20 border-dashed"
|
? "border-muted-foreground/30 bg-muted/20 border-dashed"
|
||||||
: "border-muted-foreground/20 bg-background hover:bg-muted/50"
|
: "border-muted-foreground/20 bg-background hover:bg-muted/50"
|
||||||
}
|
} ${readOnly ? "cursor-default" : "cursor-pointer"} `}
|
||||||
${readOnly ? "cursor-default" : "cursor-pointer"}
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
{/* Step Number/Icon */}
|
{/* Step Number/Icon */}
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold ${
|
||||||
flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold
|
isCompleted
|
||||||
${isCompleted
|
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
: isSkipped
|
: isSkipped
|
||||||
? "bg-transparent border border-muted-foreground/40 text-muted-foreground"
|
? "border-muted-foreground/40 text-muted-foreground border bg-transparent"
|
||||||
: isCurrent
|
: isCurrent
|
||||||
? "bg-primary text-primary-foreground ring-2 ring-primary/20"
|
? "bg-primary text-primary-foreground ring-primary/20 ring-2"
|
||||||
: "bg-muted text-muted-foreground"
|
: "bg-muted text-muted-foreground"
|
||||||
}
|
} `}
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
{isCompleted ? (
|
{isCompleted ? (
|
||||||
<CheckCircle className="h-3.5 w-3.5" />
|
<CheckCircle className="h-3.5 w-3.5" />
|
||||||
@@ -288,7 +287,8 @@ export function WizardExecutionPanel({
|
|||||||
|
|
||||||
{/* Step Name */}
|
{/* Step Name */}
|
||||||
<span
|
<span
|
||||||
className={`text-xs font-medium max-w-[120px] truncate ${isCurrent
|
className={`max-w-[120px] truncate text-xs font-medium ${
|
||||||
|
isCurrent
|
||||||
? "text-foreground"
|
? "text-foreground"
|
||||||
: isCompleted
|
: isCompleted
|
||||||
? "text-muted-foreground"
|
? "text-muted-foreground"
|
||||||
@@ -303,7 +303,10 @@ export function WizardExecutionPanel({
|
|||||||
{/* Arrow Connector */}
|
{/* Arrow Connector */}
|
||||||
{idx < steps.length - 1 && (
|
{idx < steps.length - 1 && (
|
||||||
<ArrowRight
|
<ArrowRight
|
||||||
className={`h-4 w-4 flex-shrink-0 ${isCompleted ? "text-primary/40" : "text-muted-foreground/30"
|
className={`h-4 w-4 flex-shrink-0 ${
|
||||||
|
isCompleted
|
||||||
|
? "text-primary/40"
|
||||||
|
: "text-muted-foreground/30"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -314,16 +317,20 @@ export function WizardExecutionPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Current Step Details - NO SCROLL */}
|
{/* Current Step Details - NO SCROLL */}
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<div className="min-h-0 flex-1 overflow-hidden">
|
||||||
<div className="h-full overflow-y-auto">
|
<div className="h-full overflow-y-auto">
|
||||||
<div className="pr-4">
|
<div className="pr-4">
|
||||||
{currentStep ? (
|
{currentStep ? (
|
||||||
<div className="flex flex-col gap-4 p-4 max-w-5xl mx-auto w-full">
|
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4 p-4">
|
||||||
{/* Header Info */}
|
{/* Header Info */}
|
||||||
<div className="space-y-1 pb-4 border-b">
|
<div className="space-y-1 border-b pb-4">
|
||||||
<h2 className="text-xl font-bold tracking-tight">{currentStep.name}</h2>
|
<h2 className="text-xl font-bold tracking-tight">
|
||||||
|
{currentStep.name}
|
||||||
|
</h2>
|
||||||
{currentStep.description && (
|
{currentStep.description && (
|
||||||
<div className="text-muted-foreground">{currentStep.description}</div>
|
<div className="text-muted-foreground">
|
||||||
|
{currentStep.description}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -333,34 +340,38 @@ export function WizardExecutionPanel({
|
|||||||
{currentStep.actions.map((action, idx) => {
|
{currentStep.actions.map((action, idx) => {
|
||||||
const isCompleted = idx < activeActionIndex;
|
const isCompleted = idx < activeActionIndex;
|
||||||
const isActive: boolean = idx === activeActionIndex;
|
const isActive: boolean = idx === activeActionIndex;
|
||||||
const isLast = idx === (currentStep.actions?.length || 0) - 1;
|
const isLast =
|
||||||
|
idx === (currentStep.actions?.length || 0) - 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={action.id}
|
key={action.id}
|
||||||
className="relative pl-8 pb-10 last:pb-0"
|
className="relative pb-10 pl-8 last:pb-0"
|
||||||
ref={isActive ? activeActionRef : undefined}
|
ref={isActive ? activeActionRef : undefined}
|
||||||
>
|
>
|
||||||
{/* Connecting Line */}
|
{/* Connecting Line */}
|
||||||
{!isLast && (
|
{!isLast && (
|
||||||
<div
|
<div
|
||||||
className={`absolute left-[11px] top-8 bottom-0 w-[2px] ${isCompleted ? "bg-primary/20" : "bg-border/40"}`}
|
className={`absolute top-8 bottom-0 left-[11px] w-[2px] ${isCompleted ? "bg-primary/20" : "bg-border/40"}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Marker */}
|
{/* Marker */}
|
||||||
<div
|
<div
|
||||||
className={`absolute left-0 top-1 h-6 w-6 rounded-full border-2 flex items-center justify-center z-10 bg-background transition-all duration-300 ${isCompleted
|
className={`bg-background absolute top-1 left-0 z-10 flex h-6 w-6 items-center justify-center rounded-full border-2 transition-all duration-300 ${
|
||||||
|
isCompleted
|
||||||
? "border-primary bg-primary text-primary-foreground"
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
: isActive
|
: isActive
|
||||||
? "border-primary ring-4 ring-primary/10 scale-110"
|
? "border-primary ring-primary/10 scale-110 ring-4"
|
||||||
: "border-muted-foreground/30 text-muted-foreground"
|
: "border-muted-foreground/30 text-muted-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isCompleted ? (
|
{isCompleted ? (
|
||||||
<CheckCircle className="h-3.5 w-3.5" />
|
<CheckCircle className="h-3.5 w-3.5" />
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[10px] font-bold">{idx + 1}</span>
|
<span className="text-[10px] font-bold">
|
||||||
|
{idx + 1}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -390,21 +401,28 @@ export function WizardExecutionPanel({
|
|||||||
<div className="mt-6 flex justify-center pb-8">
|
<div className="mt-6 flex justify-center pb-8">
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
onClick={currentStepIndex === steps.length - 1 ? onCompleteTrial : onNextStep}
|
onClick={
|
||||||
className={`w-full max-w-sm text-white shadow-lg transition-all hover:scale-[1.02] ${currentStepIndex === steps.length - 1
|
currentStepIndex === steps.length - 1
|
||||||
|
? onCompleteTrial
|
||||||
|
: onNextStep
|
||||||
|
}
|
||||||
|
className={`w-full max-w-sm text-white shadow-lg transition-all hover:scale-[1.02] ${
|
||||||
|
currentStepIndex === steps.length - 1
|
||||||
? "bg-blue-600 hover:bg-blue-700"
|
? "bg-blue-600 hover:bg-blue-700"
|
||||||
: "bg-green-600 hover:bg-green-700"
|
: "bg-green-600 hover:bg-green-700"
|
||||||
}`}
|
}`}
|
||||||
disabled={readOnly || isExecuting}
|
disabled={readOnly || isExecuting}
|
||||||
>
|
>
|
||||||
{currentStepIndex === steps.length - 1 ? "Complete Trial" : "Complete Step"}
|
{currentStepIndex === steps.length - 1
|
||||||
|
? "Complete Trial"
|
||||||
|
: "Complete Step"}
|
||||||
<ArrowRight className="ml-2 h-5 w-5" />
|
<ArrowRight className="ml-2 h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground space-y-3">
|
<div className="text-muted-foreground flex h-full flex-col items-center justify-center space-y-3">
|
||||||
<Loader2 className="h-8 w-8 animate-spin opacity-50" />
|
<Loader2 className="h-8 w-8 animate-spin opacity-50" />
|
||||||
<div className="text-sm">Waiting for trial to start...</div>
|
<div className="text-sm">Waiting for trial to start...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ import {
|
|||||||
Power,
|
Power,
|
||||||
PowerOff,
|
PowerOff,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
RotateCcw,
|
||||||
|
RotateCw,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
Square,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Separator } from "~/components/ui/separator";
|
import { Separator } from "~/components/ui/separator";
|
||||||
@@ -64,7 +72,8 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
|||||||
}: WizardMonitoringPanelProps) {
|
}: WizardMonitoringPanelProps) {
|
||||||
const [autonomousLife, setAutonomousLife] = React.useState(true);
|
const [autonomousLife, setAutonomousLife] = React.useState(true);
|
||||||
|
|
||||||
const handleAutonomousLifeChange = React.useCallback(async (checked: boolean) => {
|
const handleAutonomousLifeChange = React.useCallback(
|
||||||
|
async (checked: boolean) => {
|
||||||
setAutonomousLife(checked); // Optimistic update
|
setAutonomousLife(checked); // Optimistic update
|
||||||
if (onSetAutonomousLife) {
|
if (onSetAutonomousLife) {
|
||||||
try {
|
try {
|
||||||
@@ -77,11 +86,13 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
|||||||
setAutonomousLife(!checked); // Revert on failure
|
setAutonomousLife(!checked); // Revert on failure
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [onSetAutonomousLife]);
|
},
|
||||||
|
[onSetAutonomousLife],
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col p-2">
|
<div className="flex h-full flex-col p-2">
|
||||||
{/* Robot Controls - Scrollable */}
|
{/* Robot Controls - Scrollable */}
|
||||||
<div className="flex-1 min-h-0 bg-background rounded-lg border shadow-sm overflow-hidden flex flex-col">
|
<div className="bg-background flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border shadow-sm">
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="space-y-4 p-3">
|
<div className="space-y-4 p-3">
|
||||||
{/* Robot Status */}
|
{/* Robot Status */}
|
||||||
@@ -92,7 +103,12 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
|||||||
{rosConnected ? (
|
{rosConnected ? (
|
||||||
<Power className="h-3 w-3 text-green-600" />
|
<Power className="h-3 w-3 text-green-600" />
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="outline" className="text-gray-500 border-gray-300 text-xs text-muted-foreground w-auto px-1.5 py-0">Offline</Badge>
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-muted-foreground w-auto border-gray-300 px-1.5 py-0 text-xs text-gray-500"
|
||||||
|
>
|
||||||
|
Offline
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -145,11 +161,16 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
|||||||
disabled={rosConnecting || rosConnected || readOnly}
|
disabled={rosConnecting || rosConnected || readOnly}
|
||||||
>
|
>
|
||||||
<Bot className="mr-1 h-3 w-3" />
|
<Bot className="mr-1 h-3 w-3" />
|
||||||
{rosConnecting
|
{rosConnecting ? (
|
||||||
? "Connecting..."
|
"Connecting..."
|
||||||
: rosConnected
|
) : rosConnected ? (
|
||||||
? "Connected ✓"
|
<div className="flex items-center gap-1.5">
|
||||||
: "Connect to NAO6"}
|
<span>Connected</span>
|
||||||
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"Connect to NAO6"
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
@@ -192,7 +213,12 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
|||||||
{/* Autonomous Life Toggle */}
|
{/* Autonomous Life Toggle */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="autonomous-life" className="text-xs font-normal text-muted-foreground">Autonomous Life</Label>
|
<Label
|
||||||
|
htmlFor="autonomous-life"
|
||||||
|
className="text-muted-foreground text-xs font-normal"
|
||||||
|
>
|
||||||
|
Autonomous Life
|
||||||
|
</Label>
|
||||||
<Switch
|
<Switch
|
||||||
id="tour-wizard-autonomous"
|
id="tour-wizard-autonomous"
|
||||||
checked={!!autonomousLife}
|
checked={!!autonomousLife}
|
||||||
@@ -235,7 +261,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
|||||||
}}
|
}}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
↺ Turn L
|
<RotateCcw className="mr-1 h-3 w-3" /> Turn L
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -248,7 +274,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
|||||||
}}
|
}}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
↑ Forward
|
<ArrowUp className="mr-1 h-3 w-3" /> Forward
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -261,7 +287,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
|||||||
}}
|
}}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
Turn R ↻
|
Turn R <RotateCw className="ml-1 h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Row 2: Left, Stop, Right */}
|
{/* Row 2: Left, Stop, Right */}
|
||||||
@@ -276,7 +302,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
|||||||
}}
|
}}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
← Left
|
<ArrowLeft className="mr-1 h-3 w-3" /> Left
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -289,7 +315,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
|||||||
}}
|
}}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
■ Stop
|
<Square className="mr-1 h-3 w-3 fill-current" /> Stop
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -302,7 +328,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
|||||||
}}
|
}}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
Right →
|
Right <ArrowRight className="ml-1 h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Row 3: Empty, Back, Empty */}
|
{/* Row 3: Empty, Back, Empty */}
|
||||||
@@ -318,7 +344,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
|||||||
}}
|
}}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
↓ Back
|
<ArrowDown className="mr-1 h-3 w-3" /> Back
|
||||||
</Button>
|
</Button>
|
||||||
<div></div>
|
<div></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -337,10 +363,14 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Type text to speak..."
|
placeholder="Type text to speak..."
|
||||||
className="flex-1 rounded-md border border-input bg-background px-2 py-1 text-xs ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex-1 rounded-md border px-2 py-1 text-xs focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && e.currentTarget.value.trim() && !readOnly) {
|
if (
|
||||||
|
e.key === "Enter" &&
|
||||||
|
e.currentTarget.value.trim() &&
|
||||||
|
!readOnly
|
||||||
|
) {
|
||||||
executeRosAction("nao6-ros2", "say_text", {
|
executeRosAction("nao6-ros2", "say_text", {
|
||||||
text: e.currentTarget.value.trim(),
|
text: e.currentTarget.value.trim(),
|
||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
@@ -353,7 +383,8 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
const input = e.currentTarget.previousElementSibling as HTMLInputElement;
|
const input = e.currentTarget
|
||||||
|
.previousElementSibling as HTMLInputElement;
|
||||||
if (input?.value.trim()) {
|
if (input?.value.trim()) {
|
||||||
executeRosAction("nao6-ros2", "say_text", {
|
executeRosAction("nao6-ros2", "say_text", {
|
||||||
text: input.value.trim(),
|
text: input.value.trim(),
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Send, Hash, Tag, Clock, Flag, CheckCircle, Bot, User, MessageSquare, AlertTriangle, Activity } from "lucide-react";
|
import {
|
||||||
|
Send,
|
||||||
|
Hash,
|
||||||
|
Tag,
|
||||||
|
Clock,
|
||||||
|
Flag,
|
||||||
|
CheckCircle,
|
||||||
|
Bot,
|
||||||
|
User,
|
||||||
|
MessageSquare,
|
||||||
|
AlertTriangle,
|
||||||
|
Activity,
|
||||||
|
} from "lucide-react";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Textarea } from "~/components/ui/textarea";
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||||
@@ -15,7 +27,6 @@ import {
|
|||||||
} from "~/components/ui/select";
|
} from "~/components/ui/select";
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
||||||
|
|
||||||
|
|
||||||
interface TrialEvent {
|
interface TrialEvent {
|
||||||
type: string;
|
type: string;
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
@@ -32,7 +43,6 @@ interface WizardObservationPaneProps {
|
|||||||
onFlagIntervention?: () => Promise<void> | void;
|
onFlagIntervention?: () => Promise<void> | void;
|
||||||
isSubmitting?: boolean;
|
isSubmitting?: boolean;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WizardObservationPane({
|
export function WizardObservationPane({
|
||||||
@@ -79,11 +89,15 @@ export function WizardObservationPane({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-background">
|
<div className="bg-background flex h-full flex-col">
|
||||||
<div className="flex-1 flex flex-col p-4 m-0 overflow-hidden">
|
<div className="m-0 flex flex-1 flex-col overflow-hidden p-4">
|
||||||
<div className="flex flex-1 flex-col gap-2">
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder={readOnly ? "Session is read-only" : (placeholders[category] || "Type your observation here...")}
|
placeholder={
|
||||||
|
readOnly
|
||||||
|
? "Session is read-only"
|
||||||
|
: placeholders[category] || "Type your observation here..."
|
||||||
|
}
|
||||||
className="flex-1 resize-none font-mono text-sm"
|
className="flex-1 resize-none font-mono text-sm"
|
||||||
value={note}
|
value={note}
|
||||||
onChange={(e) => setNote(e.target.value)}
|
onChange={(e) => setNote(e.target.value)}
|
||||||
@@ -91,11 +105,15 @@ export function WizardObservationPane({
|
|||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 shrink-0">
|
<div className="flex shrink-0 flex-col gap-2">
|
||||||
{/* Top Line: Category & Tags */}
|
{/* Top Line: Category & Tags */}
|
||||||
<div className="flex items-center gap-2 w-full">
|
<div className="flex w-full items-center gap-2">
|
||||||
<Select value={category} onValueChange={setCategory} disabled={readOnly}>
|
<Select
|
||||||
<SelectTrigger className="w-[140px] h-8 text-xs shrink-0">
|
value={category}
|
||||||
|
onValueChange={setCategory}
|
||||||
|
disabled={readOnly}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[140px] shrink-0 text-xs">
|
||||||
<SelectValue placeholder="Category" />
|
<SelectValue placeholder="Category" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -107,12 +125,14 @@ export function WizardObservationPane({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<div className="flex flex-1 min-w-[80px] items-center gap-2 rounded-md border px-2 h-8">
|
<div className="flex h-8 min-w-[80px] flex-1 items-center gap-2 rounded-md border px-2">
|
||||||
<Tag className={`h-3 w-3 shrink-0 ${readOnly ? "text-muted-foreground/50" : "text-muted-foreground"}`} />
|
<Tag
|
||||||
|
className={`h-3 w-3 shrink-0 ${readOnly ? "text-muted-foreground/50" : "text-muted-foreground"}`}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={readOnly ? "" : "Add tags..."}
|
placeholder={readOnly ? "" : "Add tags..."}
|
||||||
className="flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed w-full min-w-0"
|
className="placeholder:text-muted-foreground w-full min-w-0 flex-1 bg-transparent text-xs outline-none disabled:cursor-not-allowed"
|
||||||
value={currentTag}
|
value={currentTag}
|
||||||
onChange={(e) => setCurrentTag(e.target.value)}
|
onChange={(e) => setCurrentTag(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -128,14 +148,14 @@ export function WizardObservationPane({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Line: Actions */}
|
{/* Bottom Line: Actions */}
|
||||||
<div className="flex items-center justify-end gap-2 w-full">
|
<div className="flex w-full items-center justify-end gap-2">
|
||||||
{onFlagIntervention && (
|
{onFlagIntervention && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onFlagIntervention()}
|
onClick={() => onFlagIntervention()}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
className="h-8 shrink-0 flex-1 sm:flex-none border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300 dark:border-yellow-700/50 dark:hover:bg-yellow-900/40"
|
className="h-8 flex-1 shrink-0 border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800 sm:flex-none dark:border-yellow-700/50 dark:bg-yellow-900/20 dark:text-yellow-300 dark:hover:bg-yellow-900/40"
|
||||||
>
|
>
|
||||||
<AlertTriangle className="mr-2 h-3 w-3" />
|
<AlertTriangle className="mr-2 h-3 w-3" />
|
||||||
Intervention
|
Intervention
|
||||||
@@ -145,7 +165,7 @@ export function WizardObservationPane({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={isSubmitting || !note.trim() || readOnly}
|
disabled={isSubmitting || !note.trim() || readOnly}
|
||||||
className="h-8 shrink-0 flex-1 sm:flex-none"
|
className="h-8 flex-1 shrink-0 sm:flex-none"
|
||||||
>
|
>
|
||||||
<Send className="mr-2 h-3 w-3" />
|
<Send className="mr-2 h-3 w-3" />
|
||||||
Save Note
|
Save Note
|
||||||
@@ -159,7 +179,7 @@ export function WizardObservationPane({
|
|||||||
<Badge
|
<Badge
|
||||||
key={tag}
|
key={tag}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="px-1 py-0 text-[10px] cursor-pointer hover:bg-destructive/10 hover:text-destructive"
|
className="hover:bg-destructive/10 hover:text-destructive cursor-pointer px-1 py-0 text-[10px]"
|
||||||
onClick={() => setTags(tags.filter((t) => t !== tag))}
|
onClick={() => setTags(tags.filter((t) => t !== tag))}
|
||||||
>
|
>
|
||||||
#{tag}
|
#{tag}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||||
import { ChevronDownIcon } from "lucide-react"
|
import { ChevronDownIcon } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
function Accordion({
|
function Accordion({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AccordionItem({
|
function AccordionItem({
|
||||||
@@ -22,7 +22,7 @@ function AccordionItem({
|
|||||||
className={cn("border-b last:border-b-0", className)}
|
className={cn("border-b last:border-b-0", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AccordionTrigger({
|
function AccordionTrigger({
|
||||||
@@ -36,7 +36,7 @@ function AccordionTrigger({
|
|||||||
data-slot="accordion-trigger"
|
data-slot="accordion-trigger"
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -44,7 +44,7 @@ function AccordionTrigger({
|
|||||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||||
</AccordionPrimitive.Trigger>
|
</AccordionPrimitive.Trigger>
|
||||||
</AccordionPrimitive.Header>
|
</AccordionPrimitive.Header>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AccordionContent({
|
function AccordionContent({
|
||||||
@@ -60,7 +60,7 @@ function AccordionContent({
|
|||||||
>
|
>
|
||||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||||
</AccordionPrimitive.Content>
|
</AccordionPrimitive.Content>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user