feat: Implement digital signatures for participant consent and introduce study forms management.

This commit is contained in:
2026-03-02 10:51:20 -05:00
parent 61af467cc8
commit 0051946bde
172 changed files with 12612 additions and 9461 deletions
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+27 -27
View File
@@ -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";
@@ -9,38 +8,39 @@ const connection = postgres(connectionString);
const db = drizzle(connection, { schema }); const db = drizzle(connection, { schema });
async function main() { 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 }) =>
eq(actions.type, "sequence"), or(
eq(actions.type, "parallel"), eq(actions.type, "sequence"),
eq(actions.type, "loop"), eq(actions.type, "parallel"),
eq(actions.type, "branch"), eq(actions.type, "loop"),
like(actions.type, "hristudio-core%") eq(actions.type, "branch"),
), like(actions.type, "hristudio-core%"),
limit: 10 ),
}); limit: 10,
});
console.log(`Found ${actions.length} control actions.`); console.log(`Found ${actions.length} control actions.`);
for (const action of actions) { for (const action of actions) {
console.log(`\nAction: ${action.name} (${action.type})`); console.log(`\nAction: ${action.name} (${action.type})`);
console.log(`ID: ${action.id}`); console.log(`ID: ${action.id}`);
// Explicitly log parameters to check structure // Explicitly log parameters to check structure
console.log("Parameters:", JSON.stringify(action.parameters, null, 2)); console.log("Parameters:", JSON.stringify(action.parameters, null, 2));
const params = action.parameters as any; const params = action.parameters as any;
if (params.children) { if (params.children) {
console.log(`✅ Has ${params.children.length} children in parameters.`); console.log(`✅ Has ${params.children.length} children in parameters.`);
} else if (params.trueBranch || params.falseBranch) { } else if (params.trueBranch || params.falseBranch) {
console.log(`✅ Has branches in parameters.`); console.log(`✅ Has branches in parameters.`);
} else { } else {
console.log(`❌ No children/branches found in parameters.`); console.log(`❌ No children/branches found in parameters.`);
}
} }
}
await connection.end(); await connection.end();
} }
main(); main();
+41 -40
View File
@@ -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";
@@ -9,57 +8,59 @@ const connection = postgres(connectionString);
const db = drizzle(connection, { schema }); const db = drizzle(connection, { schema });
async function main() { async function main() {
console.log("🔍 Checking Database State..."); console.log("🔍 Checking Database State...");
// 1. Check Plugin // 1. Check Plugin
const plugins = await db.query.plugins.findMany(); const plugins = await db.query.plugins.findMany();
console.log(`\nFound ${plugins.length} plugins.`); console.log(`\nFound ${plugins.length} plugins.`);
const expectedKeys = new Set<string>(); const expectedKeys = new Set<string>();
for (const p of plugins) { for (const p of plugins) {
const meta = p.metadata as any; const meta = p.metadata as any;
const defs = p.actionDefinitions as any[]; const defs = p.actionDefinitions as any[];
console.log(`Plugin [${p.name}] (ID: ${p.id}):`); console.log(`Plugin [${p.name}] (ID: ${p.id}):`);
console.log(` - Robot ID (Column): ${p.robotId}`); console.log(` - Robot ID (Column): ${p.robotId}`);
console.log(` - Metadata.robotId: ${meta?.robotId}`); console.log(` - Metadata.robotId: ${meta?.robotId}`);
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}`);
}); });
}
} }
}
// 2. Check Actions // 2. Check Actions
const actions = await db.query.actions.findMany(); const actions = await db.query.actions.findMany();
console.log(`\nFound ${actions.length} actions.`); console.log(`\nFound ${actions.length} actions.`);
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++;
}
} }
}
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();
} }
main().catch(console.error); main().catch(console.error);
+47 -45
View File
@@ -1,58 +1,60 @@
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";
async function debugExperimentStructure() { async function debugExperimentStructure() {
console.log("Debugging Experiment Structure for Interactive Storyteller..."); console.log("Debugging Experiment Structure for Interactive Storyteller...");
// Find the experiment // Find the experiment
const experiment = await db.query.experiments.findFirst({ const experiment = await db.query.experiments.findFirst({
where: eq(experiments.name, "The Interactive Storyteller"), where: eq(experiments.name, "The Interactive Storyteller"),
with: {
steps: {
orderBy: [asc(steps.orderIndex)],
with: { with: {
steps: { actions: {
orderBy: [asc(steps.orderIndex)], orderBy: [asc(actions.orderIndex)],
with: { },
actions: { },
orderBy: [asc(actions.orderIndex)], },
} },
} });
}
}
});
if (!experiment) { if (!experiment) {
console.error("Experiment not found!"); console.error("Experiment not found!");
return; return;
}
console.log(`Experiment: ${experiment.name} (${experiment.id})`);
console.log(`Plugin Dependencies:`, experiment.pluginDependencies);
console.log("---------------------------------------------------");
experiment.steps.forEach((step, index) => {
console.log(`Step ${index + 1}: ${step.name}`);
console.log(` ID: ${step.id}`);
console.log(` Type: ${step.type}`);
console.log(` Order: ${step.orderIndex}`);
console.log(` Conditions:`, JSON.stringify(step.conditions, null, 2));
if (step.actions && step.actions.length > 0) {
console.log(` Actions (${step.actions.length}):`);
step.actions.forEach((action, actionIndex) => {
console.log(` ${actionIndex + 1}. [${action.type}] ${action.name}`);
if (action.type === "wizard_wait_for_response") {
console.log(
` Options:`,
JSON.stringify((action.parameters as any)?.options, null, 2),
);
}
});
} }
console.log(`Experiment: ${experiment.name} (${experiment.id})`);
console.log(`Plugin Dependencies:`, experiment.pluginDependencies);
console.log("---------------------------------------------------"); console.log("---------------------------------------------------");
});
experiment.steps.forEach((step, index) => {
console.log(`Step ${index + 1}: ${step.name}`);
console.log(` ID: ${step.id}`);
console.log(` Type: ${step.type}`);
console.log(` Order: ${step.orderIndex}`);
console.log(` Conditions:`, JSON.stringify(step.conditions, null, 2));
if (step.actions && step.actions.length > 0) {
console.log(` Actions (${step.actions.length}):`);
step.actions.forEach((action, actionIndex) => {
console.log(` ${actionIndex + 1}. [${action.type}] ${action.name}`);
if (action.type === 'wizard_wait_for_response') {
console.log(` Options:`, JSON.stringify((action.parameters as any)?.options, null, 2));
}
});
}
console.log("---------------------------------------------------");
});
} }
debugExperimentStructure() debugExperimentStructure()
.then(() => process.exit(0)) .then(() => process.exit(0))
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
process.exit(1); process.exit(1);
}); });
+30 -31
View File
@@ -1,42 +1,41 @@
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";
async function inspectAllSteps() { async function inspectAllSteps() {
const result = await db.query.experiments.findMany({ const result = await db.query.experiments.findMany({
with: { with: {
steps: { steps: {
orderBy: (steps, { asc }) => [asc(steps.orderIndex)], orderBy: (steps, { asc }) => [asc(steps.orderIndex)],
columns: { columns: {
id: true, id: true,
name: true, name: true,
type: true, type: true,
orderIndex: true, orderIndex: true,
conditions: true, conditions: true,
} },
} },
} },
}); });
console.log(`Found ${result.length} experiments.`); console.log(`Found ${result.length} experiments.`);
for (const exp of result) { for (const exp of result) {
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("---");
}
} }
inspectAllSteps() inspectAllSteps()
.then(() => process.exit(0)) .then(() => process.exit(0))
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
process.exit(1); process.exit(1);
}); });
+33 -34
View File
@@ -1,47 +1,46 @@
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";
async function inspectAction() { async function inspectAction() {
console.log("Inspecting Action 10851aef-e720-45fc-ba5e-05e1e3425dab..."); console.log("Inspecting Action 10851aef-e720-45fc-ba5e-05e1e3425dab...");
const actionId = "10851aef-e720-45fc-ba5e-05e1e3425dab"; const actionId = "10851aef-e720-45fc-ba5e-05e1e3425dab";
const action = await db.query.actions.findFirst({ const action = await db.query.actions.findFirst({
where: eq(actions.id, actionId), where: eq(actions.id, actionId),
with: { with: {
step: { step: {
columns: { columns: {
id: true, id: true,
name: true, name: true,
type: true, type: true,
conditions: true conditions: true,
} },
} },
} },
}); });
if (!action) { if (!action) {
console.error("Action not found!"); console.error("Action not found!");
return; return;
} }
console.log("Action Found:"); console.log("Action Found:");
console.log(" Name:", action.name); console.log(" Name:", action.name);
console.log(" Type:", action.type); console.log(" Type:", action.type);
console.log(" Parameters:", JSON.stringify(action.parameters, null, 2)); console.log(" Parameters:", JSON.stringify(action.parameters, null, 2));
console.log("Parent Step:"); console.log("Parent Step:");
console.log(" ID:", action.step.id); console.log(" ID:", action.step.id);
console.log(" Name:", action.step.name); console.log(" Name:", action.step.name);
console.log(" Type:", action.step.type); console.log(" Type:", action.step.type);
console.log(" Conditions:", JSON.stringify(action.step.conditions, null, 2)); console.log(" Conditions:", JSON.stringify(action.step.conditions, null, 2));
} }
inspectAction() inspectAction()
.then(() => process.exit(0)) .then(() => process.exit(0))
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
process.exit(1); process.exit(1);
}); });
+18 -19
View File
@@ -1,30 +1,29 @@
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";
async function inspectBranchSteps() { async function inspectBranchSteps() {
console.log("Inspecting Steps 4 (Branch A) and 5 (Branch B)..."); console.log("Inspecting Steps 4 (Branch A) and 5 (Branch B)...");
const step4Id = "3a2dc0b7-a43e-4236-9b9e-f957abafc1e5"; const step4Id = "3a2dc0b7-a43e-4236-9b9e-f957abafc1e5";
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}`);
console.log(` Conditions:`, JSON.stringify(step.conditions, null, 2)); console.log(` Conditions:`, JSON.stringify(step.conditions, null, 2));
console.log("---------------------------------------------------"); console.log("---------------------------------------------------");
}); });
} }
inspectBranchSteps() inspectBranchSteps()
.then(() => process.exit(0)) .then(() => process.exit(0))
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
process.exit(1); process.exit(1);
}); });
+19 -14
View File
@@ -1,24 +1,29 @@
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);
for (const step of allSteps) { for (const step of allSteps) {
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),
);
}
} }
checkSteps() checkSteps()
.then(() => process.exit(0)) .then(() => process.exit(0))
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
process.exit(1); process.exit(1);
}); });
+49 -48
View File
@@ -1,61 +1,62 @@
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";
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) {
console.log("Experiment not found!"); console.log("Experiment not found!");
return; return;
}
console.log(`Inspecting Experiment: ${experiment.name} (${experiment.id})`);
const experimentSteps = await db.query.steps.findMany({
where: eq(steps.experimentId, experiment.id),
orderBy: [asc(steps.orderIndex)],
with: {
actions: {
orderBy: (actions, { asc }) => [asc(actions.orderIndex)],
},
},
});
console.log(`Found ${experimentSteps.length} steps.`);
for (const step of experimentSteps) {
console.log("--------------------------------------------------");
console.log(`Step [${step.orderIndex}] ID: ${step.id}`);
console.log(`Name: ${step.name}`);
console.log(`Type: ${step.type}`);
if (step.type === "conditional") {
console.log("Conditions:", JSON.stringify(step.conditions, null, 2));
} }
console.log(`Inspecting Experiment: ${experiment.name} (${experiment.id})`); if (step.actions.length > 0) {
console.log("Actions:");
const experimentSteps = await db.query.steps.findMany({ for (const action of step.actions) {
where: eq(steps.experimentId, experiment.id), console.log(
orderBy: [asc(steps.orderIndex)], ` - [${action.orderIndex}] ${action.name} (${action.type})`,
with: { );
actions: { if (action.type === "wizard_wait_for_response") {
orderBy: (actions, { asc }) => [asc(actions.orderIndex)] console.log(
} " Parameters:",
} JSON.stringify(action.parameters, null, 2),
}); );
console.log(`Found ${experimentSteps.length} steps.`);
for (const step of experimentSteps) {
console.log("--------------------------------------------------");
console.log(`Step [${step.orderIndex}] ID: ${step.id}`);
console.log(`Name: ${step.name}`);
console.log(`Type: ${step.type}`);
if (step.type === 'conditional') {
console.log("Conditions:", JSON.stringify(step.conditions, null, 2));
}
if (step.actions.length > 0) {
console.log("Actions:");
for (const action of step.actions) {
console.log(` - [${action.orderIndex}] ${action.name} (${action.type})`);
if (action.type === 'wizard_wait_for_response') {
console.log(" Parameters:", JSON.stringify(action.parameters, null, 2));
}
}
} }
}
} }
}
} }
inspectExperimentSteps() inspectExperimentSteps()
.then(() => process.exit(0)) .then(() => process.exit(0))
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
process.exit(1); process.exit(1);
}); });
+22 -23
View File
@@ -1,33 +1,32 @@
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";
async function inspectVisualDesign() { async function inspectVisualDesign() {
const exps = await db.select().from(experiments); const exps = await db.select().from(experiments);
for (const exp of exps) { for (const exp of exps) {
console.log(`Experiment: ${exp.name}`); console.log(`Experiment: ${exp.name}`);
if (exp.visualDesign) { if (exp.visualDesign) {
const vd = exp.visualDesign as any; const vd = exp.visualDesign as any;
console.log("Visual Design Steps:"); console.log("Visual Design Steps:");
if (vd.steps && Array.isArray(vd.steps)) { if (vd.steps && Array.isArray(vd.steps)) {
vd.steps.forEach((s: any, i: number) => { vd.steps.forEach((s: any, i: number) => {
console.log(` [${i}] ${s.name} (${s.type})`); console.log(` [${i}] ${s.name} (${s.type})`);
console.log(` Trigger: ${JSON.stringify(s.trigger)}`); console.log(` Trigger: ${JSON.stringify(s.trigger)}`);
}); });
} else { } else {
console.log(" No steps in visualDesign or invalid format."); console.log(" No steps in visualDesign or invalid format.");
} }
} else { } else {
console.log(" No visualDesign blob."); console.log(" No visualDesign blob.");
}
} }
}
} }
inspectVisualDesign() inspectVisualDesign()
.then(() => process.exit(0)) .then(() => process.exit(0))
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
process.exit(1); process.exit(1);
}); });
+49 -44
View File
@@ -1,69 +1,74 @@
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";
async function patchActionParams() { async function patchActionParams() {
console.log("Patching Action Parameters for Interactive Storyteller..."); console.log("Patching Action Parameters for Interactive Storyteller...");
// Target Step IDs // Target Step IDs
const step3CondId = "b9d43f8c-c40c-4f1c-9fdc-9076338d3c85"; // Step 3: Comprehension Check const step3CondId = "b9d43f8c-c40c-4f1c-9fdc-9076338d3c85"; // Step 3: Comprehension Check
const actionId = "10851aef-e720-45fc-ba5e-05e1e3425dab"; // Action: Wait for Choice const actionId = "10851aef-e720-45fc-ba5e-05e1e3425dab"; // Action: Wait for Choice
// 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) {
console.error("Step 3 not found!"); console.error("Step 3 not found!");
return; return;
} }
const conditions = step.conditions as any; const conditions = step.conditions as any;
const richOptions = conditions?.options; const richOptions = conditions?.options;
if (!richOptions || !Array.isArray(richOptions)) { if (!richOptions || !Array.isArray(richOptions)) {
console.error("Step 3 conditions are missing valid options!"); console.error("Step 3 conditions are missing valid options!");
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) {
console.error("Action not found!"); console.error("Action not found!");
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));
await db.execute(sql` await db.execute(sql`
UPDATE hs_action UPDATE hs_action
SET parameters = ${JSON.stringify(newParams)}::jsonb SET parameters = ${JSON.stringify(newParams)}::jsonb
WHERE id = ${actionId} WHERE id = ${actionId}
`); `);
console.log("Action parameters successfully patched."); console.log("Action parameters successfully patched.");
} }
patchActionParams() patchActionParams()
.then(() => process.exit(0)) .then(() => process.exit(0))
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
process.exit(1); process.exit(1);
}); });
+64 -56
View File
@@ -1,92 +1,100 @@
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";
async function patchBranchSteps() { async function patchBranchSteps() {
console.log("Patching branch steps for Interactive Storyteller..."); console.log("Patching branch steps for Interactive Storyteller...");
// Target Step IDs (From debug output) // Target Step IDs (From debug output)
const step3CondId = "b9d43f8c-c40c-4f1c-9fdc-9076338d3c85"; // Step 3: Comprehension Check const step3CondId = "b9d43f8c-c40c-4f1c-9fdc-9076338d3c85"; // Step 3: Comprehension Check
const stepBranchAId = "3a2dc0b7-a43e-4236-9b9e-f957abafc1e5"; // Step 4: Branch A (Correct) const stepBranchAId = "3a2dc0b7-a43e-4236-9b9e-f957abafc1e5"; // Step 4: Branch A (Correct)
const stepBranchBId = "3ae2fe8a-fc5d-4a04-baa5-699a21f19e30"; // Step 5: Branch B (Incorrect) const stepBranchBId = "3ae2fe8a-fc5d-4a04-baa5-699a21f19e30"; // Step 5: Branch B (Incorrect)
const stepConclusionId = "cc3fbc7f-29e5-45e0-8d46-e80813c54292"; // Step 6: Conclusion const stepConclusionId = "cc3fbc7f-29e5-45e0-8d46-e80813c54292"; // Step 6: Conclusion
// 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) {
const currentConditions = (step3Conditional.conditions as any) || {};
const options = currentConditions.options || [];
// Patch options to point to real step IDs
const newOptions = options.map((opt: any) => {
if (opt.value === "Correct") return { ...opt, nextStepId: stepBranchAId };
if (opt.value === "Incorrect")
return { ...opt, nextStepId: stepBranchBId };
return opt;
}); });
if (step3Conditional) { const newConditions = { ...currentConditions, options: newOptions };
const currentConditions = (step3Conditional.conditions as any) || {};
const options = currentConditions.options || [];
// Patch options to point to real step IDs await db.execute(sql`
const newOptions = options.map((opt: any) => {
if (opt.value === "Correct") return { ...opt, nextStepId: stepBranchAId };
if (opt.value === "Incorrect") return { ...opt, nextStepId: stepBranchBId };
return opt;
});
const newConditions = { ...currentConditions, options: newOptions };
await db.execute(sql`
UPDATE hs_step UPDATE hs_step
SET conditions = ${JSON.stringify(newConditions)}::jsonb SET conditions = ${JSON.stringify(newConditions)}::jsonb
WHERE id = ${step3CondId} WHERE id = ${step3CondId}
`); `);
console.log("Step 3 (Conditional) updated links."); console.log("Step 3 (Conditional) updated links.");
} else { } else {
console.log("Step 3 (Conditional) not found."); console.log("Step 3 (Conditional) not found.");
} }
// Update Step 4 (Branch A) // Update Step 4 (Branch A)
console.log("Updating Step 4 (Branch A)..."); console.log("Updating Step 4 (Branch A)...");
/* /*
Note: We already patched Step 4 in previous run but under wrong assumption? Note: We already patched Step 4 in previous run but under wrong assumption?
Let's re-patch to be safe. Let's re-patch to be safe.
Debug output showed ID: 3a2dc0b7-a43e-4236-9b9e-f957abafc1e5 Debug output showed ID: 3a2dc0b7-a43e-4236-9b9e-f957abafc1e5
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
SET conditions = ${JSON.stringify(newConditions)}::jsonb SET conditions = ${JSON.stringify(newConditions)}::jsonb
WHERE id = ${stepBranchAId} WHERE id = ${stepBranchAId}
`); `);
console.log("Step 4 (Branch A) updated jump target."); console.log("Step 4 (Branch A) updated jump target.");
} }
// 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
SET conditions = ${JSON.stringify(newConditions)}::jsonb SET conditions = ${JSON.stringify(newConditions)}::jsonb
WHERE id = ${stepBranchBId} WHERE id = ${stepBranchBId}
`); `);
console.log("Step 5 (Branch B) updated jump target."); console.log("Step 5 (Branch B) updated jump target.");
} }
} }
patchBranchSteps() patchBranchSteps()
.then(() => process.exit(0)) .then(() => process.exit(0))
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
process.exit(1); process.exit(1);
}); });
+47 -36
View File
@@ -1,43 +1,52 @@
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";
// Mock DB Steps (simulating what experimentsRouter returns before conversion) // Mock DB Steps (simulating what experimentsRouter returns before conversion)
const mockDbSteps = [ const mockDbSteps = [
{ {
id: "step-1", id: "step-1",
name: "Step 1", name: "Step 1",
type: "wizard", type: "wizard",
orderIndex: 0, orderIndex: 0,
actions: [ actions: [
{
id: "seq-1",
name: "Test Sequence",
type: "sequence",
parameters: {
children: [
{ {
id: "seq-1", id: "child-1",
name: "Test Sequence", name: "Child 1",
type: "sequence", type: "wait",
parameters: { parameters: { duration: 1 },
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-2",
] name: "Child 2",
} type: "wait",
} parameters: { duration: 2 },
] },
} ],
},
},
],
},
]; ];
// Mock Store Logic (simulating store.ts) // Mock Store Logic (simulating store.ts)
function cloneActions(actions: any[]): any[] { function cloneActions(actions: any[]): any[] {
return actions.map((a) => ({ return actions.map((a) => ({
...a, ...a,
children: a.children ? cloneActions(a.children) : undefined, children: a.children ? cloneActions(a.children) : undefined,
})); }));
} }
function cloneSteps(steps: any[]): any[] { function cloneSteps(steps: any[]): any[] {
return steps.map((s) => ({ return steps.map((s) => ({
...s, ...s,
actions: cloneActions(s.actions), actions: cloneActions(s.actions),
})); }));
} }
console.log("🔹 Testing Hydration & Cloning..."); console.log("🔹 Testing Hydration & Cloning...");
@@ -47,15 +56,15 @@ const runtimeSteps = convertDatabaseToSteps(mockDbSteps);
const seq = runtimeSteps[0]?.actions[0]; const seq = runtimeSteps[0]?.actions[0];
if (!seq) { if (!seq) {
console.error("❌ Conversion Failed: Sequence action not found."); console.error("❌ Conversion Failed: Sequence action not found.");
process.exit(1); process.exit(1);
} }
console.log(`Runtime Children Count: ${seq.children?.length ?? "undefined"}`); console.log(`Runtime Children Count: ${seq.children?.length ?? "undefined"}`);
if (!seq.children || seq.children.length === 0) { if (!seq.children || seq.children.length === 0) {
console.error("❌ Conversion Failed: Children not hydrated from parameters."); console.error("❌ Conversion Failed: Children not hydrated from parameters.");
process.exit(1); process.exit(1);
} }
// 2. Store Cloning // 2. Store Cloning
@@ -63,14 +72,16 @@ const clonedSteps = cloneSteps(runtimeSteps);
const clonedSeq = clonedSteps[0]?.actions[0]; const clonedSeq = clonedSteps[0]?.actions[0];
if (!clonedSeq) { if (!clonedSeq) {
console.error("❌ Cloning Failed: Sequence action lost."); console.error("❌ Cloning Failed: Sequence action lost.");
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.");
} else { } else {
console.error("❌ CLONING FAILED: Children lost during clone."); console.error("❌ CLONING FAILED: Children lost during clone.");
} }
+109 -94
View File
@@ -9,113 +9,128 @@ const connection = postgres(connectionString);
const db = drizzle(connection, { schema }); const db = drizzle(connection, { schema });
async function main() { async function main() {
console.log("🌱 Seeding 'Control Flow Demo' experiment..."); console.log("🌱 Seeding 'Control Flow Demo' experiment...");
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
const [experiment] = await db
.insert(schema.experiments)
.values({
studyId: study.id,
name: "Control Flow Demo",
description:
"Demonstration of enhanced control flow actions: Sequence, Parallel, Wait, Loop, Branch.",
version: 1,
status: "draft",
robotId: robot.id,
createdBy: user.id,
})
.returning();
// 2. Create Experiment if (!experiment) throw new Error("Failed to create experiment");
const [experiment] = await db.insert(schema.experiments).values({ console.log(`✅ Created Experiment: ${experiment.id}`);
studyId: study.id,
name: "Control Flow Demo",
description: "Demonstration of enhanced control flow actions: Sequence, Parallel, Wait, Loop, Branch.",
version: 1,
status: "draft",
robotId: robot.id,
createdBy: user.id,
}).returning();
if (!experiment) throw new Error("Failed to create experiment"); // 3. Create Steps
console.log(`✅ Created Experiment: ${experiment.id}`);
// 3. Create Steps // Step 1: Sequence & Parallel
const [step1] = await db
.insert(schema.steps)
.values({
experimentId: experiment.id,
name: "Complex Action Structures",
description: "Demonstrating Sequence and Parallel groups",
type: "robot",
orderIndex: 0,
required: true,
durationEstimate: 30,
})
.returning();
// Step 1: Sequence & Parallel // Step 2: Loops & Waits
const [step1] = await db.insert(schema.steps).values({ const [step2] = await db
experimentId: experiment.id, .insert(schema.steps)
name: "Complex Action Structures", .values({
description: "Demonstrating Sequence and Parallel groups", experimentId: experiment.id,
type: "robot", name: "Repetition & Delays",
orderIndex: 0, description: "Demonstrating Loop and Wait actions",
required: true, type: "robot",
durationEstimate: 30 orderIndex: 1,
}).returning(); required: true,
durationEstimate: 45,
})
.returning();
// Step 2: Loops & Waits // 4. Create Actions
const [step2] = await db.insert(schema.steps).values({
experimentId: experiment.id,
name: "Repetition & Delays",
description: "Demonstrating Loop and Wait actions",
type: "robot",
orderIndex: 1,
required: true,
durationEstimate: 45
}).returning();
// 4. Create Actions // --- Step 1 Actions ---
// --- Step 1 Actions --- // Top-level Sequence
const seqId = `seq-${Date.now()}`;
await db.insert(schema.actions).values({
stepId: step1!.id,
name: "Introduction Sequence",
type: "sequence", // New type
orderIndex: 0,
parameters: {},
pluginId: "hristudio-core",
category: "control",
// No explicit children column in schema?
// Wait, schema.actions has "children" as jsonb or it's a recursive relationship?
// Let's check schema/types.
// Looking at ActionChip, it expects `action.children`.
// In DB, it's likely stored in `children` jsonb column if it exists, OR we need to perform recursive inserts if schema supports parentId.
// Checking `types.ts` or schema...
// Assuming flat list references for now or JSONB.
// Wait, `ExperimentAction` in types has `children?: ExperimentAction[]`.
// If the DB schema `actions` table handles nesting via `parameters` or specific column, I need to know.
// Defaulting to "children" property in JSON parameter if DB doesn't have parentId.
// Checking `schema.ts`: "children" is likely NOT a column if I haven't seen it in seed-dev.
// However, `ActionChip` uses `action.children`. Steps map to `actions`.
// If `actions` table has `parentId` or `children` JSONB.
// I will assume `children` is part of the `parameters` or a simplified representation for now,
// BUT `FlowWorkspace` treats `action.children` as real actions.
// Let's check `schema.ts` quickly.
});
// Top-level Sequence // I need to check schema.actions definition effectively.
const seqId = `seq-${Date.now()}`; // For this pass, I will insert them as flat actions since I can't confirm nesting storage without checking schema.
await db.insert(schema.actions).values({ // But the user WANTS to see the nesting (Sequence, Parallel).
stepId: step1!.id, // The `SortableActionChip` renders `action.children`.
name: "Introduction Sequence", // The `TrialExecutionEngine` executes `action.children`.
type: "sequence", // New type // So the data MUST include children.
orderIndex: 0, // Most likely `actions` table has a `children` JSONB column.
parameters: {},
pluginId: "hristudio-core",
category: "control",
// No explicit children column in schema?
// Wait, schema.actions has "children" as jsonb or it's a recursive relationship?
// Let's check schema/types.
// Looking at ActionChip, it expects `action.children`.
// In DB, it's likely stored in `children` jsonb column if it exists, OR we need to perform recursive inserts if schema supports parentId.
// Checking `types.ts` or schema...
// Assuming flat list references for now or JSONB.
// Wait, `ExperimentAction` in types has `children?: ExperimentAction[]`.
// If the DB schema `actions` table handles nesting via `parameters` or specific column, I need to know.
// Defaulting to "children" property in JSON parameter if DB doesn't have parentId.
// Checking `schema.ts`: "children" is likely NOT a column if I haven't seen it in seed-dev.
// However, `ActionChip` uses `action.children`. Steps map to `actions`.
// If `actions` table has `parentId` or `children` JSONB.
// I will assume `children` is part of the `parameters` or a simplified representation for now,
// BUT `FlowWorkspace` treats `action.children` as real actions.
// Let's check `schema.ts` quickly.
});
// I need to check schema.actions definition effectively. // I will insert a Parallel action with embedded children in the `children` column (if it exists) or `parameters`.
// For this pass, I will insert them as flat actions since I can't confirm nesting storage without checking schema. // Re-reading `scripts/seed-dev.ts`: It doesn't show any nested actions.
// But the user WANTS to see the nesting (Sequence, Parallel). // I will read `src/server/db/schema.ts` to be sure.
// The `SortableActionChip` renders `action.children`. } catch (err) {
// The `TrialExecutionEngine` executes `action.children`. console.error(err);
// So the data MUST include children. process.exit(1);
// Most likely `actions` table has a `children` JSONB column. }
// 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.
// I will read `src/server/db/schema.ts` to be sure.
} catch (err) {
console.error(err);
process.exit(1);
}
} }
// I'll write the file AFTER checking schema to ensure I structure the nested actions correctly. // I'll write the file AFTER checking schema to ensure I structure the nested actions correctly.
+224 -211
View File
@@ -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,231 +10,245 @@ const connection = postgres(connectionString);
const db = drizzle(connection, { schema }); const db = drizzle(connection, { schema });
async function main() { async function main() {
console.log("🌱 Seeding 'Control Flow Demo' experiment..."); console.log("🌱 Seeding 'Control Flow Demo' experiment...");
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
const [experiment] = await db
.insert(schema.experiments)
.values({
studyId: study.id,
name: "Control Flow Demo",
description:
"Demonstration of enhanced control flow actions: Sequence, Parallel, Wait, Loop, Branch.",
version: 1,
status: "draft",
robotId: robot.id,
createdBy: user.id,
})
.returning();
// 2. Create Experiment if (!experiment) throw new Error("Failed to create experiment");
const [experiment] = await db.insert(schema.experiments).values({ console.log(`✅ Created Experiment: ${experiment.id}`);
studyId: study.id,
name: "Control Flow Demo",
description: "Demonstration of enhanced control flow actions: Sequence, Parallel, Wait, Loop, Branch.",
version: 1,
status: "draft",
robotId: robot.id,
createdBy: user.id,
}).returning();
if (!experiment) throw new Error("Failed to create experiment"); // 3. Create Steps
console.log(`✅ Created Experiment: ${experiment.id}`);
// 3. Create Steps // Step 1: Sequence & Parallel
const [step1] = await db
.insert(schema.steps)
.values({
experimentId: experiment.id,
name: "Complex Action Structures",
description: "Demonstrating Sequence and Parallel groups",
type: "robot",
orderIndex: 0,
required: true,
durationEstimate: 30,
})
.returning();
if (!step1) throw new Error("Failed to create step1");
// Step 1: Sequence & Parallel // Step 2: Loops & Waits
const [step1] = await db.insert(schema.steps).values({ const [step2] = await db
experimentId: experiment.id, .insert(schema.steps)
name: "Complex Action Structures", .values({
description: "Demonstrating Sequence and Parallel groups", experimentId: experiment.id,
type: "robot", name: "Repetition & Delays",
orderIndex: 0, description: "Demonstrating Loop and Wait actions",
required: true, type: "robot",
durationEstimate: 30 orderIndex: 1,
}).returning(); required: true,
if (!step1) throw new Error("Failed to create step1"); durationEstimate: 45,
})
.returning();
if (!step2) throw new Error("Failed to create step2");
// Step 2: Loops & Waits // 4. Create Actions
const [step2] = await db.insert(schema.steps).values({
experimentId: experiment.id,
name: "Repetition & Delays",
description: "Demonstrating Loop and Wait actions",
type: "robot",
orderIndex: 1,
required: true,
durationEstimate: 45
}).returning();
if (!step2) throw new Error("Failed to create step2");
// 4. Create Actions // --- Step 1 Actions ---
// --- Step 1 Actions --- // Action 1: Sequence
// Note: Nested children are stored in 'children' property of the action object in frontend,
// but in DB 'parameters' is the JSONB field.
// However, looking at ActionChip, it expects `action.children`.
// The `ExperimentAction` type usually has `children` at top level.
// If the DB doesn't have it, the API must be hydrating it.
// BUT, for the purpose of this seed which writes to DB directly, I will put it in `parameters.children`
// and assume the frontend/API handles it or I'm missing a column.
// Actually, looking at schema again, `actions` table DOES NOT have children.
// So it MUST be in `parameters` or it's not persisted in this table structure yet (which would be a bug, but I'm seeding what exists).
// Wait, if I put it in parameters, does the UI read it?
// `ActionChip` reads `action.children`.
// I will try to put it in `parameters` and distinct `children` property in the JSON passed to `parameters`?
// No, `parameters` is jsonb.
// I will assume for now that the system expects it in parameters if it's not a column, OR it's not fully supported in DB yet.
// I will stick to what the UI likely consumes. `parameters: { children: [...] }`
// Action 1: Sequence // Sequence
// Note: Nested children are stored in 'children' property of the action object in frontend, await db.insert(schema.actions).values({
// but in DB 'parameters' is the JSONB field. stepId: step1.id,
// However, looking at ActionChip, it expects `action.children`. name: "Introduction Sequence",
// The `ExperimentAction` type usually has `children` at top level. type: "sequence",
// If the DB doesn't have it, the API must be hydrating it. orderIndex: 0,
// BUT, for the purpose of this seed which writes to DB directly, I will put it in `parameters.children` // Embedding children here to demonstrate.
// and assume the frontend/API handles it or I'm missing a column. // Real implementation might vary if keys are strictly checked.
// Actually, looking at schema again, `actions` table DOES NOT have children. parameters: {
// So it MUST be in `parameters` or it's not persisted in this table structure yet (which would be a bug, but I'm seeding what exists). children: [
// Wait, if I put it in parameters, does the UI read it? {
// `ActionChip` reads `action.children`. id: uuidv4(),
// I will try to put it in `parameters` and distinct `children` property in the JSON passed to `parameters`? name: "Say Hello",
// No, `parameters` is jsonb. type: "nao6-ros2.say_text",
// I will assume for now that the system expects it in parameters if it's not a column, OR it's not fully supported in DB yet. parameters: { text: "Hello there!" },
// I will stick to what the UI likely consumes. `parameters: { children: [...] }` category: "interaction",
},
{
id: uuidv4(),
name: "Wave Hand",
type: "nao6-ros2.move_arm",
parameters: { arm: "right", action: "wave" },
category: "movement",
},
],
},
pluginId: "hristudio-core",
category: "control",
sourceKind: "core",
});
// Sequence // Parallel
await db.insert(schema.actions).values({ await db.insert(schema.actions).values({
stepId: step1.id, stepId: step1.id,
name: "Introduction Sequence", name: "Parallel Actions",
type: "sequence", type: "parallel",
orderIndex: 0, orderIndex: 1,
// Embedding children here to demonstrate. parameters: {
// Real implementation might vary if keys are strictly checked. children: [
parameters: { {
children: [ id: uuidv4(),
{ name: "Say 'Moving'",
id: uuidv4(), type: "nao6-ros2.say_text",
name: "Say Hello", parameters: { text: "I am moving and talking." },
type: "nao6-ros2.say_text", category: "interaction",
parameters: { text: "Hello there!" }, },
category: "interaction" {
}, id: uuidv4(),
{ name: "Walk Forward",
id: uuidv4(), type: "nao6-ros2.move_to",
name: "Wave Hand", parameters: { x: 0.5, y: 0 },
type: "nao6-ros2.move_arm", category: "movement",
parameters: { arm: "right", action: "wave" }, },
category: "movement" ],
} },
] pluginId: "hristudio-core",
category: "control",
sourceKind: "core",
});
// --- Step 2 Actions ---
// Loop
await db.insert(schema.actions).values({
stepId: step2.id,
name: "Repeat Message",
type: "loop",
orderIndex: 0,
parameters: {
iterations: 3,
children: [
{
id: uuidv4(),
name: "Say 'Echo'",
type: "nao6-ros2.say_text",
parameters: { text: "Echo" },
category: "interaction",
},
],
},
pluginId: "hristudio-core",
category: "control",
sourceKind: "core",
});
// Wait
await db.insert(schema.actions).values({
stepId: step2.id,
name: "Wait 5 Seconds",
type: "wait",
orderIndex: 1,
parameters: { duration: 5 },
pluginId: "hristudio-core",
category: "control",
sourceKind: "core",
});
// Branch (Controls step routing, not nested actions)
// Note: Branch configuration is stored in step.trigger.conditions, not action.parameters
// The branch action itself is just a marker that this step has conditional routing
await db.insert(schema.actions).values({
stepId: step2.id,
name: "Conditional Routing",
type: "branch",
orderIndex: 2,
parameters: {
// Branch actions don't have nested children
// Routing is configured at the step level via trigger.conditions
},
pluginId: "hristudio-core",
category: "control",
sourceKind: "core",
});
// Update step2 to have conditional routing
await db
.update(schema.steps)
.set({
type: "conditional",
conditions: {
options: [
{
label: "High Score Path",
nextStepIndex: 2, // Would go to a hypothetical step 3
variant: "default",
}, },
pluginId: "hristudio-core", {
category: "control", label: "Low Score Path",
sourceKind: "core" nextStepIndex: 0, // Loop back to step 1
}); variant: "outline",
// Parallel
await db.insert(schema.actions).values({
stepId: step1.id,
name: "Parallel Actions",
type: "parallel",
orderIndex: 1,
parameters: {
children: [
{
id: uuidv4(),
name: "Say 'Moving'",
type: "nao6-ros2.say_text",
parameters: { text: "I am moving and talking." },
category: "interaction"
},
{
id: uuidv4(),
name: "Walk Forward",
type: "nao6-ros2.move_to",
parameters: { x: 0.5, y: 0 },
category: "movement"
}
]
}, },
pluginId: "hristudio-core", ],
category: "control", },
sourceKind: "core" })
}); .where(sql`id = ${step2.id}`);
} catch (err) {
console.error(err);
// --- Step 2 Actions --- process.exit(1);
} finally {
// Loop await connection.end();
await db.insert(schema.actions).values({ }
stepId: step2.id,
name: "Repeat Message",
type: "loop",
orderIndex: 0,
parameters: {
iterations: 3,
children: [
{
id: uuidv4(),
name: "Say 'Echo'",
type: "nao6-ros2.say_text",
parameters: { text: "Echo" },
category: "interaction"
}
]
},
pluginId: "hristudio-core",
category: "control",
sourceKind: "core"
});
// Wait
await db.insert(schema.actions).values({
stepId: step2.id,
name: "Wait 5 Seconds",
type: "wait",
orderIndex: 1,
parameters: { duration: 5 },
pluginId: "hristudio-core",
category: "control",
sourceKind: "core"
});
// Branch (Controls step routing, not nested actions)
// Note: Branch configuration is stored in step.trigger.conditions, not action.parameters
// The branch action itself is just a marker that this step has conditional routing
await db.insert(schema.actions).values({
stepId: step2.id,
name: "Conditional Routing",
type: "branch",
orderIndex: 2,
parameters: {
// Branch actions don't have nested children
// Routing is configured at the step level via trigger.conditions
},
pluginId: "hristudio-core",
category: "control",
sourceKind: "core"
});
// Update step2 to have conditional routing
await db.update(schema.steps)
.set({
type: "conditional",
conditions: {
options: [
{
label: "High Score Path",
nextStepIndex: 2, // Would go to a hypothetical step 3
variant: "default"
},
{
label: "Low Score Path",
nextStepIndex: 0, // Loop back to step 1
variant: "outline"
}
]
}
})
.where(sql`id = ${step2.id}`);
} catch (err) {
console.error(err);
process.exit(1);
} finally {
await connection.end();
}
} }
main(); main();
+58 -50
View File
@@ -1,69 +1,77 @@
// 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",
name: "Step 4 (Branch A)",
order: 3,
conditions: {
nextStepId: "cc3fbc7f-29e5-45e0-8d46-e80813c54292",
}, },
{ },
id: "3a2dc0b7-a43e-4236-9b9e-f957abafc1e5", {
name: "Step 4 (Branch A)", id: "3ae2fe8a-fc5d-4a04-baa5-699a21f19e30",
order: 3, name: "Step 5 (Branch B)",
conditions: { order: 4,
"nextStepId": "cc3fbc7f-29e5-45e0-8d46-e80813c54292" conditions: {
} nextStepId: "cc3fbc7f-29e5-45e0-8d46-e80813c54292",
}, },
{ },
id: "3ae2fe8a-fc5d-4a04-baa5-699a21f19e30", {
name: "Step 5 (Branch B)", id: "cc3fbc7f-29e5-45e0-8d46-e80813c54292",
order: 4, name: "Step 6 (Conclusion)",
conditions: { order: 5,
"nextStepId": "cc3fbc7f-29e5-45e0-8d46-e80813c54292" },
}
},
{
id: "cc3fbc7f-29e5-45e0-8d46-e80813c54292",
name: "Step 6 (Conclusion)",
order: 5
}
]; ];
function simulateNextStep(currentStepIndex: number) { function simulateNextStep(currentStepIndex: number) {
const currentStep = steps[currentStepIndex]; const currentStep = steps[currentStepIndex];
if (!currentStep) { if (!currentStep) {
console.log("No step found at index:", currentStepIndex); console.log("No step found at index:", currentStepIndex);
return; return;
} }
console.log(`\n--- Simulating Next Step from: ${currentStep.name} ---`); console.log(`\n--- Simulating Next Step from: ${currentStep.name} ---`);
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(
return targetIndex; `[WizardInterface] Condition-based jump to step ${targetIndex} (${nextId})`,
} else { );
console.warn(`[WizardInterface] Targeted nextStepId ${nextId} not found in steps list.`); return targetIndex;
}
} else { } else {
console.log("[WizardInterface] No nextStepId found in conditions, proceeding linearly."); console.warn(
`[WizardInterface] Targeted nextStepId ${nextId} not found in steps list.`,
);
} }
} else {
console.log(
"[WizardInterface] No nextStepId found in conditions, proceeding linearly.",
);
}
// Default: Linear progression // Default: Linear progression
const nextIndex = currentStepIndex + 1; const nextIndex = currentStepIndex + 1;
console.log(`Proceeding linearly to index ${nextIndex}`); console.log(`Proceeding linearly to index ${nextIndex}`);
return nextIndex; return nextIndex;
} }
// Simulate Branch A (Index 1 in this array, but 3 in real experiment?) // Simulate Branch A (Index 1 in this array, but 3 in real experiment?)
+49 -50
View File
@@ -1,60 +1,59 @@
import { convertDatabaseToAction } from "../../src/lib/experiment-designer/block-converter"; import { convertDatabaseToAction } from "../../src/lib/experiment-designer/block-converter";
const mockDbAction = { const mockDbAction = {
id: "eaf8f85b-75cf-4973-b436-092516b4e0e4", id: "eaf8f85b-75cf-4973-b436-092516b4e0e4",
name: "Introduction Sequence", name: "Introduction Sequence",
description: null, description: null,
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,
sourceKind: "core", sourceKind: "core",
pluginId: "hristudio-core", pluginId: "hristudio-core",
pluginVersion: null, pluginVersion: null,
robotId: null, robotId: null,
baseActionId: null, baseActionId: null,
category: "control", category: "control",
transport: null, transport: null,
ros2: null, ros2: null,
rest: null, rest: null,
retryable: null, retryable: null,
parameterSchemaRaw: null parameterSchemaRaw: null,
}; };
console.log("Testing convertDatabaseToAction..."); console.log("Testing convertDatabaseToAction...");
try { try {
const result = convertDatabaseToAction(mockDbAction); const result = convertDatabaseToAction(mockDbAction);
console.log("Result:", JSON.stringify(result, null, 2)); console.log("Result:", JSON.stringify(result, null, 2));
if (result.children && result.children.length > 0) { if (result.children && result.children.length > 0) {
console.log("✅ Children hydrated successfully."); console.log("✅ Children hydrated successfully.");
} else { } else {
console.error("❌ Children NOT hydrated."); console.error("❌ Children NOT hydrated.");
} }
} catch (e) { } catch (e) {
console.error("❌ Error during conversion:", e); console.error("❌ Error during conversion:", e);
} }
+46 -43
View File
@@ -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";
@@ -13,59 +12,63 @@ const db = drizzle(connection, { schema });
// 2. Mock Session // 2. Mock Session
const mockSession = { 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
const createCaller = createCallerFactory(appRouter); 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() {
console.log("🔍 Fetching experiment via TRPC caller..."); console.log("🔍 Fetching experiment via TRPC caller...");
// 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) {
console.error("❌ Experiment not found");
return;
}
const result = await caller.experiments.get({ id: exp.id });
console.log(`✅ Fetched experiment: ${result.name} (${result.id})`);
if (result.steps && result.steps.length > 0) {
console.log(`Checking ${result.steps.length} steps...`);
const actions = result.steps[0]!.actions; // Step 1 actions
console.log(`Step 1 has ${actions.length} actions.`);
actions.forEach((a) => {
if (["sequence", "parallel", "loop", "branch"].includes(a.type)) {
console.log(`\nAction: ${a.name} (${a.type})`);
console.log(
`Children Count: ${a.children ? a.children.length : "UNDEFINED"}`,
);
if (a.children && a.children.length > 0) {
console.log(
`First Child: ${a.children[0]!.name} (${a.children[0]!.type})`,
);
}
}
}); });
} else {
console.error("❌ No steps found in result.");
}
if (!exp) { await connection.end();
console.error("❌ Experiment not found");
return;
}
const result = await caller.experiments.get({ id: exp.id });
console.log(`✅ Fetched experiment: ${result.name} (${result.id})`);
if (result.steps && result.steps.length > 0) {
console.log(`Checking ${result.steps.length} steps...`);
const actions = result.steps[0]!.actions; // Step 1 actions
console.log(`Step 1 has ${actions.length} actions.`);
actions.forEach(a => {
if (["sequence", "parallel", "loop", "branch"].includes(a.type)) {
console.log(`\nAction: ${a.name} (${a.type})`);
console.log(`Children Count: ${a.children ? a.children.length : 'UNDEFINED'}`);
if (a.children && a.children.length > 0) {
console.log(`First Child: ${a.children[0]!.name} (${a.children[0]!.type})`);
}
}
});
} else {
console.error("❌ No steps found in result.");
}
await connection.end();
} }
main(); main();
+34 -32
View File
@@ -1,44 +1,46 @@
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";
import { convertDatabaseToSteps } from "../../src/lib/experiment-designer/block-converter"; import { convertDatabaseToSteps } from "../../src/lib/experiment-designer/block-converter";
async function verifyConversion() { async function verifyConversion() {
const experiment = await db.query.experiments.findFirst({ const experiment = await db.query.experiments.findFirst({
with: {
steps: {
orderBy: (steps, { asc }) => [asc(steps.orderIndex)],
with: { with: {
steps: { actions: {
orderBy: (steps, { asc }) => [asc(steps.orderIndex)], orderBy: (actions, { asc }) => [asc(actions.orderIndex)],
with: { },
actions: { },
orderBy: (actions, { asc }) => [asc(actions.orderIndex)], },
} },
} });
}
}
});
if (!experiment) { if (!experiment) {
console.log("No experiment found"); console.log("No experiment found");
return; return;
}
console.log("Raw DB Steps Count:", experiment.steps.length);
const converted = convertDatabaseToSteps(experiment.steps);
console.log("Converted Steps:");
converted.forEach((s, idx) => {
console.log(`[${idx}] ${s.name} (${s.type})`);
console.log(` Trigger:`, JSON.stringify(s.trigger));
if (s.type === "conditional") {
console.log(
` Conditions populated?`,
Object.keys(s.trigger.conditions).length > 0,
);
} }
});
console.log("Raw DB Steps Count:", experiment.steps.length);
const converted = convertDatabaseToSteps(experiment.steps);
console.log("Converted Steps:");
converted.forEach((s, idx) => {
console.log(`[${idx}] ${s.name} (${s.type})`);
console.log(` Trigger:`, JSON.stringify(s.trigger));
if (s.type === 'conditional') {
console.log(` Conditions populated?`, Object.keys(s.trigger.conditions).length > 0);
}
});
} }
verifyConversion() verifyConversion()
.then(() => process.exit(0)) .then(() => process.exit(0))
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
process.exit(1); process.exit(1);
}); });
+86 -69
View File
@@ -8,83 +8,100 @@ const client = postgres(connectionString);
const db = drizzle(client, { schema }); const db = drizzle(client, { schema });
async function verify() { async function verify() {
console.log("🔍 Verifying Study Readiness..."); console.log("🔍 Verifying Study Readiness...");
// 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) {
console.error("❌ Study 'Comparative WoZ Study' not found."); console.error("❌ Study 'Comparative WoZ Study' not found.");
process.exit(1); process.exit(1);
}
console.log("✅ Study found:", study.name);
// 2. Check Experiment
const experiment = await db.query.experiments.findFirst({
where: eq(schema.experiments.name, "The Interactive Storyteller"),
});
if (!experiment) {
console.error("❌ Experiment 'The Interactive Storyteller' not found.");
process.exit(1);
}
console.log("✅ Experiment found:", experiment.name);
// 3. Check Steps
const steps = await db.query.steps.findMany({
where: eq(schema.steps.experimentId, experiment.id),
orderBy: schema.steps.orderIndex,
});
console.log(`️ Found ${steps.length} steps.`);
if (steps.length < 5) {
console.error("❌ Expected at least 5 steps, found " + steps.length);
process.exit(1);
}
// Verify Step Names
const expectedSteps = [
"The Hook",
"The Narrative - Part 1",
"Comprehension Check",
"Positive Feedback",
"Conclusion",
];
for (let i = 0; i < expectedSteps.length; i++) {
const step = steps[i];
if (!step) continue;
if (step.name !== expectedSteps[i]) {
console.error(
`❌ Step mismatch at index ${i}. Expected '${expectedSteps[i]}', got '${step.name}'`,
);
} else {
console.log(`✅ Step ${i + 1}: ${step.name}`);
} }
console.log("✅ Study found:", study.name); }
// 2. Check Experiment // 4. Check Plugin Actions
const experiment = await db.query.experiments.findFirst({ // Find the NAO6 plugin
where: eq(schema.experiments.name, "The Interactive Storyteller") const plugin = await db.query.plugins.findFirst({
}); where: (plugins, { eq, and }) =>
and(
eq(plugins.name, "NAO6 Robot (Enhanced ROS2 Integration)"),
eq(plugins.status, "active"),
),
});
if (!experiment) { if (!plugin) {
console.error("❌ Experiment 'The Interactive Storyteller' not found."); console.error("❌ NAO6 Plugin not found.");
process.exit(1); process.exit(1);
}
const actions = plugin.actionDefinitions as any[];
const requiredActions = [
"nao_nod",
"nao_shake_head",
"nao_bow",
"nao_open_hand",
];
for (const actionId of requiredActions) {
const found = actions.find((a) => a.id === actionId);
if (!found) {
console.error(`❌ Plugin missing action: ${actionId}`);
process.exit(1);
} }
console.log("✅ Experiment found:", experiment.name); console.log(`✅ Plugin has action: ${actionId}`);
}
// 3. Check Steps console.log("🎉 Verification Complete: Platform is ready for the study!");
const steps = await db.query.steps.findMany({ process.exit(0);
where: eq(schema.steps.experimentId, experiment.id),
orderBy: schema.steps.orderIndex
});
console.log(`️ Found ${steps.length} steps.`);
if (steps.length < 5) {
console.error("❌ Expected at least 5 steps, found " + steps.length);
process.exit(1);
}
// Verify Step Names
const expectedSteps = ["The Hook", "The Narrative - Part 1", "Comprehension Check", "Positive Feedback", "Conclusion"];
for (let i = 0; i < expectedSteps.length; i++) {
const step = steps[i];
if (!step) continue;
if (step.name !== expectedSteps[i]) {
console.error(`❌ Step mismatch at index ${i}. Expected '${expectedSteps[i]}', got '${step.name}'`);
} else {
console.log(`✅ Step ${i + 1}: ${step.name}`);
}
}
// 4. Check Plugin Actions
// Find the NAO6 plugin
const plugin = await db.query.plugins.findFirst({
where: (plugins, { eq, and }) => and(eq(plugins.name, "NAO6 Robot (Enhanced ROS2 Integration)"), eq(plugins.status, "active"))
});
if (!plugin) {
console.error("❌ NAO6 Plugin not found.");
process.exit(1);
}
const actions = plugin.actionDefinitions as any[];
const requiredActions = ["nao_nod", "nao_shake_head", "nao_bow", "nao_open_hand"];
for (const actionId of requiredActions) {
const found = actions.find(a => a.id === actionId);
if (!found) {
console.error(`❌ Plugin missing action: ${actionId}`);
process.exit(1);
}
console.log(`✅ Plugin has action: ${actionId}`);
}
console.log("🎉 Verification Complete: Platform is ready for the study!");
process.exit(0);
} }
verify().catch((e) => { verify().catch((e) => {
console.error(e); console.error(e);
process.exit(1); process.exit(1);
}); });
+69 -67
View File
@@ -1,84 +1,86 @@
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";
import { convertDatabaseToSteps } from "~/lib/experiment-designer/block-converter"; import { convertDatabaseToSteps } from "~/lib/experiment-designer/block-converter";
async function verifyTrpcLogic() { async function verifyTrpcLogic() {
console.log("Verifying TRPC Logic for Interactive Storyteller..."); console.log("Verifying TRPC Logic for Interactive Storyteller...");
// 1. Simulate the DB Query from experiments.ts // 1. Simulate the DB Query from experiments.ts
const experiment = await db.query.experiments.findFirst({ const experiment = await db.query.experiments.findFirst({
where: eq(experiments.name, "The Interactive Storyteller"), where: eq(experiments.name, "The Interactive Storyteller"),
with: { with: {
study: { study: {
columns: { columns: {
id: true, id: true,
name: true, name: true,
},
},
createdBy: {
columns: {
id: true,
name: true,
email: true,
},
},
robot: true,
steps: {
with: {
actions: {
orderBy: [asc(actions.orderIndex)],
},
},
orderBy: [asc(steps.orderIndex)],
},
}, },
}); },
createdBy: {
columns: {
id: true,
name: true,
email: true,
},
},
robot: true,
steps: {
with: {
actions: {
orderBy: [asc(actions.orderIndex)],
},
},
orderBy: [asc(steps.orderIndex)],
},
},
});
if (!experiment) { if (!experiment) {
console.error("Experiment not found!"); console.error("Experiment not found!");
return; return;
} }
// 2. Simulate the Transformation // 2. Simulate the Transformation
console.log("Transforming DB steps to Designer steps..."); console.log("Transforming DB steps to Designer steps...");
const transformedSteps = convertDatabaseToSteps(experiment.steps); const transformedSteps = convertDatabaseToSteps(experiment.steps);
// 3. Inspect Step 4 (Branch A) // 3. Inspect Step 4 (Branch A)
// Step index 3 (0-based) is Branch A // Step index 3 (0-based) is Branch A
const branchAStep = transformedSteps[3]; const branchAStep = transformedSteps[3];
if (branchAStep) { if (branchAStep) {
console.log("Step 4 (Branch A):", branchAStep.name); console.log("Step 4 (Branch A):", branchAStep.name);
console.log(" Type:", branchAStep.type); console.log(" Type:", branchAStep.type);
console.log(" Trigger:", JSON.stringify(branchAStep.trigger, null, 2)); console.log(" Trigger:", JSON.stringify(branchAStep.trigger, null, 2));
} else { } else {
console.error("Step 4 (Branch A) not found in transformed steps!"); console.error("Step 4 (Branch A) not found in transformed steps!");
process.exit(1); process.exit(1);
} }
// 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(
} else { "SUCCESS: nextStepId found in conditions:",
console.error("FAILURE: nextStepId MISSING in conditions!"); conditions.nextStepId,
} );
} else {
console.error("FAILURE: nextStepId MISSING in conditions!");
}
// Inspect Step 5 (Branch B) for completeness // Inspect Step 5 (Branch B) for completeness
const branchBStep = transformedSteps[4]; const branchBStep = transformedSteps[4];
if (branchBStep) { if (branchBStep) {
console.log("Step 5 (Branch B):", branchBStep.name); console.log("Step 5 (Branch B):", branchBStep.name);
console.log(" Trigger:", JSON.stringify(branchBStep.trigger, null, 2)); console.log(" Trigger:", JSON.stringify(branchBStep.trigger, null, 2));
} else { } else {
console.warn("Step 5 (Branch B) not found in transformed steps."); console.warn("Step 5 (Branch B) not found in transformed steps.");
} }
} }
verifyTrpcLogic() verifyTrpcLogic()
.then(() => process.exit(0)) .then(() => process.exit(0))
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
process.exit(1); process.exit(1);
}); });
+10 -11
View File
@@ -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";
@@ -9,18 +8,18 @@ const connection = postgres(connectionString);
const db = drizzle(connection, { schema }); 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) {
console.log(`Experiment ID: ${exp.id}`); console.log(`Experiment ID: ${exp.id}`);
} else { } else {
console.error("Experiment not found"); console.error("Experiment not found");
} }
await connection.end(); await connection.end();
} }
main(); main();
+10 -11
View File
@@ -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";
@@ -9,18 +8,18 @@ const connection = postgres(connectionString);
const db = drizzle(connection, { schema }); 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) {
console.log(`User ID: ${user.id}`); console.log(`User ID: ${user.id}`);
} else { } else {
console.error("User not found"); console.error("User not found");
} }
await connection.end(); await connection.end();
} }
main(); main();
+420 -301
View File
File diff suppressed because it is too large Load Diff
+41 -20
View File
@@ -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} />
+133 -132
View File
@@ -1,145 +1,146 @@
import { import {
BookOpen, BookOpen,
FlaskConical, FlaskConical,
PlayCircle, PlayCircle,
BarChart3, BarChart3,
HelpCircle, HelpCircle,
FileText, FileText,
Video, Video,
ExternalLink, ExternalLink,
} from "lucide-react"; } from "lucide-react";
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "~/components/ui/card"; } from "~/components/ui/card";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { PageLayout } from "~/components/ui/page-layout"; import { PageLayout } from "~/components/ui/page-layout";
import Link from "next/link"; import Link from "next/link";
export default function HelpCenterPage() { export default function HelpCenterPage() {
const guides = [ const guides = [
{ {
title: "Getting Started", title: "Getting Started",
description: "Learn the basics of HRIStudio and set up your first study.", description: "Learn the basics of HRIStudio and set up your first study.",
icon: BookOpen, icon: BookOpen,
items: [ items: [
{ label: "Platform Overview", href: "#" }, { label: "Platform Overview", href: "#" },
{ label: "Creating a New Study", href: "#" }, { label: "Creating a New Study", href: "#" },
{ label: "Managing Team Members", href: "#" }, { label: "Managing Team Members", href: "#" },
], ],
}, },
{ {
title: "Designing Experiments", title: "Designing Experiments",
description: "Master the visual experiment designer and flow control.", description: "Master the visual experiment designer and flow control.",
icon: FlaskConical, icon: FlaskConical,
items: [ items: [
{ label: "Using the Visual Designer", href: "#" }, { label: "Using the Visual Designer", href: "#" },
{ label: "Robot Actions & Plugins", href: "#" }, { label: "Robot Actions & Plugins", href: "#" },
{ label: "Variables & Logic", href: "#" }, { label: "Variables & Logic", href: "#" },
], ],
}, },
{ {
title: "Running Trials", title: "Running Trials",
description: "Execute experiments and manage Wizard of Oz sessions.", description: "Execute experiments and manage Wizard of Oz sessions.",
icon: PlayCircle, icon: PlayCircle,
items: [ items: [
{ label: "Wizard Interface Guide", href: "#" }, { label: "Wizard Interface Guide", href: "#" },
{ label: "Participant Management", href: "#" }, { label: "Participant Management", href: "#" },
{ label: "Handling Robot Errors", href: "#" }, { label: "Handling Robot Errors", href: "#" },
], ],
}, },
{ {
title: "Analysis & Data", title: "Analysis & Data",
description: "Analyze trial results and export research data.", description: "Analyze trial results and export research data.",
icon: BarChart3, icon: BarChart3,
items: [ items: [
{ label: "Understanding Analytics", href: "#" }, { label: "Understanding Analytics", href: "#" },
{ label: "Exporting Data (CSV/JSON)", href: "#" }, { label: "Exporting Data (CSV/JSON)", href: "#" },
{ label: "Video Replay & Annotation", href: "#" }, { label: "Video Replay & Annotation", href: "#" },
], ],
}, },
]; ];
return ( return (
<PageLayout <PageLayout
title="Help Center" title="Help Center"
description="Documentation, guides, and support for HRIStudio researchers." description="Documentation, guides, and support for HRIStudio researchers."
> >
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
{guides.map((guide, index) => ( {guides.map((guide, index) => (
<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>
<CardTitle className="text-xl">{guide.title}</CardTitle>
</div>
<CardDescription>{guide.description}</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{guide.items.map((item, i) => (
<li key={i}>
<Button
variant="link"
className="h-auto p-0 text-foreground hover:text-primary justify-start font-normal"
asChild
>
<Link href={item.href}>
<FileText className="mr-2 h-4 w-4 text-muted-foreground" />
{item.label}
</Link>
</Button>
</li>
))}
</ul>
</CardContent>
</Card>
))}
</div>
<div className="mt-8">
<h2 className="text-2xl font-bold tracking-tight mb-4">
Video Tutorials
</h2>
<div className="grid gap-6 md:grid-cols-3">
{[
"Introduction to HRIStudio",
"Advanced Flow Control",
"ROS2 Integration Deep Dive",
].map((title, i) => (
<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">
<PlayCircle className="h-12 w-12 text-muted-foreground group-hover:text-primary transition-colors" />
</div>
<CardHeader className="p-4">
<CardTitle className="text-base">{title}</CardTitle>
</CardHeader>
</Card>
))}
</div> </div>
</div> <CardTitle className="text-xl">{guide.title}</CardTitle>
</div>
<div className="mt-8 bg-muted/50 rounded-xl p-8 text-center border"> <CardDescription>{guide.description}</CardDescription>
<div className="mx-auto w-12 h-12 bg-background rounded-full flex items-center justify-center mb-4 shadow-sm"> </CardHeader>
<HelpCircle className="h-6 w-6 text-primary" /> <CardContent>
</div> <ul className="space-y-2">
<h2 className="text-xl font-semibold mb-2">Still need help?</h2> {guide.items.map((item, i) => (
<p className="text-muted-foreground mb-6 max-w-md mx-auto"> <li key={i}>
Contact your system administrator or check the official documentation for technical support. <Button
</p> variant="link"
<div className="flex justify-center gap-4"> className="text-foreground hover:text-primary h-auto justify-start p-0 font-normal"
<Button variant="outline" className="gap-2"> asChild
<ExternalLink className="h-4 w-4" /> >
Official Docs <Link href={item.href}>
<FileText className="text-muted-foreground mr-2 h-4 w-4" />
{item.label}
</Link>
</Button> </Button>
<Button className="gap-2">Contact Support</Button> </li>
</div> ))}
</div> </ul>
</PageLayout> </CardContent>
); </Card>
))}
</div>
<div className="mt-8">
<h2 className="mb-4 text-2xl font-bold tracking-tight">
Video Tutorials
</h2>
<div className="grid gap-6 md:grid-cols-3">
{[
"Introduction to HRIStudio",
"Advanced Flow Control",
"ROS2 Integration Deep Dive",
].map((title, i) => (
<Card key={i} className="overflow-hidden">
<div className="bg-muted group hover:bg-muted/80 relative flex aspect-video cursor-pointer items-center justify-center transition-colors">
<PlayCircle className="text-muted-foreground group-hover:text-primary h-12 w-12 transition-colors" />
</div>
<CardHeader className="p-4">
<CardTitle className="text-base">{title}</CardTitle>
</CardHeader>
</Card>
))}
</div>
</div>
<div className="bg-muted/50 mt-8 rounded-xl border p-8 text-center">
<div className="bg-background mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full shadow-sm">
<HelpCircle className="text-primary h-6 w-6" />
</div>
<h2 className="mb-2 text-xl font-semibold">Still need help?</h2>
<p className="text-muted-foreground mx-auto mb-6 max-w-md">
Contact your system administrator or check the official documentation
for technical support.
</p>
<div className="flex justify-center gap-4">
<Button variant="outline" className="gap-2">
<ExternalLink className="h-4 w-4" />
Official Docs
</Button>
<Button className="gap-2">Contact Support</Button>
</div>
</div>
</PageLayout>
);
} }
+12 -4
View File
@@ -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}
+65 -33
View File
@@ -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
...t, data={(trialsList ?? []).map((t) => ({
startedAt: t.startedAt ? new Date(t.startedAt) : null, ...t,
completedAt: t.completedAt ? new Date(t.completedAt) : null, startedAt: t.startedAt ? new Date(t.startedAt) : null,
createdAt: new Date(t.createdAt), completedAt: t.completedAt ? new Date(t.completedAt) : null,
}))} /> 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();
@@ -36,13 +38,13 @@ export default async function ExperimentDesignerPage({
// Only pass initialDesign if there's existing visual design data // Only pass initialDesign if there's existing visual design data
let initialDesign: let initialDesign:
| { | {
id: string; id: string;
name: string; name: string;
description: string; description: string;
steps: ExperimentStep[]; steps: ExperimentStep[];
version: number; version: number;
lastSaved: Date; lastSaved: Date;
} }
| undefined; | undefined;
if (existingDesign?.steps && existingDesign.steps.length > 0) { if (existingDesign?.steps && existingDesign.steps.length > 0) {
@@ -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";
@@ -9,13 +17,13 @@ import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { PageHeader } from "~/components/ui/page-header"; import { PageHeader } from "~/components/ui/page-header";
import { import {
EntityView, EntityView,
EntityViewHeader, EntityViewHeader,
EntityViewSection, EntityViewSection,
EmptyState, EmptyState,
InfoGrid, InfoGrid,
QuickActions, QuickActions,
StatsGrid, StatsGrid,
} from "~/components/ui/entity-view"; } from "~/components/ui/entity-view";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider"; import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
@@ -23,436 +31,443 @@ import { useSession } from "next-auth/react";
import { useStudyManagement } from "~/hooks/useStudyManagement"; import { useStudyManagement } from "~/hooks/useStudyManagement";
interface ExperimentDetailPageProps { interface ExperimentDetailPageProps {
params: Promise<{ id: string; experimentId: string }>; params: Promise<{ id: string; experimentId: string }>;
} }
const statusConfig = { const statusConfig = {
draft: { draft: {
label: "Draft", label: "Draft",
variant: "secondary" as const, variant: "secondary" as const,
icon: "FileText" as const, icon: "FileText" as const,
}, },
testing: { testing: {
label: "Testing", label: "Testing",
variant: "outline" as const, variant: "outline" as const,
icon: "TestTube" as const, icon: "TestTube" as const,
}, },
ready: { ready: {
label: "Ready", label: "Ready",
variant: "default" as const, variant: "default" as const,
icon: "CheckCircle" as const, icon: "CheckCircle" as const,
}, },
deprecated: { deprecated: {
label: "Deprecated", label: "Deprecated",
variant: "destructive" as const, variant: "destructive" as const,
icon: "AlertTriangle" as const, icon: "AlertTriangle" as const,
}, },
}; };
type Experiment = { type Experiment = {
id: string; id: string;
name: string; name: string;
description: string | null; description: string | null;
status: string; status: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
study: { id: string; name: string }; study: { id: string; name: string };
robot: { id: string; name: string; description: string | null } | null; robot: { id: string; name: string; description: string | null } | null;
protocol?: { blocks: unknown[] } | null; protocol?: { blocks: unknown[] } | null;
visualDesign?: unknown; visualDesign?: unknown;
studyId: string; studyId: string;
createdBy: string; createdBy: string;
robotId: string | null; robotId: string | null;
version: number; version: number;
}; };
type Trial = { type Trial = {
id: string;
status: string;
createdAt: Date;
duration: number | null;
participant: {
id: string; id: string;
status: string; participantCode: string;
createdAt: Date; name?: string | null;
duration: number | null; } | null;
participant: { experiment: { name: string } | null;
id: string; participantId: string | null;
participantCode: string; experimentId: string;
name?: string | null; startedAt: Date | null;
} | null; completedAt: Date | null;
experiment: { name: string } | null; notes: string | null;
participantId: string | null; updatedAt: Date;
experimentId: string; canAccess: boolean;
startedAt: Date | null; userRole: string;
completedAt: Date | null;
notes: string | null;
updatedAt: Date;
canAccess: boolean;
userRole: string;
}; };
export default function ExperimentDetailPage({ export default function ExperimentDetailPage({
params, params,
}: ExperimentDetailPageProps) { }: ExperimentDetailPageProps) {
const { data: session } = useSession(); const { data: session } = useSession();
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;
const { selectStudy } = useStudyManagement(); } | null>(null);
const { selectStudy } = useStudyManagement();
useEffect(() => { useEffect(() => {
const resolveParams = async () => { const resolveParams = async () => {
const resolved = await params; const resolved = await params;
setResolvedParams(resolved); setResolvedParams(resolved);
// Ensure study context is synced // Ensure study context is synced
if (resolved.id) { if (resolved.id) {
void selectStudy(resolved.id); void selectStudy(resolved.id);
} }
}; };
void resolveParams(); void resolveParams();
}, [params, selectStudy]); }, [params, selectStudy]);
const experimentQuery = api.experiments.get.useQuery( const experimentQuery = api.experiments.get.useQuery(
{ id: resolvedParams?.experimentId ?? "" }, { id: resolvedParams?.experimentId ?? "" },
{ enabled: !!resolvedParams?.experimentId }, { enabled: !!resolvedParams?.experimentId },
); );
const trialsQuery = api.trials.list.useQuery( const trialsQuery = api.trials.list.useQuery(
{ experimentId: resolvedParams?.experimentId ?? "" }, { experimentId: resolvedParams?.experimentId ?? "" },
{ enabled: !!resolvedParams?.experimentId }, { enabled: !!resolvedParams?.experimentId },
); );
useEffect(() => { useEffect(() => {
if (experimentQuery.data) { if (experimentQuery.data) {
setExperiment(experimentQuery.data); setExperiment(experimentQuery.data);
} }
}, [experimentQuery.data]); }, [experimentQuery.data]);
useEffect(() => { useEffect(() => {
if (trialsQuery.data) { if (trialsQuery.data) {
setTrials(trialsQuery.data); setTrials(trialsQuery.data);
} }
}, [trialsQuery.data]); }, [trialsQuery.data]);
useEffect(() => { useEffect(() => {
if (experimentQuery.isLoading || trialsQuery.isLoading) { if (experimentQuery.isLoading || trialsQuery.isLoading) {
setLoading(true); setLoading(true);
} else { } else {
setLoading(false); setLoading(false);
} }
}, [experimentQuery.isLoading, trialsQuery.isLoading]); }, [experimentQuery.isLoading, trialsQuery.isLoading]);
// Set breadcrumbs // Set breadcrumbs
useBreadcrumbsEffect([ useBreadcrumbsEffect([
{ {
label: "Dashboard", label: "Dashboard",
href: "/", href: "/",
}, },
{ {
label: "Studies", label: "Studies",
href: "/studies", href: "/studies",
}, },
{ {
label: experiment?.study?.name ?? "Study", label: experiment?.study?.name ?? "Study",
href: `/studies/${experiment?.study?.id}`, href: `/studies/${experiment?.study?.id}`,
}, },
{ {
label: "Experiments", label: "Experiments",
href: `/studies/${experiment?.study?.id}/experiments`, href: `/studies/${experiment?.study?.id}/experiments`,
}, },
{ {
label: experiment?.name ?? "Experiment", label: experiment?.name ?? "Experiment",
}, },
]); ]);
if (loading) return <div>Loading...</div>; if (loading) return <div>Loading...</div>;
if (experimentQuery.error) return notFound(); if (experimentQuery.error) return notFound();
if (!experiment) return notFound(); if (!experiment) return notFound();
const displayName = experiment.name ?? "Untitled Experiment"; const displayName = experiment.name ?? "Untitled Experiment";
const description = experiment.description; const description = experiment.description;
// Check if user can edit this experiment // Check if user can edit this experiment
const userRoles = session?.user?.roles?.map((r) => r.role) ?? []; const userRoles = session?.user?.roles?.map((r) => r.role) ?? [];
const canEdit = const canEdit =
userRoles.includes("administrator") || userRoles.includes("researcher"); userRoles.includes("administrator") || userRoles.includes("researcher");
const statusInfo = const statusInfo =
statusConfig[experiment.status as keyof typeof statusConfig]; statusConfig[experiment.status as keyof typeof statusConfig];
const studyId = experiment.study.id; const studyId = experiment.study.id;
const experimentId = experiment.id; const experimentId = experiment.id;
return ( return (
<EntityView> <EntityView>
<PageHeader <PageHeader
title={displayName} title={displayName}
description={description ?? undefined} description={description ?? undefined}
icon={TestTube} icon={TestTube}
badges={[ badges={[
{ {
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
<Settings className="mr-2 h-4 w-4" /> href={`/studies/${studyId}/experiments/${experimentId}/designer`}
Designer >
</Link> <Settings className="mr-2 h-4 w-4" />
</Button> Designer
<Button asChild> </Link>
<Link </Button>
href={`/studies/${studyId}/trials/new?experimentId=${experimentId}`} <Button asChild>
> <Link
<Play className="mr-2 h-4 w-4" /> href={`/studies/${studyId}/trials/new?experimentId=${experimentId}`}
Start Trial >
</Link> <Play className="mr-2 h-4 w-4" />
</Button> Start Trial
</div> </Link>
) : undefined </Button>
}
/>
<div className="grid gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2">
{/* Basic Information */}
<EntityViewSection title="Information" icon="Info">
<InfoGrid
columns={2}
items={[
{
label: "Study",
value: experiment.study ? (
<Link
href={`/studies/${experiment.study.id}`}
className="text-primary hover:underline"
>
{experiment.study.name}
</Link>
) : (
"No study assigned"
),
},
{
label: "Status",
value: statusInfo?.label ?? "Unknown",
},
{
label: "Created",
value: formatDistanceToNow(experiment.createdAt, {
addSuffix: true,
}),
},
{
label: "Last Updated",
value: formatDistanceToNow(experiment.updatedAt, {
addSuffix: true,
}),
},
]}
/>
</EntityViewSection>
{/* Protocol Section */}
<EntityViewSection
title="Experiment Protocol"
icon="FileText"
actions={
canEdit && (
<Button asChild variant="outline" size="sm">
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}>
<Edit className="mr-2 h-4 w-4" />
Edit Protocol
</Link>
</Button>
)
}
>
{experiment.protocol &&
typeof experiment.protocol === "object" &&
experiment.protocol !== null ? (
<div className="space-y-3">
<div className="text-muted-foreground text-sm">
Protocol contains{" "}
{Array.isArray(
(experiment.protocol as { blocks: unknown[] }).blocks,
)
? (experiment.protocol as { blocks: unknown[] }).blocks
.length
: 0}{" "}
blocks
</div>
</div>
) : (
<EmptyState
icon="FileText"
title="No protocol defined"
description="Create an experiment protocol using the visual designer"
action={
canEdit && (
<Button asChild>
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}>
Open Designer
</Link>
</Button>
)
}
/>
)}
</EntityViewSection>
{/* Recent Trials */}
<EntityViewSection
title="Recent Trials"
icon="Play"
actions={
<Button asChild variant="outline" size="sm">
<Link href={`/studies/${experiment.study?.id}/trials`}>
View All
</Link>
</Button>
}
>
{trials.length > 0 ? (
<div className="space-y-3">
{trials.slice(0, 5).map((trial) => (
<div
key={trial.id}
className="hover:bg-muted/50 rounded-lg border p-4 transition-colors"
>
<div className="mb-2 flex items-center justify-between">
<Link
href={`/studies/${experiment.study.id}/trials/${trial.id}`}
className="font-medium hover:underline"
>
Trial #{trial.id.slice(-6)}
</Link>
<Badge
variant={
trial.status === "completed"
? "default"
: trial.status === "in_progress"
? "secondary"
: trial.status === "failed"
? "destructive"
: "outline"
}
>
{trial.status.charAt(0).toUpperCase() +
trial.status.slice(1).replace("_", " ")}
</Badge>
</div>
<div className="text-muted-foreground flex items-center gap-4 text-sm">
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{formatDistanceToNow(trial.createdAt, {
addSuffix: true,
})}
</span>
{trial.duration && (
<span className="flex items-center gap-1">
<Clock className="h-4 w-4" />
{Math.round(trial.duration / 60)} min
</span>
)}
{trial.participant && (
<span className="flex items-center gap-1">
<Users className="h-4 w-4" />
{trial.participant.name ??
trial.participant.participantCode}
</span>
)}
</div>
</div>
))}
</div>
) : (
<EmptyState
icon="Play"
title="No trials yet"
description="Start your first trial to collect data"
action={
canEdit && (
<Button asChild>
<Link
href={`/studies/${studyId}/trials/new?experimentId=${experimentId}`}
>
Start Trial
</Link>
</Button>
)
}
/>
)}
</EntityViewSection>
</div>
<div className="space-y-6">
{/* Statistics */}
<EntityViewSection title="Statistics" icon="BarChart">
<StatsGrid
stats={[
{
label: "Total Trials",
value: trials.length,
},
{
label: "Completed",
value: trials.filter((t) => t.status === "completed").length,
},
{
label: "In Progress",
value: trials.filter((t) => t.status === "in_progress")
.length,
},
]}
/>
</EntityViewSection>
{/* Robot Information */}
{experiment.robot && (
<EntityViewSection title="Robot Platform" icon="Bot">
<InfoGrid
columns={1}
items={[
{
label: "Platform",
value: experiment.robot.name,
},
{
label: "Type",
value: experiment.robot.description ?? "Not specified",
},
]}
/>
</EntityViewSection>
)}
{/* Quick Actions */}
<EntityViewSection title="Quick Actions" icon="Zap">
<QuickActions
actions={[
{
label: "Export Data",
icon: "Download" as const,
},
...(canEdit
? [
{
label: "Open Designer",
icon: "Palette" as const,
href: `/studies/${studyId}/experiments/${experimentId}/designer`,
},
]
: []),
]}
/>
</EntityViewSection>
</div>
</div> </div>
</EntityView> ) : undefined
); }
/>
<div className="grid gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2">
{/* Basic Information */}
<EntityViewSection title="Information" icon="Info">
<InfoGrid
columns={2}
items={[
{
label: "Study",
value: experiment.study ? (
<Link
href={`/studies/${experiment.study.id}`}
className="text-primary hover:underline"
>
{experiment.study.name}
</Link>
) : (
"No study assigned"
),
},
{
label: "Status",
value: statusInfo?.label ?? "Unknown",
},
{
label: "Created",
value: formatDistanceToNow(experiment.createdAt, {
addSuffix: true,
}),
},
{
label: "Last Updated",
value: formatDistanceToNow(experiment.updatedAt, {
addSuffix: true,
}),
},
]}
/>
</EntityViewSection>
{/* Protocol Section */}
<EntityViewSection
title="Experiment Protocol"
icon="FileText"
actions={
canEdit && (
<Button asChild variant="outline" size="sm">
<Link
href={`/studies/${studyId}/experiments/${experimentId}/designer`}
>
<Edit className="mr-2 h-4 w-4" />
Edit Protocol
</Link>
</Button>
)
}
>
{experiment.protocol &&
typeof experiment.protocol === "object" &&
experiment.protocol !== null ? (
<div className="space-y-3">
<div className="text-muted-foreground text-sm">
Protocol contains{" "}
{Array.isArray(
(experiment.protocol as { blocks: unknown[] }).blocks,
)
? (experiment.protocol as { blocks: unknown[] }).blocks
.length
: 0}{" "}
blocks
</div>
</div>
) : (
<EmptyState
icon="FileText"
title="No protocol defined"
description="Create an experiment protocol using the visual designer"
action={
canEdit && (
<Button asChild>
<Link
href={`/studies/${studyId}/experiments/${experimentId}/designer`}
>
Open Designer
</Link>
</Button>
)
}
/>
)}
</EntityViewSection>
{/* Recent Trials */}
<EntityViewSection
title="Recent Trials"
icon="Play"
actions={
<Button asChild variant="outline" size="sm">
<Link href={`/studies/${experiment.study?.id}/trials`}>
View All
</Link>
</Button>
}
>
{trials.length > 0 ? (
<div className="space-y-3">
{trials.slice(0, 5).map((trial) => (
<div
key={trial.id}
className="hover:bg-muted/50 rounded-lg border p-4 transition-colors"
>
<div className="mb-2 flex items-center justify-between">
<Link
href={`/studies/${experiment.study.id}/trials/${trial.id}`}
className="font-medium hover:underline"
>
Trial #{trial.id.slice(-6)}
</Link>
<Badge
variant={
trial.status === "completed"
? "default"
: trial.status === "in_progress"
? "secondary"
: trial.status === "failed"
? "destructive"
: "outline"
}
>
{trial.status.charAt(0).toUpperCase() +
trial.status.slice(1).replace("_", " ")}
</Badge>
</div>
<div className="text-muted-foreground flex items-center gap-4 text-sm">
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{formatDistanceToNow(trial.createdAt, {
addSuffix: true,
})}
</span>
{trial.duration && (
<span className="flex items-center gap-1">
<Clock className="h-4 w-4" />
{Math.round(trial.duration / 60)} min
</span>
)}
{trial.participant && (
<span className="flex items-center gap-1">
<Users className="h-4 w-4" />
{trial.participant.name ??
trial.participant.participantCode}
</span>
)}
</div>
</div>
))}
</div>
) : (
<EmptyState
icon="Play"
title="No trials yet"
description="Start your first trial to collect data"
action={
canEdit && (
<Button asChild>
<Link
href={`/studies/${studyId}/trials/new?experimentId=${experimentId}`}
>
Start Trial
</Link>
</Button>
)
}
/>
)}
</EntityViewSection>
</div>
<div className="space-y-6">
{/* Statistics */}
<EntityViewSection title="Statistics" icon="BarChart">
<StatsGrid
stats={[
{
label: "Total Trials",
value: trials.length,
},
{
label: "Completed",
value: trials.filter((t) => t.status === "completed").length,
},
{
label: "In Progress",
value: trials.filter((t) => t.status === "in_progress")
.length,
},
]}
/>
</EntityViewSection>
{/* Robot Information */}
{experiment.robot && (
<EntityViewSection title="Robot Platform" icon="Bot">
<InfoGrid
columns={1}
items={[
{
label: "Platform",
value: experiment.robot.name,
},
{
label: "Type",
value: experiment.robot.description ?? "Not specified",
},
]}
/>
</EntityViewSection>
)}
{/* Quick Actions */}
<EntityViewSection title="Quick Actions" icon="Zap">
<QuickActions
actions={[
{
label: "Export Data",
icon: "Download" as const,
},
...(canEdit
? [
{
label: "Open Designer",
icon: "Palette" as const,
href: `/studies/${studyId}/experiments/${experimentId}/designer`,
},
]
: []),
]}
/>
</EntityViewSection>
</div>
</div>
</EntityView>
);
} }
@@ -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>
);
}
+10 -3
View File
@@ -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>
@@ -3,29 +3,29 @@ import { api } from "~/trpc/server";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
interface EditParticipantPageProps { interface EditParticipantPageProps {
params: Promise<{ params: Promise<{
id: string; id: string;
participantId: string; participantId: string;
}>; }>;
} }
export default async function EditParticipantPage({ export default async function EditParticipantPage({
params, params,
}: EditParticipantPageProps) { }: EditParticipantPageProps) {
const { id: studyId, participantId } = await params; const { id: studyId, participantId } = await params;
const participant = await api.participants.get({ id: participantId }); const participant = await api.participants.get({ id: participantId });
if (!participant || participant.studyId !== studyId) { if (!participant || participant.studyId !== studyId) {
notFound(); notFound();
} }
// Transform data to match form expectations if needed, or pass directly // Transform data to match form expectations if needed, or pass directly
return ( return (
<ParticipantForm <ParticipantForm
mode="edit" mode="edit"
studyId={studyId} studyId={studyId}
participantId={participantId} participantId={participantId}
/> />
); );
} }
@@ -1,12 +1,18 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { api } from "~/trpc/server"; import { api } from "~/trpc/server";
import { import {
EntityView, EntityView,
EntityViewHeader, EntityViewHeader,
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";
@@ -17,104 +23,129 @@ import { PageHeader } from "~/components/ui/page-header";
import { ParticipantConsentManager } from "~/components/participants/ParticipantConsentManager"; import { ParticipantConsentManager } from "~/components/participants/ParticipantConsentManager";
interface ParticipantDetailPageProps { interface ParticipantDetailPageProps {
params: Promise<{ id: string; participantId: string }>; params: Promise<{ id: string; participantId: string }>;
} }
export default async function ParticipantDetailPage({ export default async function ParticipantDetailPage({
params, params,
}: ParticipantDetailPageProps) { }: ParticipantDetailPageProps) {
const { id: studyId, participantId } = await params; const { id: studyId, participantId } = await params;
const participant = await api.participants.get({ id: participantId }); const participant = await api.participants.get({ id: participantId });
if (!participant) { if (!participant) {
notFound(); notFound();
} }
// Ensure participant belongs to study // Ensure participant belongs to study
if (participant.studyId !== studyId) { if (participant.studyId !== studyId) {
notFound(); notFound();
} }
return ( return (
<EntityView> <EntityView>
<PageHeader <PageHeader
title={participant.participantCode} title={participant.participantCode}
description={participant.name ?? "Unnamed Participant"} description={participant.name ?? "Unnamed Participant"}
icon={Users} icon={Users}
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
<Edit className="mr-2 h-4 w-4" /> href={`/studies/${studyId}/participants/${participantId}/edit`}
Edit Participant >
</Link> <Edit className="mr-2 h-4 w-4" />
</Button> Edit Participant
} </Link>
</Button>
}
/>
<Tabs defaultValue="overview" className="w-full">
<TabsList className="mb-4">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="files">Files & Documents</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<div className="grid grid-cols-1 gap-6">
<ParticipantConsentManager
studyId={studyId}
participantId={participantId}
participantName={participant.name}
participantCode={participant.participantCode}
consentGiven={participant.consentGiven}
consentDate={participant.consentDate}
existingConsent={participant.consents[0] ?? null}
/> />
<EntityViewSection title="Participant Information" icon="Info">
<div className="grid grid-cols-2 gap-4 text-sm md:grid-cols-4">
<div>
<span className="text-muted-foreground mb-1 block">Code</span>
<span className="text-base font-medium">
{participant.participantCode}
</span>
</div>
<Tabs defaultValue="overview" className="w-full"> <div>
<TabsList className="mb-4"> <span className="text-muted-foreground mb-1 block">Name</span>
<TabsTrigger value="overview">Overview</TabsTrigger> <span className="text-base font-medium">
<TabsTrigger value="files">Files & Documents</TabsTrigger> {participant.name || "-"}
</TabsList> </span>
</div>
<TabsContent value="overview"> <div>
<div className="grid gap-6 grid-cols-1"> <span className="text-muted-foreground mb-1 block">
<ParticipantConsentManager Email
studyId={studyId} </span>
participantId={participantId} <span className="text-base font-medium">
consentGiven={participant.consentGiven} {participant.email || "-"}
consentDate={participant.consentDate} </span>
existingConsent={participant.consents[0] ?? null} </div>
/>
<EntityViewSection title="Participant Information" icon="Info">
<div className="grid grid-cols-2 gap-4 text-sm md:grid-cols-4">
<div>
<span className="text-muted-foreground block mb-1">Code</span>
<span className="font-medium text-base">{participant.participantCode}</span>
</div>
<div> <div>
<span className="text-muted-foreground block mb-1">Name</span> <span className="text-muted-foreground mb-1 block">
<span className="font-medium text-base">{participant.name || "-"}</span> Added
</div> </span>
<span className="text-base font-medium">
{new Date(participant.createdAt).toLocaleDateString()}
</span>
</div>
<div> <div>
<span className="text-muted-foreground block mb-1">Email</span> <span className="text-muted-foreground mb-1 block">Age</span>
<span className="font-medium text-base">{participant.email || "-"}</span> <span className="text-base font-medium">
</div> {(participant.demographics as any)?.age || "-"}
</span>
</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> Gender
</div> </span>
<span className="text-base font-medium capitalize">
{(participant.demographics as any)?.gender?.replace(
"_",
" ",
) || "-"}
</span>
</div>
</div>
</EntityViewSection>
</div>
</TabsContent>
<div> <TabsContent value="files">
<span className="text-muted-foreground block mb-1">Age</span> <EntityViewSection title="Documents" icon="FileText">
<span className="font-medium text-base">{(participant.demographics as any)?.age || "-"}</span> <ParticipantDocuments participantId={participantId} />
</div> </EntityViewSection>
</TabsContent>
<div> </Tabs>
<span className="text-muted-foreground block mb-1">Gender</span> </EntityView>
<span className="font-medium capitalize text-base">{(participant.demographics as any)?.gender?.replace("_", " ") || "-"}</span> );
</div>
</div>
</EntityViewSection>
</div>
</TabsContent>
<TabsContent value="files">
<EntityViewSection title="Documents" icon="FileText">
<ParticipantDocuments participantId={participantId} />
</EntityViewSection>
</TabsContent>
</Tabs>
</EntityView>
);
} }
@@ -4,184 +4,192 @@ import { useState } from "react";
import { Upload, FileText, Trash2, Download, Loader2 } from "lucide-react"; import { Upload, FileText, Trash2, Download, Loader2 } from "lucide-react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "~/components/ui/card"; } from "~/components/ui/card";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { formatBytes } from "~/lib/utils"; import { formatBytes } from "~/lib/utils";
import { toast } from "sonner"; import { toast } from "sonner";
interface ParticipantDocumentsProps { interface ParticipantDocumentsProps {
participantId: string; participantId: string;
} }
export function ParticipantDocuments({ participantId }: ParticipantDocumentsProps) { export function ParticipantDocuments({
const [isUploading, setIsUploading] = useState(false); participantId,
const utils = api.useUtils(); }: ParticipantDocumentsProps) {
const [isUploading, setIsUploading] = useState(false);
const utils = api.useUtils();
const { data: documents, isLoading } = api.files.listParticipantDocuments.useQuery({ const { data: documents, isLoading } =
api.files.listParticipantDocuments.useQuery({
participantId,
});
const getPresignedUrl = api.files.getPresignedUrl.useMutation();
const registerUpload = api.files.registerUpload.useMutation();
const deleteDocument = api.files.deleteDocument.useMutation({
onSuccess: () => {
toast.success("Document deleted");
utils.files.listParticipantDocuments.invalidate({ participantId });
},
onError: (err) => toast.error(`Failed to delete: ${err.message}`),
});
// Since presigned URLs are for PUT, we can use a direct fetch
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsUploading(true);
try {
// 1. Get presigned URL
const { url, storagePath } = await getPresignedUrl.mutateAsync({
filename: file.name,
contentType: file.type || "application/octet-stream",
participantId, participantId,
}); });
const getPresignedUrl = api.files.getPresignedUrl.useMutation(); // 2. Upload to MinIO/S3
const registerUpload = api.files.registerUpload.useMutation(); const uploadRes = await fetch(url, {
const deleteDocument = api.files.deleteDocument.useMutation({ method: "PUT",
onSuccess: () => { body: file,
toast.success("Document deleted"); headers: {
utils.files.listParticipantDocuments.invalidate({ participantId }); "Content-Type": file.type || "application/octet-stream",
}, },
onError: (err) => toast.error(`Failed to delete: ${err.message}`), });
});
// Since presigned URLs are for PUT, we can use a direct fetch if (!uploadRes.ok) {
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { throw new Error("Upload to storage failed");
const file = e.target.files?.[0]; }
if (!file) return;
setIsUploading(true); // 3. Register in DB
try { await registerUpload.mutateAsync({
// 1. Get presigned URL participantId,
const { url, storagePath } = await getPresignedUrl.mutateAsync({ name: file.name,
filename: file.name, type: file.type,
contentType: file.type || "application/octet-stream", storagePath,
participantId, fileSize: file.size,
}); });
// 2. Upload to MinIO/S3 toast.success("File uploaded successfully");
const uploadRes = await fetch(url, { utils.files.listParticipantDocuments.invalidate({ participantId });
method: "PUT", } catch (error) {
body: file, console.error(error);
headers: { toast.error("Failed to upload file");
"Content-Type": file.type || "application/octet-stream", } finally {
}, setIsUploading(false);
}); // Reset input
e.target.value = "";
}
};
if (!uploadRes.ok) { const handleDownload = async (storagePath: string, filename: string) => {
throw new Error("Upload to storage failed"); // We would typically get a temporary download URL here
} // For now assuming public bucket or implementing a separate download procedure
// Let's implement a quick procedure call right here via client or assume the server router has it.
// I added getDownloadUrl to the router in previous steps.
try {
const { url } = await utils.client.files.getDownloadUrl.query({
storagePath,
});
window.open(url, "_blank");
} catch (e) {
toast.error("Could not get download URL");
}
};
// 3. Register in DB return (
await registerUpload.mutateAsync({ <Card>
participantId, <CardHeader>
name: file.name, <div className="flex items-center justify-between">
type: file.type, <div className="space-y-1">
storagePath, <CardTitle>Documents</CardTitle>
fileSize: file.size, <CardDescription>
}); Manage consent forms and other files for this participant.
</CardDescription>
toast.success("File uploaded successfully"); </div>
utils.files.listParticipantDocuments.invalidate({ participantId }); <div className="flex items-center gap-2">
} catch (error) { <Button disabled={isUploading} asChild>
console.error(error); <label className="cursor-pointer">
toast.error("Failed to upload file"); {isUploading ? (
} finally { <Loader2 className="mr-2 h-4 w-4 animate-spin" />
setIsUploading(false);
// Reset input
e.target.value = "";
}
};
const handleDownload = async (storagePath: string, filename: string) => {
// We would typically get a temporary download URL here
// For now assuming public bucket or implementing a separate download procedure
// Let's implement a quick procedure call right here via client or assume the server router has it.
// I added getDownloadUrl to the router in previous steps.
try {
const { url } = await utils.client.files.getDownloadUrl.query({ storagePath });
window.open(url, "_blank");
} catch (e) {
toast.error("Could not get download URL");
}
};
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="space-y-1">
<CardTitle>Documents</CardTitle>
<CardDescription>
Manage consent forms and other files for this participant.
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button disabled={isUploading} asChild>
<label className="cursor-pointer">
{isUploading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
Upload PDF
<input
type="file"
className="hidden"
accept=".pdf,.doc,.docx,.txt" // User asked for PDF, but generic is fine
onChange={handleFileUpload}
disabled={isUploading}
/>
</label>
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex justify-center p-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : documents?.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
<FileText className="mb-2 h-8 w-8 opacity-50" />
<p>No documents uploaded yet.</p>
</div>
) : ( ) : (
<div className="space-y-2"> <Upload className="mr-2 h-4 w-4" />
{documents?.map((doc) => (
<div
key={doc.id}
className="flex items-center justify-between rounded-lg border p-3 hover:bg-muted/50"
>
<div className="flex items-center gap-3">
<div className="rounded-md bg-blue-50 p-2">
<FileText className="h-4 w-4 text-blue-600" />
</div>
<div>
<p className="font-medium">{doc.name}</p>
<p className="text-xs text-muted-foreground">
{formatBytes(doc.fileSize ?? 0)} {new Date(doc.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleDownload(doc.storagePath, doc.name)}
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => {
if (confirm("Are you sure you want to delete this file?")) {
deleteDocument.mutate({ id: doc.id });
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)} )}
</CardContent> Upload PDF
</Card> <input
); type="file"
className="hidden"
accept=".pdf,.doc,.docx,.txt" // User asked for PDF, but generic is fine
onChange={handleFileUpload}
disabled={isUploading}
/>
</label>
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex justify-center p-4">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
) : documents?.length === 0 ? (
<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" />
<p>No documents uploaded yet.</p>
</div>
) : (
<div className="space-y-2">
{documents?.map((doc) => (
<div
key={doc.id}
className="hover:bg-muted/50 flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-3">
<div className="rounded-md bg-blue-50 p-2">
<FileText className="h-4 w-4 text-blue-600" />
</div>
<div>
<p className="font-medium">{doc.name}</p>
<p className="text-muted-foreground text-xs">
{formatBytes(doc.fileSize ?? 0)} {" "}
{new Date(doc.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleDownload(doc.storagePath, doc.name)}
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => {
if (
confirm("Are you sure you want to delete this file?")
) {
deleteDocument.mutate({ id: doc.id });
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
} }
@@ -13,111 +13,112 @@ import { TrialAnalysisView } from "~/components/trials/views/TrialAnalysisView";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
function AnalysisPageContent() { function AnalysisPageContent() {
const params = useParams(); const params = useParams();
const studyId: string = typeof params.id === "string" ? params.id : ""; const studyId: string = typeof params.id === "string" ? params.id : "";
const trialId: string = const trialId: string =
typeof params.trialId === "string" ? params.trialId : ""; typeof params.trialId === "string" ? params.trialId : "";
const { setSelectedStudyId, selectedStudyId } = useStudyContext(); const { setSelectedStudyId, selectedStudyId } = useStudyContext();
const { study } = useSelectedStudyDetails(); const { study } = useSelectedStudyDetails();
// Get trial data // Get trial data
const { const {
data: trial, data: trial,
isLoading, isLoading,
error, error,
} = api.trials.get.useQuery({ id: trialId }, { enabled: !!trialId }); } = api.trials.get.useQuery({ id: trialId }, { enabled: !!trialId });
// Set breadcrumbs // Set breadcrumbs
useBreadcrumbsEffect([ useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" }, { label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" }, { label: "Studies", href: "/studies" },
{ label: study?.name ?? "Study", href: `/studies/${studyId}` }, { label: study?.name ?? "Study", href: `/studies/${studyId}` },
{ label: "Trials", href: `/studies/${studyId}/trials` }, { label: "Trials", href: `/studies/${studyId}/trials` },
{ {
label: trial?.experiment.name ?? "Trial", label: trial?.experiment.name ?? "Trial",
href: `/studies/${studyId}/trials`, href: `/studies/${studyId}/trials`,
}, },
{ label: "Analysis" }, { label: "Analysis" },
]); ]);
// Sync selected study (unified study-context) // Sync selected study (unified study-context)
useEffect(() => { useEffect(() => {
if (studyId && selectedStudyId !== studyId) { if (studyId && selectedStudyId !== studyId) {
setSelectedStudyId(studyId); setSelectedStudyId(studyId);
}
}, [studyId, selectedStudyId, setSelectedStudyId]);
if (isLoading) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-muted-foreground">Loading analysis...</div>
</div>
);
} }
}, [studyId, selectedStudyId, setSelectedStudyId]);
if (error || !trial) { if (isLoading) {
return (
<div className="space-y-6">
<PageHeader
title="Trial Analysis"
description="Analyze trial results"
icon={LineChart}
actions={
<Button asChild variant="outline">
<Link href={`/studies/${studyId}/trials`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Trials
</Link>
</Button>
}
/>
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<h3 className="text-destructive mb-2 text-lg font-semibold">
{error ? "Error Loading Trial" : "Trial Not Found"}
</h3>
<p className="text-muted-foreground">
{error?.message || "The requested trial could not be found."}
</p>
</div>
</div>
</div>
);
}
const customTrialData = {
...trial,
startedAt: trial.startedAt ? new Date(trial.startedAt) : null,
completedAt: trial.completedAt ? new Date(trial.completedAt) : null,
eventCount: (trial as any).eventCount,
mediaCount: (trial as any).mediaCount,
media: trial.media?.map(m => ({
...m,
mediaType: m.mediaType ?? "video",
format: m.format ?? undefined,
contentType: m.contentType ?? undefined
})) ?? [],
};
return ( return (
<TrialAnalysisView <div className="flex h-96 items-center justify-center">
trial={customTrialData} <div className="text-muted-foreground">Loading analysis...</div>
backHref={`/studies/${studyId}/trials/${trialId}`} </div>
/>
); );
}
if (error || !trial) {
return (
<div className="space-y-6">
<PageHeader
title="Trial Analysis"
description="Analyze trial results"
icon={LineChart}
actions={
<Button asChild variant="outline">
<Link href={`/studies/${studyId}/trials`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Trials
</Link>
</Button>
}
/>
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<h3 className="text-destructive mb-2 text-lg font-semibold">
{error ? "Error Loading Trial" : "Trial Not Found"}
</h3>
<p className="text-muted-foreground">
{error?.message || "The requested trial could not be found."}
</p>
</div>
</div>
</div>
);
}
const customTrialData = {
...trial,
startedAt: trial.startedAt ? new Date(trial.startedAt) : null,
completedAt: trial.completedAt ? new Date(trial.completedAt) : null,
eventCount: (trial as any).eventCount,
mediaCount: (trial as any).mediaCount,
media:
trial.media?.map((m) => ({
...m,
mediaType: m.mediaType ?? "video",
format: m.format ?? undefined,
contentType: m.contentType ?? undefined,
})) ?? [],
};
return (
<TrialAnalysisView
trial={customTrialData}
backHref={`/studies/${studyId}/trials/${trialId}`}
/>
);
} }
export default function TrialAnalysisPage() { export default function TrialAnalysisPage() {
return ( return (
<Suspense <Suspense
fallback={ fallback={
<div className="flex h-96 items-center justify-center"> <div className="flex h-96 items-center justify-center">
<div className="text-muted-foreground">Loading...</div> <div className="text-muted-foreground">Loading...</div>
</div> </div>
} }
> >
<AnalysisPageContent /> <AnalysisPageContent />
</Suspense> </Suspense>
); );
} }
@@ -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">
@@ -156,13 +163,13 @@ function TrialDetailContent() {
)} )}
{(trial.status === "in_progress" || {(trial.status === "in_progress" ||
trial.status === "scheduled") && ( trial.status === "scheduled") && (
<Button asChild> <Button asChild>
<Link href={`/studies/${studyId}/trials/${trialId}/wizard`}> <Link href={`/studies/${studyId}/trials/${trialId}/wizard`}>
<Zap className="mr-2 h-4 w-4" /> <Zap className="mr-2 h-4 w-4" />
Wizard Interface Wizard Interface
</Link> </Link>
</Button> </Button>
)} )}
{trial.status === "completed" && ( {trial.status === "completed" && (
<Button asChild> <Button asChild>
<Link href={`/studies/${studyId}/trials/${trialId}/analysis`}> <Link href={`/studies/${studyId}/trials/${trialId}/analysis`}>
@@ -211,11 +211,7 @@ function WizardPageContent() {
} }
}; };
return ( return <div>{renderView()}</div>;
<div>
{renderView()}
</div>
);
} }
export default function TrialWizardPage() { export default function TrialWizardPage() {
+1 -1
View File
@@ -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,
+34 -17
View File
@@ -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&apos;m not a robot{" "} I&apos;m not a robot{" "}
<span className="text-muted-foreground text-xs italic">(ironic, isn&apos;t it?)</span> <span className="text-muted-foreground text-xs italic">
(ironic, isn&apos;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&apos;t have an account?{" "} Don&apos;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>
&copy; {new Date().getFullYear()} HRIStudio. All rights reserved. &copy; {new Date().getFullYear()} HRIStudio. All rights reserved.
</p> </p>
+8 -7
View File
@@ -6,11 +6,11 @@ import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import {
Card, Card,
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>
+20 -13
View File
@@ -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>
&copy; {new Date().getFullYear()} HRIStudio. All rights reserved. &copy; {new Date().getFullYear()} HRIStudio. All rights reserved.
</p> </p>
+118 -57
View File
@@ -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>
+138 -71
View File
@@ -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">
&copy; {new Date().getFullYear()} HRIStudio. All rights reserved. &copy; {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 -5
View File
@@ -1,11 +1,11 @@
import Link from "next/link"; import Link from "next/link";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import {
Card, Card,
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";
+3 -1
View File
@@ -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() {
@@ -1,321 +1,341 @@
"use client"; "use client";
import { import {
type ColumnDef, type ColumnDef,
type ColumnFiltersState, type ColumnFiltersState,
type SortingState, type SortingState,
type VisibilityState, type VisibilityState,
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
getFilteredRowModel, getFilteredRowModel,
getPaginationRowModel, getPaginationRowModel,
getSortedRowModel, getSortedRowModel,
useReactTable, useReactTable,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { import {
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
} from "~/components/ui/table"; } from "~/components/ui/table";
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 { useState } from "react"; import { useState } from "react";
import { import {
ArrowUpDown, ArrowUpDown,
MoreHorizontal, MoreHorizontal,
Calendar, Calendar,
Clock, Clock,
Activity, Activity,
Eye, Eye,
Video Video,
} from "lucide-react"; } from "lucide-react";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"; } from "~/components/ui/dropdown-menu";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import Link from "next/link"; import Link from "next/link";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
export type AnalyticsTrial = { export type AnalyticsTrial = {
id: string; id: string;
sessionNumber: number; sessionNumber: number;
status: string; status: string;
createdAt: Date; createdAt: Date;
startedAt: Date | null; startedAt: Date | null;
completedAt: Date | null; completedAt: Date | null;
duration: number | null; duration: number | null;
eventCount: number; eventCount: number;
mediaCount: number; mediaCount: number;
experimentId: string; experimentId: string;
participant: { participant: {
participantCode: string; participantCode: string;
}; };
experiment: { experiment: {
name: string; name: string;
studyId: string; studyId: string;
}; };
}; };
export const columns: ColumnDef<AnalyticsTrial>[] = [ export const columns: ColumnDef<AnalyticsTrial>[] = [
{ {
accessorKey: "sessionNumber", accessorKey: "sessionNumber",
header: ({ column }) => { header: ({ column }) => {
return ( return (
<Button <Button
variant="ghost" variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
> >
Session Session
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
},
cell: ({ row }) => <div className="font-mono text-center">#{row.getValue("sessionNumber")}</div>,
}, },
{ cell: ({ row }) => (
accessorKey: "participant.participantCode", <div className="text-center font-mono">
id: "participantCode", #{row.getValue("sessionNumber")}
header: "Participant", </div>
cell: ({ row }) => ( ),
<div className="font-medium">{row.original.participant?.participantCode ?? "Unknown"}</div> },
), {
accessorKey: "participant.participantCode",
id: "participantCode",
header: "Participant",
cell: ({ row }) => (
<div className="font-medium">
{row.original.participant?.participantCode ?? "Unknown"}
</div>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => {
const status = row.getValue("status") as string;
return (
<Badge
variant="outline"
className={`capitalize ${
status === "completed"
? "border-green-500/20 bg-green-500/10 text-green-500"
: status === "in_progress"
? "border-blue-500/20 bg-blue-500/10 text-blue-500"
: "border-slate-500/20 bg-slate-500/10 text-slate-500"
}`}
>
{status.replace("_", " ")}
</Badge>
);
}, },
{ },
accessorKey: "status", {
header: "Status", accessorKey: "createdAt",
cell: ({ row }) => { header: ({ column }) => {
const status = row.getValue("status") as string; return (
return ( <Button
<Badge variant="ghost"
variant="outline" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className={`capitalize ${status === "completed" >
? "bg-green-500/10 text-green-500 border-green-500/20" Date
: status === "in_progress" <ArrowUpDown className="ml-2 h-4 w-4" />
? "bg-blue-500/10 text-blue-500 border-blue-500/20" </Button>
: "bg-slate-500/10 text-slate-500 border-slate-500/20" );
}`}
>
{status.replace("_", " ")}
</Badge>
);
},
}, },
{ cell: ({ row }) => {
accessorKey: "createdAt", const date = new Date(row.getValue("createdAt"));
header: ({ column }) => { return (
return ( <div className="flex flex-col">
<Button <span className="text-sm">{date.toLocaleDateString()}</span>
variant="ghost" <span className="text-muted-foreground text-xs">
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} {formatDistanceToNow(date, { addSuffix: true })}
> </span>
Date </div>
<ArrowUpDown className="ml-2 h-4 w-4" /> );
</Button>
);
},
cell: ({ row }) => {
const date = new Date(row.getValue("createdAt"));
return (
<div className="flex flex-col">
<span className="text-sm">{date.toLocaleDateString()}</span>
<span className="text-xs text-muted-foreground">{formatDistanceToNow(date, { addSuffix: true })}</span>
</div>
)
},
}, },
{ },
accessorKey: "duration", {
header: "Duration", accessorKey: "duration",
cell: ({ row }) => { header: "Duration",
const duration = row.getValue("duration") as number | null; cell: ({ row }) => {
if (!duration) return <span className="text-muted-foreground">-</span>; const duration = row.getValue("duration") as number | null;
const m = Math.floor(duration / 60); if (!duration) return <span className="text-muted-foreground">-</span>;
const s = Math.floor(duration % 60); const m = Math.floor(duration / 60);
return <div className="font-mono">{`${m}m ${s}s`}</div>; const s = Math.floor(duration % 60);
}, return <div className="font-mono">{`${m}m ${s}s`}</div>;
}, },
{ },
accessorKey: "eventCount", {
header: "Events", accessorKey: "eventCount",
cell: ({ row }) => { header: "Events",
return ( cell: ({ row }) => {
<div className="flex items-center gap-1"> return (
<Activity className="h-3 w-3 text-muted-foreground" /> <div className="flex items-center gap-1">
<span>{row.getValue("eventCount")}</span> <Activity className="text-muted-foreground h-3 w-3" />
</div> <span>{row.getValue("eventCount")}</span>
) </div>
}, );
}, },
{ },
accessorKey: "mediaCount", {
header: "Media", accessorKey: "mediaCount",
cell: ({ row }) => { header: "Media",
const count = row.getValue("mediaCount") as number; cell: ({ row }) => {
if (count === 0) return <span className="text-muted-foreground">-</span>; const count = row.getValue("mediaCount") as number;
return ( if (count === 0) return <span className="text-muted-foreground">-</span>;
<div className="flex items-center gap-1"> return (
<Video className="h-3 w-3 text-muted-foreground" /> <div className="flex items-center gap-1">
<span>{count}</span> <Video className="text-muted-foreground h-3 w-3" />
</div> <span>{count}</span>
) </div>
}, );
}, },
{ },
id: "actions", {
cell: ({ row }) => { id: "actions",
const trial = row.original; cell: ({ row }) => {
return ( const trial = row.original;
<DropdownMenu> return (
<DropdownMenuTrigger asChild> <DropdownMenu>
<Button variant="ghost" className="h-8 w-8 p-0"> <DropdownMenuTrigger asChild>
<span className="sr-only">Open menu</span> <Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" /> <span className="sr-only">Open menu</span>
</Button> <MoreHorizontal className="h-4 w-4" />
</DropdownMenuTrigger> </Button>
<DropdownMenuContent align="end"> </DropdownMenuTrigger>
<DropdownMenuLabel>Actions</DropdownMenuLabel> <DropdownMenuContent align="end">
<DropdownMenuItem asChild> <DropdownMenuLabel>Actions</DropdownMenuLabel>
<Link href={`/studies/${trial.experiment?.studyId}/trials/${trial.id}/analysis`}> <DropdownMenuItem asChild>
<Eye className="mr-2 h-4 w-4" /> <Link
View Analysis href={`/studies/${trial.experiment?.studyId}/trials/${trial.id}/analysis`}
</Link> >
</DropdownMenuItem> <Eye className="mr-2 h-4 w-4" />
<DropdownMenuItem asChild> View Analysis
<Link href={`/experiments/${trial.experimentId}/trials/${trial.id}`}> </Link>
View Trial Details </DropdownMenuItem>
</Link> <DropdownMenuItem asChild>
</DropdownMenuItem> <Link
</DropdownMenuContent> href={`/experiments/${trial.experimentId}/trials/${trial.id}`}
</DropdownMenu> >
); View Trial Details
}, </Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}, },
},
]; ];
interface StudyAnalyticsDataTableProps { interface StudyAnalyticsDataTableProps {
data: AnalyticsTrial[]; data: AnalyticsTrial[];
} }
export function StudyAnalyticsDataTable({ data }: StudyAnalyticsDataTableProps) { export function StudyAnalyticsDataTable({
const [sorting, setSorting] = useState<SortingState>([]); data,
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]); }: StudyAnalyticsDataTableProps) {
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({}); const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState({}); const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState({});
const table = useReactTable({ const table = useReactTable({
data, data,
columns, columns,
onSortingChange: setSorting, onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters, onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(), getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(), getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility, onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
state: { state: {
sorting, sorting,
columnFilters, columnFilters,
columnVisibility, columnVisibility,
rowSelection, rowSelection,
}, },
}); });
return ( return (
<div className="w-full" id="tour-analytics-table"> <div className="w-full" id="tour-analytics-table">
<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={
onChange={(event) => (table.getColumn("participantCode")?.getFilterValue() as string) ??
table.getColumn("participantCode")?.setFilterValue(event.target.value) ""
} }
className="max-w-sm" onChange={(event) =>
id="tour-analytics-filter" table
/> .getColumn("participantCode")
</div> ?.setFilterValue(event.target.value)
<div className="rounded-md border bg-card"> }
<Table> className="max-w-sm"
<TableHeader> id="tour-analytics-filter"
{table.getHeaderGroups().map((headerGroup) => ( />
<TableRow key={headerGroup.id}> </div>
{headerGroup.headers.map((header) => { <div className="bg-card rounded-md border">
return ( <Table>
<TableHead key={header.id}> <TableHeader>
{header.isPlaceholder {table.getHeaderGroups().map((headerGroup) => (
? null <TableRow key={headerGroup.id}>
: flexRender( {headerGroup.headers.map((header) => {
header.column.columnDef.header, return (
header.getContext() <TableHead key={header.id}>
)} {header.isPlaceholder
</TableHead> ? null
); : flexRender(
})} header.column.columnDef.header,
</TableRow> header.getContext(),
))} )}
</TableHeader> </TableHead>
<TableBody> );
{table.getRowModel().rows?.length ? ( })}
table.getRowModel().rows.map((row) => ( </TableRow>
<TableRow ))}
key={row.id} </TableHeader>
data-state={row.getIsSelected() && "selected"} <TableBody>
> {table.getRowModel().rows?.length ? (
{row.getVisibleCells().map((cell) => ( table.getRowModel().rows.map((row) => (
<TableCell key={cell.id}> <TableRow
{flexRender( key={row.id}
cell.column.columnDef.cell, data-state={row.getIsSelected() && "selected"}
cell.getContext() >
)} {row.getVisibleCells().map((cell) => (
</TableCell> <TableCell key={cell.id}>
))} {flexRender(
</TableRow> cell.column.columnDef.cell,
)) cell.getContext(),
) : ( )}
<TableRow> </TableCell>
<TableCell ))}
colSpan={columns.length} </TableRow>
className="h-24 text-center" ))
> ) : (
No results. <TableRow>
</TableCell> <TableCell
</TableRow> colSpan={columns.length}
)} className="h-24 text-center"
</TableBody> >
</Table> No results.
</div> </TableCell>
<div className="flex items-center justify-end space-x-2 py-4"> </TableRow>
<div className="flex-1 text-sm text-muted-foreground"> )}
{table.getFilteredSelectedRowModel().rows.length} of{" "} </TableBody>
{table.getFilteredRowModel().rows.length} row(s) selected. </Table>
</div> </div>
<div className="space-x-2"> <div className="flex items-center justify-end space-x-2 py-4">
<Button <div className="text-muted-foreground flex-1 text-sm">
variant="outline" {table.getFilteredSelectedRowModel().rows.length} of{" "}
size="sm" {table.getFilteredRowModel().rows.length} row(s) selected.
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div> </div>
); <div className="space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
);
} }
+49 -25
View File
@@ -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,22 +599,23 @@ 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 =
<SidebarMenuButton item.action === "tour" ? (
onClick={() => startTour("full_platform")} <SidebarMenuButton
isActive={false} onClick={() => startTour("full_platform")}
> isActive={false}
<item.icon className="h-4 w-4" /> >
<span>{item.title}</span>
</SidebarMenuButton>
) : (
<SidebarMenuButton asChild isActive={isActive}>
<Link href={item.url}>
<item.icon className="h-4 w-4" /> <item.icon className="h-4 w-4" />
<span>{item.title}</span> <span>{item.title}</span>
</Link> </SidebarMenuButton>
</SidebarMenuButton> ) : (
); <SidebarMenuButton asChild isActive={isActive}>
<Link href={item.url}>
<item.icon className="h-4 w-4" />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
);
return ( return (
<SidebarMenuItem key={item.title}> <SidebarMenuItem key={item.title}>
+32 -28
View File
@@ -87,33 +87,33 @@ export function ExperimentForm({ mode, experimentId }: ExperimentFormProps) {
{ label: "Studies", href: "/studies" }, { label: "Studies", href: "/studies" },
...(selectedStudyId ...(selectedStudyId
? [ ? [
{ {
label: experiment?.study?.name ?? "Study", label: experiment?.study?.name ?? "Study",
href: `/studies/${selectedStudyId}`, href: `/studies/${selectedStudyId}`,
}, },
{ label: "Experiments", href: "/experiments" }, { label: "Experiments", href: "/experiments" },
...(mode === "edit" && experiment ...(mode === "edit" && experiment
? [ ? [
{ {
label: experiment.name, label: experiment.name,
href: `/studies/${selectedStudyId}/experiments/${experiment.id}`, href: `/studies/${selectedStudyId}/experiments/${experiment.id}`,
}, },
{ label: "Edit" }, { label: "Edit" },
] ]
: [{ label: "New Experiment" }]), : [{ label: "New Experiment" }]),
] ]
: [ : [
{ label: "Experiments", href: "/experiments" }, { label: "Experiments", href: "/experiments" },
...(mode === "edit" && experiment ...(mode === "edit" && experiment
? [ ? [
{ {
label: experiment.name, label: experiment.name,
href: `/studies/${experiment.studyId}/experiments/${experiment.id}`, href: `/studies/${experiment.studyId}/experiments/${experiment.id}`,
}, },
{ label: "Edit" }, { label: "Edit" },
] ]
: [{ label: "New Experiment" }]), : [{ label: "New Experiment" }]),
]), ]),
]; ];
useBreadcrumbsEffect(breadcrumbs); useBreadcrumbsEffect(breadcrumbs);
@@ -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(
+24 -8
View File
@@ -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,41 +116,41 @@ 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
? { ? {
transport: "ros2" as const, transport: "ros2" as const,
timeoutMs: action.timeout,
retryable: action.retryable,
ros2: {
topic: action.ros2.topic,
messageType: action.ros2.messageType,
service: action.ros2.service,
action: action.ros2.action,
qos: action.ros2.qos,
payloadMapping: action.ros2.payloadMapping,
},
}
: action.rest
? {
transport: "rest" as const,
timeoutMs: action.timeout, timeoutMs: action.timeout,
retryable: action.retryable, retryable: action.retryable,
rest: { ros2: {
method: action.rest.method, topic: action.ros2.topic,
path: action.rest.path, messageType: action.ros2.messageType,
headers: action.rest.headers, service: action.ros2.service,
action: action.ros2.action,
qos: action.ros2.qos,
payloadMapping: action.ros2.payloadMapping,
}, },
} }
: action.rest
? {
transport: "rest" as const,
timeoutMs: action.timeout,
retryable: action.retryable,
rest: {
method: action.rest.method,
path: action.rest.path,
headers: action.rest.headers,
},
}
: { : {
transport: "internal" as const, transport: "internal" as const,
timeoutMs: action.timeout, timeoutMs: action.timeout,
retryable: action.retryable, retryable: action.retryable,
}; };
// Extract semantic ID from metadata if available, otherwise fall back to database IDs // Extract semantic ID from metadata if available, otherwise fall back to database IDs
// Priority: metadata.robotId > metadata.id (for system plugins) > robotId > id // Priority: metadata.robotId > metadata.id (for system plugins) > robotId > id
@@ -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;
@@ -1044,13 +1081,13 @@ export function DesignerRoot({
const execution: ExperimentAction["execution"] = const execution: ExperimentAction["execution"] =
actionDef.execution && actionDef.execution &&
(actionDef.execution.transport === "internal" || (actionDef.execution.transport === "internal" ||
actionDef.execution.transport === "rest" || actionDef.execution.transport === "rest" ||
actionDef.execution.transport === "ros2") actionDef.execution.transport === "ros2")
? { ? {
transport: actionDef.execution.transport, transport: actionDef.execution.transport,
retryable: actionDef.execution.retryable ?? false, retryable: actionDef.execution.retryable ?? false,
} }
: undefined; : undefined;
const newId = `action-${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`; const newId = `action-${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`;
@@ -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, ? {
pluginId: actionDef.source.pluginId, kind: actionDef.source.kind as any,
pluginVersion: actionDef.source.pluginVersion, pluginId: actionDef.source.pluginId,
baseActionId: actionDef.id pluginVersion: actionDef.source.pluginVersion,
} : { kind: "core" }, baseActionId: actionDef.id,
}
: { 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,35 +1363,38 @@ 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",
dragOverlayAction.category === "robot" && "bg-emerald-600", dragOverlayAction.category === "robot" && "bg-emerald-600",
dragOverlayAction.category === "control" && "bg-amber-500", dragOverlayAction.category === "control" && "bg-amber-500",
dragOverlayAction.category === "observation" && dragOverlayAction.category === "observation" &&
"bg-purple-600", "bg-purple-600",
)} )}
/> />
{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,
}; };
@@ -225,12 +225,15 @@ export function PropertiesPanelBase({
const ResolvedIcon: React.ComponentType<{ className?: string }> = const ResolvedIcon: React.ComponentType<{ className?: string }> =
def?.icon && iconComponents[def.icon] def?.icon && iconComponents[def.icon]
? (iconComponents[def.icon] as React.ComponentType<{ ? (iconComponents[def.icon] as React.ComponentType<{
className?: string; className?: string;
}>) }>)
: 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,44 +559,42 @@ 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="text-muted-foreground text-[10px] tracking-wide uppercase">
Parameters
</div>
<div className="space-y-3"> <div className="space-y-3">
<div className="text-muted-foreground text-[10px] tracking-wide uppercase"> {def.parameters.map((param) => (
Parameters <ParameterEditor
</div> key={param.id}
<div className="space-y-3"> param={param}
{def.parameters.map((param) => ( value={selectedAction.parameters[param.id]}
<ParameterEditor onUpdate={(val) => {
key={param.id} onActionUpdate(containingStep.id, selectedAction.id, {
param={param} parameters: {
value={selectedAction.parameters[param.id]} ...selectedAction.parameters,
onUpdate={(val) => { [param.id]: val,
onActionUpdate(containingStep.id, selectedAction.id, { },
parameters: { });
...selectedAction.parameters, }}
[param.id]: val, onCommit={() => {}}
}, />
}); ))}
}}
onCommit={() => { }}
/>
))}
</div>
</div> </div>
) : ( </div>
<div className="text-muted-foreground text-xs"> ) : (
No parameters for this action. <div className="text-muted-foreground text-xs">
</div> No parameters for this action.
) </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,19 +776,22 @@ const ParameterEditor = React.memo(function ParameterEditor({
setLocalValue(rawValue); setLocalValue(rawValue);
}, [rawValue]); }, [rawValue]);
const handleUpdate = useCallback((newVal: unknown, immediate = false) => { const handleUpdate = useCallback(
setLocalValue(newVal); (newVal: unknown, immediate = false) => {
setLocalValue(newVal);
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
if (immediate) { if (immediate) {
onUpdate(newVal);
} else {
debounceRef.current = setTimeout(() => {
onUpdate(newVal); onUpdate(newVal);
}, 300); } else {
} debounceRef.current = setTimeout(() => {
}, [onUpdate]); onUpdate(newVal);
}, 300);
}
},
[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]">
@@ -2,52 +2,52 @@
import { SettingsTab } from "./tabs/SettingsTab"; import { SettingsTab } from "./tabs/SettingsTab";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "~/components/ui/dialog"; } from "~/components/ui/dialog";
interface SettingsModalProps { interface SettingsModalProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
experiment: { experiment: {
id: string; id: string;
name: string; name: string;
description: string | null; description: string | null;
status: string; status: string;
studyId: string; studyId: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
study: { study: {
id: string; id: string;
name: string; name: string;
};
};
designStats?: {
stepCount: number;
actionCount: number;
}; };
};
designStats?: {
stepCount: number;
actionCount: number;
};
} }
export function SettingsModal({ export function SettingsModal({
open, open,
onOpenChange, onOpenChange,
experiment, experiment,
designStats, designStats,
}: 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>
Configure experiment metadata and status Configure experiment metadata and status
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<SettingsTab experiment={experiment} designStats={designStats} /> <SettingsTab experiment={experiment} designStats={designStats} />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
} }
@@ -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(
@@ -289,7 +285,7 @@ export function ValidationPanel({
className={cn( className={cn(
"h-7 justify-start gap-1 text-[11px]", "h-7 justify-start gap-1 text-[11px]",
severityFilter === "error" && severityFilter === "error" &&
"bg-red-600 text-white hover:opacity-90", "bg-red-600 text-white hover:opacity-90",
)} )}
onClick={() => setSeverityFilter("error")} onClick={() => setSeverityFilter("error")}
aria-pressed={severityFilter === "error"} aria-pressed={severityFilter === "error"}
@@ -305,7 +301,7 @@ export function ValidationPanel({
className={cn( className={cn(
"h-7 justify-start gap-1 text-[11px]", "h-7 justify-start gap-1 text-[11px]",
severityFilter === "warning" && severityFilter === "warning" &&
"bg-amber-500 text-white hover:opacity-90", "bg-amber-500 text-white hover:opacity-90",
)} )}
onClick={() => setSeverityFilter("warning")} onClick={() => setSeverityFilter("warning")}
aria-pressed={severityFilter === "warning"} aria-pressed={severityFilter === "warning"}
@@ -321,7 +317,7 @@ export function ValidationPanel({
className={cn( className={cn(
"h-7 justify-start gap-1 text-[11px]", "h-7 justify-start gap-1 text-[11px]",
severityFilter === "info" && severityFilter === "info" &&
"bg-blue-600 text-white hover:opacity-90", "bg-blue-600 text-white hover:opacity-90",
)} )}
onClick={() => setSeverityFilter("info")} onClick={() => setSeverityFilter("info")}
aria-pressed={severityFilter === "info"} aria-pressed={severityFilter === "info"}
@@ -5,16 +5,16 @@ import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { useDroppable } from "@dnd-kit/core"; import { useDroppable } from "@dnd-kit/core";
import { import {
ChevronRight, ChevronRight,
Trash2, Trash2,
Clock, Clock,
GitBranch, GitBranch,
Repeat, Repeat,
Layers, Layers,
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";
@@ -24,480 +24,530 @@ import { Badge } from "~/components/ui/badge";
import { useDesignerStore } from "../state/store"; import { useDesignerStore } from "../state/store";
export interface ActionChipProps { export interface ActionChipProps {
stepId: string; stepId: string;
action: ExperimentAction; action: ExperimentAction;
parentId: string | null; parentId: string | null;
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?: (
dragHandle?: boolean; stepId: string,
isFirst?: boolean; actionId: string,
isLast?: boolean; direction: "up" | "down",
) => void;
dragHandle?: boolean;
isFirst?: boolean;
isLast?: boolean;
} }
export interface ActionChipVisualsProps { export interface ActionChipVisualsProps {
action: ExperimentAction; action: ExperimentAction;
isSelected?: boolean; isSelected?: boolean;
isDragging?: boolean; isDragging?: boolean;
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;
isLast?: boolean; isLast?: boolean;
validationStatus?: "error" | "warning" | "info"; validationStatus?: "error" | "warning" | "info";
} }
/** /**
* Helper to determine visual style based on action type/category * Helper to determine visual style based on action type/category
*/ */
function getActionVisualStyle(action: ExperimentAction) { function getActionVisualStyle(action: ExperimentAction) {
const def = actionRegistry.getAction(action.type); const def = actionRegistry.getAction(action.type);
const category = def?.category || "other"; const category = def?.category || "other";
// Specific Control Types // Specific Control Types
if (action.type === "hristudio-core.wait" || action.type === "wait") { if (action.type === "hristudio-core.wait" || action.type === "wait") {
return {
variant: "wait",
icon: Clock,
bg: "bg-amber-500/10 hover:bg-amber-500/20",
border: "border-amber-200 dark:border-amber-800",
text: "text-amber-700 dark:text-amber-400",
accent: "bg-amber-500",
};
}
if (action.type === "hristudio-core.branch" || action.type === "branch") {
return {
variant: "branch",
icon: GitBranch,
bg: "bg-orange-500/10 hover:bg-orange-500/20",
border: "border-orange-200 dark:border-orange-800",
text: "text-orange-700 dark:text-orange-400",
accent: "bg-orange-500",
};
}
if (action.type === "hristudio-core.loop" || action.type === "loop") {
return {
variant: "loop",
icon: Repeat,
bg: "bg-purple-500/10 hover:bg-purple-500/20",
border: "border-purple-200 dark:border-purple-800",
text: "text-purple-700 dark:text-purple-400",
accent: "bg-purple-500",
};
}
if (action.type === "hristudio-core.parallel" || action.type === "parallel") {
return {
variant: "parallel",
icon: Layers,
bg: "bg-emerald-500/10 hover:bg-emerald-500/20",
border: "border-emerald-200 dark:border-emerald-800",
text: "text-emerald-700 dark:text-emerald-400",
accent: "bg-emerald-500",
};
}
// General Categories
if (category === "wizard") {
return {
variant: "wizard",
icon: HelpCircle,
bg: "bg-indigo-500/5 hover:bg-indigo-500/10",
border: "border-indigo-200 dark:border-indigo-800",
text: "text-indigo-700 dark:text-indigo-300",
accent: "bg-indigo-500",
};
}
if ((category as string) === "robot" || (category as string) === "movement" || (category as string) === "speech") {
return {
variant: "robot",
icon: Play, // Or specific robot icon if available
bg: "bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700",
border: "border-slate-200 dark:border-slate-700",
text: "text-slate-700 dark:text-slate-300",
accent: "bg-slate-500",
}
}
// Default
return { return {
variant: "default", variant: "wait",
icon: undefined, icon: Clock,
bg: "bg-muted/40 hover:bg-accent/40", bg: "bg-amber-500/10 hover:bg-amber-500/20",
border: "border-border", border: "border-amber-200 dark:border-amber-800",
text: "text-foreground", text: "text-amber-700 dark:text-amber-400",
accent: "bg-muted-foreground", accent: "bg-amber-500",
}; };
}
if (action.type === "hristudio-core.branch" || action.type === "branch") {
return {
variant: "branch",
icon: GitBranch,
bg: "bg-orange-500/10 hover:bg-orange-500/20",
border: "border-orange-200 dark:border-orange-800",
text: "text-orange-700 dark:text-orange-400",
accent: "bg-orange-500",
};
}
if (action.type === "hristudio-core.loop" || action.type === "loop") {
return {
variant: "loop",
icon: Repeat,
bg: "bg-purple-500/10 hover:bg-purple-500/20",
border: "border-purple-200 dark:border-purple-800",
text: "text-purple-700 dark:text-purple-400",
accent: "bg-purple-500",
};
}
if (action.type === "hristudio-core.parallel" || action.type === "parallel") {
return {
variant: "parallel",
icon: Layers,
bg: "bg-emerald-500/10 hover:bg-emerald-500/20",
border: "border-emerald-200 dark:border-emerald-800",
text: "text-emerald-700 dark:text-emerald-400",
accent: "bg-emerald-500",
};
}
// General Categories
if (category === "wizard") {
return {
variant: "wizard",
icon: HelpCircle,
bg: "bg-indigo-500/5 hover:bg-indigo-500/10",
border: "border-indigo-200 dark:border-indigo-800",
text: "text-indigo-700 dark:text-indigo-300",
accent: "bg-indigo-500",
};
}
if (
(category as string) === "robot" ||
(category as string) === "movement" ||
(category as string) === "speech"
) {
return {
variant: "robot",
icon: Play, // Or specific robot icon if available
bg: "bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700",
border: "border-slate-200 dark:border-slate-700",
text: "text-slate-700 dark:text-slate-300",
accent: "bg-slate-500",
};
}
// Default
return {
variant: "default",
icon: undefined,
bg: "bg-muted/40 hover:bg-accent/40",
border: "border-border",
text: "text-foreground",
accent: "bg-muted-foreground",
};
} }
export function ActionChipVisuals({ export function ActionChipVisuals({
action, action,
isSelected, isSelected,
isDragging, isDragging,
isOverNested, isOverNested,
onSelect, onSelect,
onDelete, onDelete,
onReorder, onReorder,
dragHandleProps, dragHandleProps,
children, children,
isFirst, isFirst,
isLast, isLast,
validationStatus, validationStatus,
}: ActionChipVisualsProps) { }: ActionChipVisualsProps) {
const def = actionRegistry.getAction(action.type); const def = actionRegistry.getAction(action.type);
const style = getActionVisualStyle(action); const style = getActionVisualStyle(action);
const Icon = style.icon; const Icon = style.icon;
return ( return (
<div
className={cn(
"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.border,
isSelected && "ring-primary border-primary bg-accent/50 ring-2",
isDragging && "scale-95 opacity-70 shadow-lg",
isOverNested &&
!isDragging &&
"bg-blue-50/50 ring-2 ring-blue-400 ring-offset-1 dark:bg-blue-900/20",
)}
onClick={onSelect}
role="button"
aria-pressed={isSelected}
tabIndex={0}
>
{/* Accent Bar logic for control flow */}
{style.variant !== "default" && style.variant !== "robot" && (
<div <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 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( className={cn(
"group relative flex w-full flex-col items-start gap-1 rounded border px-3 py-2 text-[11px] transition-all duration-200", "truncate leading-snug font-medium break-words",
style.bg, style.text,
style.border,
isSelected && "ring-2 ring-primary border-primary bg-accent/50",
isDragging && "opacity-70 shadow-lg scale-95",
isOverNested && !isDragging && "ring-2 ring-blue-400 ring-offset-1 bg-blue-50/50 dark:bg-blue-900/20"
)} )}
onClick={onSelect} >
role="button" {action.name}
aria-pressed={isSelected} </span>
tabIndex={0}
{/* Inline Info for Control Actions */}
{style.variant === "wait" && !!action.parameters.duration && (
<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
</span>
)}
{style.variant === "loop" && (
<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
</span>
)}
{style.variant === "loop" &&
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" />
Ask
</span>
)}
{validationStatus === "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" && (
<div
className="h-2 w-2 flex-shrink-0 rounded-full bg-amber-500 ring-1 ring-amber-600"
aria-label="Warning"
/>
)}
</div>
<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
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground pointer-events-auto z-20 h-5 w-5 p-0 text-[10px]"
onClick={(e) => {
e.stopPropagation();
onReorder?.("up");
}}
disabled={isFirst}
aria-label="Move action up"
>
<ChevronRight className="h-3 w-3 -rotate-90" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground pointer-events-auto z-20 h-5 w-5 p-0 text-[10px]"
onClick={(e) => {
e.stopPropagation();
onReorder?.("down");
}}
disabled={isLast}
aria-label="Move action down"
>
<ChevronRight className="h-3 w-3 rotate-90" />
</Button>
</div>
<button
type="button"
onClick={onDelete}
className="text-muted-foreground hover:text-destructive rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
aria-label="Delete action"
> >
{/* Accent Bar logic for control flow */} <Trash2 className="h-3 w-3" />
{style.variant !== "default" && style.variant !== "robot" && ( </button>
<div className={cn("absolute left-0 top-0 bottom-0 w-1 rounded-l", style.accent)} /> </div>
)}
<div className={cn("flex w-full items-center gap-2", style.variant !== "default" && style.variant !== "robot" && "pl-2")}> {/* Description / Subtext */}
<div className="flex items-center gap-2 flex-1 min-w-0"> {def?.description && (
{Icon && <Icon className={cn("h-3.5 w-3.5 flex-shrink-0", style.text)} />} <div
<span className={cn("leading-snug font-medium break-words truncate", style.text)}> className={cn(
{action.name} "text-muted-foreground mt-0.5 line-clamp-2 w-full pl-2 text-[10px] leading-snug",
</span> style.variant !== "default" && style.variant !== "robot" && "pl-4",
)}
>
{def.description}
</div>
)}
{/* Inline Info for Control Actions */} {/* Tags for parameters (hide for specialized control blocks that show inline) */}
{style.variant === "wait" && !!action.parameters.duration && ( {def?.parameters?.length &&
<span className="ml-1 text-[10px] bg-background/50 px-1.5 py-0.5 rounded font-mono text-muted-foreground"> (style.variant === "default" || style.variant === "robot") ? (
{String(action.parameters.duration ?? "")}s <div className="flex flex-wrap gap-1 pt-1">
</span> {def.parameters.slice(0, 3).map((p) => (
)} <span
{style.variant === "loop" && ( key={p.id}
<span className="ml-1 text-[10px] bg-background/50 px-1.5 py-0.5 rounded font-mono text-muted-foreground"> 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"
{String(action.parameters.iterations || 1)}x >
</span> {p.name}
)} </span>
{style.variant === "loop" && action.parameters.requireApproval !== false && ( ))}
<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"> {def.parameters.length > 3 && (
<HelpCircle className="h-2 w-2" /> <span className="text-muted-foreground text-[9px]">
Ask +{def.parameters.length - 3}
</span> </span>
)} )}
</div>
) : null}
{validationStatus === "error" && ( {children}
<div className="h-2 w-2 rounded-full bg-red-500 ring-1 ring-red-600 flex-shrink-0" aria-label="Error" /> </div>
)} );
{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>
<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">
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-[10px] text-muted-foreground hover:text-foreground z-20 pointer-events-auto"
onClick={(e) => {
e.stopPropagation();
onReorder?.('up');
}}
disabled={isFirst}
aria-label="Move action up"
>
<ChevronRight className="h-3 w-3 -rotate-90" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-[10px] text-muted-foreground hover:text-foreground z-20 pointer-events-auto"
onClick={(e) => {
e.stopPropagation();
onReorder?.('down');
}}
disabled={isLast}
aria-label="Move action down"
>
<ChevronRight className="h-3 w-3 rotate-90" />
</Button>
</div>
<button
type="button"
onClick={onDelete}
className="text-muted-foreground hover:text-destructive rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
aria-label="Delete action"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
{/* Description / Subtext */}
{
def?.description && (
<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")}>
{def.description}
</div>
)
}
{/* Tags for parameters (hide for specialized control blocks that show inline) */}
{
def?.parameters?.length && (style.variant === 'default' || style.variant === 'robot') ? (
<div className="flex flex-wrap gap-1 pt-1">
{def.parameters.slice(0, 3).map((p) => (
<span
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]"
>
{p.name}
</span>
))}
{def.parameters.length > 3 && (
<span className="text-[9px] text-muted-foreground">+{def.parameters.length - 3}</span>
)}
</div>
) : null
}
{children}
</div >
);
} }
export function SortableActionChip({ export function SortableActionChip({
stepId, stepId,
action, action,
parentId, parentId,
selectedActionId, selectedActionId,
onSelectAction, onSelectAction,
onDeleteAction, onDeleteAction,
onReorderAction, onReorderAction,
dragHandle, dragHandle,
isFirst, isFirst,
isLast, isLast,
}: ActionChipProps) { }: ActionChipProps) {
const isSelected = selectedActionId === action.id; const isSelected = selectedActionId === action.id;
const insertionProjection = useDesignerStore((s) => s.insertionProjection); const insertionProjection = useDesignerStore((s) => s.insertionProjection);
const steps = useDesignerStore((s) => s.steps); const steps = useDesignerStore((s) => s.steps);
const currentStep = steps.find((s) => s.id === stepId); const currentStep = steps.find((s) => s.id === stepId);
// Branch Options Visualization // Branch Options Visualization
const branchOptions = useMemo(() => { const branchOptions = useMemo(() => {
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 (
return ( !options?.length &&
<div className="mt-2 text-muted-foreground/60 italic text-center py-2 text-[10px] bg-background/50 rounded border border-dashed"> !(currentStep.trigger as any)?.conditions?.nextStepId
No branches configured. Add options in properties. ) {
</div> return (
); <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.
</div>
// Combine explicit options and unconditional nextStepId );
// The original FlowWorkspace logic iterated options. logic there:
// (step.trigger.conditions as any).options.map...
return (
<div className="mt-2 space-y-1 w-full">
{options?.map((opt: any, idx: number) => {
// Resolve ID to name for display
let targetName = "Unlinked";
let targetIndex = -1;
if (opt.nextStepId) {
const target = steps.find(s => s.id === opt.nextStepId);
if (target) {
targetName = target.name;
targetIndex = target.order;
}
} else if (typeof opt.nextStepIndex === 'number') {
targetIndex = opt.nextStepIndex;
targetName = `Step #${targetIndex + 1}`;
}
return (
<div key={idx} className="flex items-center justify-between rounded bg-background/50 shadow-sm border p-1.5 text-[10px]">
<div className="flex items-center gap-2 min-w-0">
<Badge variant="outline" className={cn(
"text-[9px] uppercase font-bold tracking-wider px-1 py-0 min-w-[60px] justify-center bg-background",
opt.variant === "destructive"
? "border-red-500/30 text-red-600 dark:text-red-400"
: "border-slate-500/30 text-foreground"
)}>
{opt.label}
</Badge>
<ChevronRight className="h-3 w-3 text-muted-foreground/50 flex-shrink-0" />
</div>
<div className="flex items-center gap-1.5 text-right min-w-0 max-w-[60%] justify-end">
<span className="font-medium truncate text-foreground/80" title={targetName}>
{targetName}
</span>
{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">
#{targetIndex + 1}
</Badge>
)}
</div>
</div>
);
})}
{/* Visual indicator for unconditional jump if present and no options matched (though usually logic handles this) */}
{/* For now keeping parity with FlowWorkspace which only showed options */}
</div>
);
}, [action.type, currentStep, steps]);
const displayChildren = useMemo(() => {
if (
insertionProjection?.stepId === stepId &&
insertionProjection.parentId === action.id
) {
const copy = [...(action.children || [])];
copy.splice(insertionProjection.index, 0, insertionProjection.action);
return copy;
}
return action.children || [];
}, [action.children, action.id, stepId, insertionProjection]);
/* ------------------------------------------------------------------------ */
/* Main Sortable Logic */
/* ------------------------------------------------------------------------ */
const isPlaceholder = action.id === "projection-placeholder";
// Compute validation status
const issues = useDesignerStore((s) => s.validationIssues[action.id]);
const validationStatus = useMemo(() => {
if (!issues?.length) return undefined;
if (issues.some((i) => i.severity === "error")) return "error";
if (issues.some((i) => i.severity === "warning")) return "warning";
return "info";
}, [issues]);
/* ------------------------------------------------------------------------ */
/* Sortable (Local) DnD Monitoring */
/* ------------------------------------------------------------------------ */
// useSortable disabled per user request to remove action drag-and-drop
// const { ... } = useSortable(...)
// Use local dragging state or passed prop
const isDragging = dragHandle || false;
/* ------------------------------------------------------------------------ */
/* Nested Droppable (for control flow containers) */
/* ------------------------------------------------------------------------ */
const def = actionRegistry.getAction(action.type);
const nestedDroppableId = `container-${action.id}`;
const {
isOver: isOverNested,
setNodeRef: setNestedNodeRef
} = useDroppable({
id: nestedDroppableId,
disabled: !def?.nestable || isPlaceholder, // Disable droppable for placeholder
data: {
type: "container",
stepId,
parentId: action.id,
action // Pass full action for projection logic
}
});
const shouldRenderChildren = !!def?.nestable;
if (isPlaceholder) {
return (
<div
className={cn(
"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"
)}
>
<div className="flex w-full items-center gap-2">
<span className="font-medium text-blue-700 italic">
{action.name}
</span>
</div>
</div >
);
} }
return ( // Combine explicit options and unconditional nextStepId
<ActionChipVisuals // The original FlowWorkspace logic iterated options. logic there:
action={action} // (step.trigger.conditions as any).options.map...
isSelected={isSelected}
isDragging={isDragging}
isOverNested={isOverNested && !isDragging}
onSelect={(e) => {
e.stopPropagation();
onSelectAction(stepId, action.id);
}}
onDelete={(e) => {
e.stopPropagation();
onDeleteAction(stepId, action.id);
}}
onReorder={(direction) => onReorderAction?.(stepId, action.id, direction)}
isFirst={isFirst}
isLast={isLast}
validationStatus={validationStatus}
>
{/* Branch Options Visualization */}
{branchOptions}
{/* Nested Children Rendering (e.g. for Loops/Parallel) */} return (
{shouldRenderChildren && ( <div className="mt-2 w-full space-y-1">
<div {options?.map((opt: any, idx: number) => {
ref={setNestedNodeRef} // Resolve ID to name for display
className={cn( let targetName = "Unlinked";
"mt-2 w-full space-y-2 rounded border border-dashed p-1.5 transition-colors", let targetIndex = -1;
isOverNested
? "bg-blue-100/50 dark:bg-blue-900/20 border-blue-400" if (opt.nextStepId) {
: "bg-muted/20 dark:bg-muted/10 border-border/50" const target = steps.find((s) => s.id === opt.nextStepId);
)} if (target) {
targetName = target.name;
targetIndex = target.order;
}
} else if (typeof opt.nextStepIndex === "number") {
targetIndex = opt.nextStepIndex;
targetName = `Step #${targetIndex + 1}`;
}
return (
<div
key={idx}
className="bg-background/50 flex items-center justify-between rounded border p-1.5 text-[10px] shadow-sm"
>
<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"
? "border-red-500/30 text-red-600 dark:text-red-400"
: "text-foreground border-slate-500/30",
)}
> >
{displayChildren?.length === 0 ? ( {opt.label}
<div className="py-2 text-center text-[10px] text-muted-foreground/60 italic"> </Badge>
Empty container <ChevronRight className="text-muted-foreground/50 h-3 w-3 flex-shrink-0" />
</div> </div>
) : (
displayChildren?.map((child, idx) => ( <div className="flex max-w-[60%] min-w-0 items-center justify-end gap-1.5 text-right">
<SortableActionChip <span
key={child.id} className="text-foreground/80 truncate font-medium"
stepId={stepId} title={targetName}
action={child} >
parentId={action.id} {targetName}
selectedActionId={selectedActionId} </span>
onSelectAction={onSelectAction} {targetIndex !== -1 && (
onDeleteAction={onDeleteAction} <Badge
onReorderAction={onReorderAction} variant="secondary"
isFirst={idx === 0} className="h-3.5 min-w-[18px] justify-center bg-slate-100 px-1 py-0 text-[9px] tabular-nums dark:bg-slate-800"
isLast={idx === (displayChildren?.length || 0) - 1} >
/> #{targetIndex + 1}
)) </Badge>
)} )}
</div> </div>
)} </div>
</ActionChipVisuals> );
})}
{/* Visual indicator for unconditional jump if present and no options matched (though usually logic handles this) */}
{/* For now keeping parity with FlowWorkspace which only showed options */}
</div>
); );
}, [action.type, currentStep, steps]);
const displayChildren = useMemo(() => {
if (
insertionProjection?.stepId === stepId &&
insertionProjection.parentId === action.id
) {
const copy = [...(action.children || [])];
copy.splice(insertionProjection.index, 0, insertionProjection.action);
return copy;
}
return action.children || [];
}, [action.children, action.id, stepId, insertionProjection]);
/* ------------------------------------------------------------------------ */
/* Main Sortable Logic */
/* ------------------------------------------------------------------------ */
const isPlaceholder = action.id === "projection-placeholder";
// Compute validation status
const issues = useDesignerStore((s) => s.validationIssues[action.id]);
const validationStatus = useMemo(() => {
if (!issues?.length) return undefined;
if (issues.some((i) => i.severity === "error")) return "error";
if (issues.some((i) => i.severity === "warning")) return "warning";
return "info";
}, [issues]);
/* ------------------------------------------------------------------------ */
/* Sortable (Local) DnD Monitoring */
/* ------------------------------------------------------------------------ */
// useSortable disabled per user request to remove action drag-and-drop
// const { ... } = useSortable(...)
// Use local dragging state or passed prop
const isDragging = dragHandle || false;
/* ------------------------------------------------------------------------ */
/* Nested Droppable (for control flow containers) */
/* ------------------------------------------------------------------------ */
const def = actionRegistry.getAction(action.type);
const nestedDroppableId = `container-${action.id}`;
const { isOver: isOverNested, setNodeRef: setNestedNodeRef } = useDroppable({
id: nestedDroppableId,
disabled: !def?.nestable || isPlaceholder, // Disable droppable for placeholder
data: {
type: "container",
stepId,
parentId: action.id,
action, // Pass full action for projection logic
},
});
const shouldRenderChildren = !!def?.nestable;
if (isPlaceholder) {
return (
<div
className={cn(
"relative flex w-full flex-col items-start gap-1 rounded border border-dashed px-3 py-2 text-[11px]",
"border-blue-400 bg-blue-50/50 opacity-70 dark:bg-blue-900/20",
)}
>
<div className="flex w-full items-center gap-2">
<span className="font-medium text-blue-700 italic">
{action.name}
</span>
</div>
</div>
);
}
return (
<ActionChipVisuals
action={action}
isSelected={isSelected}
isDragging={isDragging}
isOverNested={isOverNested && !isDragging}
onSelect={(e) => {
e.stopPropagation();
onSelectAction(stepId, action.id);
}}
onDelete={(e) => {
e.stopPropagation();
onDeleteAction(stepId, action.id);
}}
onReorder={(direction) => onReorderAction?.(stepId, action.id, direction)}
isFirst={isFirst}
isLast={isLast}
validationStatus={validationStatus}
>
{/* Branch Options Visualization */}
{branchOptions}
{/* Nested Children Rendering (e.g. for Loops/Parallel) */}
{shouldRenderChildren && (
<div
ref={setNestedNodeRef}
className={cn(
"mt-2 w-full space-y-2 rounded border border-dashed p-1.5 transition-colors",
isOverNested
? "border-blue-400 bg-blue-100/50 dark:bg-blue-900/20"
: "bg-muted/20 dark:bg-muted/10 border-border/50",
)}
>
{displayChildren?.length === 0 ? (
<div className="text-muted-foreground/60 py-2 text-center text-[10px] italic">
Empty container
</div>
) : (
displayChildren?.map((child, idx) => (
<SortableActionChip
key={child.id}
stepId={stepId}
action={child}
parentId={action.id}
selectedActionId={selectedActionId}
onSelectAction={onSelectAction}
onDeleteAction={onDeleteAction}
onReorderAction={onReorderAction}
isFirst={idx === 0}
isLast={idx === (displayChildren?.length || 0) - 1}
/>
))
)}
</div>
)}
</ActionChipVisuals>
);
} }
@@ -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
@@ -220,7 +224,7 @@ function StepRow({
onRenameStep( onRenameStep(
step, step,
(e.target as HTMLInputElement).value.trim() || (e.target as HTMLInputElement).value.trim() ||
step.name, step.name,
); );
setRenamingStepId(null); setRenamingStepId(null);
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
@@ -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;
@@ -459,14 +478,12 @@ function StepDroppableArea({ stepId }: { stepId: string }) {
className={cn( className={cn(
"pointer-events-none absolute inset-0 rounded-md transition-colors", "pointer-events-none absolute inset-0 rounded-md transition-colors",
isOver && isOver &&
"bg-blue-50/40 ring-2 ring-blue-400/60 ring-offset-0 dark:bg-blue-950/20", "bg-blue-50/40 ring-2 ring-blue-400/60 ring-offset-0 dark:bg-blue-950/20",
)} )}
/> />
); );
} }
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* 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,29 +64,30 @@ 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<
className?: string; React.PropsWithChildren<{
panelClassName?: string; className?: string;
contentClassName?: string; panelClassName?: string;
}>> = ({ contentClassName?: string;
className: panelCls, }>
panelClassName, > = ({ className: panelCls, panelClassName, contentClassName, children }) => (
contentClassName, <section
children, className={cn(
}) => ( "min-w-0 overflow-hidden transition-[width,opacity] duration-300 ease-in-out",
<section panelCls,
className={cn("min-w-0 overflow-hidden transition-[width,opacity] duration-300 ease-in-out", panelCls, panelClassName)} panelClassName,
)}
>
<div
className={cn(
"h-full min-h-0 w-full overflow-x-hidden overflow-y-auto",
contentClassName,
)}
> >
<div {children}
className={cn( </div>
"h-full min-h-0 w-full overflow-x-hidden overflow-y-auto", </section>
contentClassName, );
)}
>
{children}
</div>
</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(() => {
@@ -270,10 +280,10 @@ export function PanelsContainer({
// We use FR units instead of % to let the browser handle exact pixel fitting without rounding errors causing overflow // We use FR units instead of % to let the browser handle exact pixel fitting without rounding errors causing overflow
const styleVars: React.CSSProperties & Record<string, string> = hasCenter const styleVars: React.CSSProperties & Record<string, string> = hasCenter
? { ? {
"--col-left": `${hasLeft ? l : 0}fr`, "--col-left": `${hasLeft ? l : 0}fr`,
"--col-center": `${c}fr`, "--col-center": `${c}fr`,
"--col-right": `${hasRight ? r : 0}fr`, "--col-right": `${hasRight ? r : 0}fr`,
} }
: {}; : {};
// Explicit grid template depending on which side panels exist // Explicit grid template depending on which side panels exist
@@ -299,19 +309,17 @@ export function PanelsContainer({
const centerDividers = const centerDividers =
showDividers && hasCenter showDividers && hasCenter
? cn({ ? cn({
"border-l": hasLeft, "border-l": hasLeft,
"border-r": hasRight, "border-r": hasRight,
}) })
: 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")}
@@ -89,8 +89,8 @@ function DraggableAction({
const style: React.CSSProperties = transform const style: React.CSSProperties = transform
? { ? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
} }
: {}; : {};
const IconComponent = iconMap[action.icon] ?? Sparkles; const IconComponent = iconMap[action.icon] ?? Sparkles;
@@ -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) {
@@ -176,16 +179,16 @@ function projectExecutionDescriptor(
timeoutMs: exec.timeoutMs ?? null, timeoutMs: exec.timeoutMs ?? null,
ros2: exec.ros2 ros2: exec.ros2
? { ? {
topic: exec.ros2.topic ?? null, topic: exec.ros2.topic ?? null,
service: exec.ros2.service ?? null, service: exec.ros2.service ?? null,
action: exec.ros2.action ?? null, action: exec.ros2.action ?? null,
} }
: null, : null,
rest: exec.rest rest: exec.rest
? { ? {
method: exec.rest.method, method: exec.rest.method,
path: exec.rest.path, path: exec.rest.path,
} }
: null, : null,
}; };
} }
@@ -244,12 +247,14 @@ export async function computeActionSignature(
baseActionId: def.baseActionId ?? null, baseActionId: def.baseActionId ?? null,
execution: def.execution execution: def.execution
? { ? {
transport: def.execution.transport, transport: def.execution.transport,
retryable: def.execution.retryable ?? false, retryable: def.execution.retryable ?? false,
timeoutMs: def.execution.timeoutMs ?? null, timeoutMs: def.execution.timeoutMs ?? null,
} }
: null,
schema: def.parameterSchemaRaw
? canonicalize(def.parameterSchemaRaw)
: null, : null,
schema: def.parameterSchemaRaw ? canonicalize(def.parameterSchemaRaw) : null,
}; };
return hashObject(projection); return hashObject(projection);
} }
@@ -271,29 +276,33 @@ 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(
// Action hashes sortedSteps.map(async (s) => {
const actionHashes = await Promise.all(s.actions.map(a => hashObject(projectActionForDesign(a, options)))); // Action hashes
const actionHashes = await Promise.all(
s.actions.map((a) => hashObject(projectActionForDesign(a, options))),
);
// Step hash // Step hash
const pStep = { const pStep = {
id: s.id, id: s.id,
type: s.type, type: s.type,
order: s.order, order: s.order,
trigger: { trigger: {
type: s.trigger.type, type: s.trigger.type,
conditions: canonicalize(s.trigger.conditions), conditions: canonicalize(s.trigger.conditions),
}, },
actions: actionHashes, actions: actionHashes,
...(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,
}); });
} }
+316 -274
View File
@@ -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,298 +266,331 @@ 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) => ({
dirtyEntities: new Set<string>(), steps: props.initialSteps
validationIssues: {}, ? reindexSteps(cloneSteps(props.initialSteps))
actionSignatureIndex: new Map(), : [],
actionSignatureDrift: new Set(), dirtyEntities: new Set<string>(),
pendingSave: false, validationIssues: {},
versionStrategy: "auto_minor" as VersionStrategy, actionSignatureIndex: new Map(),
autoSaveEnabled: true, actionSignatureDrift: new Set(),
busyHashing: false, pendingSave: false,
busyValidating: false, versionStrategy: "auto_minor" as VersionStrategy,
insertionProjection: null, autoSaveEnabled: true,
busyHashing: false,
busyValidating: false,
insertionProjection: null,
/* ------------------------------ Selection -------------------------------- */ /* ------------------------------ Selection -------------------------------- */
selectStep: (id) => selectStep: (id) =>
set({ set({
selectedStepId: id, selectedStepId: id,
selectedActionId: id ? get().selectedActionId : undefined, selectedActionId: id ? get().selectedActionId : undefined,
}), }),
selectAction: (stepId, actionId) => selectAction: (stepId, actionId) =>
set({ set({
selectedStepId: stepId, selectedStepId: stepId,
selectedActionId: actionId, selectedActionId: actionId,
}), }),
/* -------------------------------- Steps ---------------------------------- */ /* -------------------------------- Steps ---------------------------------- */
setSteps: (steps) => setSteps: (steps) =>
set(() => ({ set(() => ({
steps: reindexSteps(cloneSteps(steps)), steps: reindexSteps(cloneSteps(steps)),
dirtyEntities: new Set<string>(), // assume authoritative load dirtyEntities: new Set<string>(), // assume authoritative load
})), })),
upsertStep: (step) => upsertStep: (step) =>
set((state) => { set((state) => {
const idx = state.steps.findIndex((s) => s.id === step.id); const idx = state.steps.findIndex((s) => s.id === step.id);
let steps: ExperimentStep[]; let steps: ExperimentStep[];
if (idx >= 0) { if (idx >= 0) {
steps = [...state.steps]; steps = [...state.steps];
steps[idx] = { ...step }; steps[idx] = { ...step };
} else { } else {
steps = [...state.steps, { ...step, order: state.steps.length }]; steps = [...state.steps, { ...step, order: state.steps.length }];
} }
return { return {
steps: reindexSteps(steps), steps: reindexSteps(steps),
dirtyEntities: new Set([...state.dirtyEntities, step.id]), dirtyEntities: new Set([...state.dirtyEntities, step.id]),
}; };
}), }),
removeStep: (stepId) => removeStep: (stepId) =>
set((state) => { set((state) => {
const steps = state.steps.filter((s) => s.id !== stepId); const steps = state.steps.filter((s) => s.id !== stepId);
const dirty = new Set(state.dirtyEntities); const dirty = new Set(state.dirtyEntities);
dirty.add(stepId); dirty.add(stepId);
return { return {
steps: reindexSteps(steps), steps: reindexSteps(steps),
dirtyEntities: dirty, dirtyEntities: dirty,
selectedStepId: selectedStepId:
state.selectedStepId === stepId ? undefined : state.selectedStepId, state.selectedStepId === stepId ? undefined : state.selectedStepId,
selectedActionId: undefined, selectedActionId: undefined,
}; };
}), }),
reorderStep: (from: number, to: number) => reorderStep: (from: number, to: number) =>
set((state: DesignerState) => { set((state: DesignerState) => {
if ( if (
from < 0 || from < 0 ||
to < 0 || to < 0 ||
from >= state.steps.length || from >= state.steps.length ||
to >= state.steps.length || to >= state.steps.length ||
from === to from === to
) { ) {
return state; return state;
} }
const stepsDraft = [...state.steps]; const stepsDraft = [...state.steps];
const [moved] = stepsDraft.splice(from, 1); const [moved] = stepsDraft.splice(from, 1);
if (!moved) return state; if (!moved) return state;
stepsDraft.splice(to, 0, moved); stepsDraft.splice(to, 0, moved);
const reindexed = reindexSteps(stepsDraft); const reindexed = reindexSteps(stepsDraft);
return { return {
steps: reindexed, steps: reindexed,
dirtyEntities: new Set<string>([ dirtyEntities: new Set<string>([
...state.dirtyEntities, ...state.dirtyEntities,
...reindexed.map((s) => s.id), ...reindexed.map((s) => s.id),
]), ]),
}; };
}), }),
/* ------------------------------- Actions --------------------------------- */ /* ------------------------------- Actions --------------------------------- */
upsertAction: (stepId: string, action: ExperimentAction, parentId: string | null = null, index?: number) => upsertAction: (
set((state: DesignerState) => { stepId: string,
const stepsDraft: ExperimentStep[] = state.steps.map((s) => { action: ExperimentAction,
if (s.id !== stepId) return s; parentId: string | null = null,
index?: number,
) =>
set((state: DesignerState) => {
const stepsDraft: ExperimentStep[] = state.steps.map((s) => {
if (s.id !== stepId) return s;
// Check if exists (update)
const exists = findActionById(s.actions, action.id);
if (exists) {
// If updating, we don't (currently) support moving via upsert.
// Use moveAction for moving.
return {
...s,
actions: updateActionInTree(s.actions, action),
};
}
// Add new
// If index is provided, use it. Otherwise append.
const insertIndex = index ?? s.actions.length;
// Check if exists (update)
const exists = findActionById(s.actions, action.id);
if (exists) {
// If updating, we don't (currently) support moving via upsert.
// Use moveAction for moving.
return { return {
...s, ...s,
actions: updateActionInTree(s.actions, action) actions: insertActionIntoTree(
s.actions,
action,
parentId,
insertIndex,
),
}; };
} });
// Add new
// If index is provided, use it. Otherwise append.
const insertIndex = index ?? s.actions.length;
return { return {
...s, steps: stepsDraft,
actions: insertActionIntoTree(s.actions, action, parentId, insertIndex) dirtyEntities: new Set<string>([
...state.dirtyEntities,
action.id,
stepId,
]),
}; };
}); }),
return {
steps: stepsDraft,
dirtyEntities: new Set<string>([
...state.dirtyEntities,
action.id,
stepId,
]),
};
}),
removeAction: (stepId: string, actionId: string) => removeAction: (stepId: string, actionId: string) =>
set((state: DesignerState) => { set((state: DesignerState) => {
const stepsDraft: ExperimentStep[] = state.steps.map((s) => const stepsDraft: ExperimentStep[] = state.steps.map((s) =>
s.id === stepId s.id === stepId
? { ? {
...s, ...s,
actions: removeActionFromTree(s.actions, actionId), actions: removeActionFromTree(s.actions, actionId),
} }
: s, : s,
); );
const dirty = new Set<string>(state.dirtyEntities); const dirty = new Set<string>(state.dirtyEntities);
dirty.add(actionId); dirty.add(actionId);
dirty.add(stepId); dirty.add(stepId);
return { return {
steps: stepsDraft, steps: stepsDraft,
dirtyEntities: dirty, dirtyEntities: dirty,
selectedActionId: selectedActionId:
state.selectedActionId === actionId state.selectedActionId === actionId
? undefined ? undefined
: state.selectedActionId, : state.selectedActionId,
}; };
}), }),
moveAction: (stepId: string, actionId: string, newParentId: string | null, newIndex: number) => moveAction: (
set((state: DesignerState) => { stepId: string,
const stepsDraft = state.steps.map((s) => { actionId: string,
if (s.id !== stepId) return s; newParentId: string | null,
newIndex: number,
) =>
set((state: DesignerState) => {
const stepsDraft = state.steps.map((s) => {
if (s.id !== stepId) return s;
const actionToMove = findActionById(s.actions, actionId); const actionToMove = findActionById(s.actions, actionId);
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(
return { ...s, actions: inserted }; pruned,
}); actionToMove,
return { newParentId,
steps: stepsDraft, newIndex,
dirtyEntities: new Set<string>([...state.dirtyEntities, stepId, actionId]), );
}; return { ...s, actions: inserted };
}), });
return {
steps: stepsDraft,
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) =>
set((state: DesignerState) => ({ set((state: DesignerState) => ({
dirtyEntities: state.dirtyEntities.has(id) dirtyEntities: state.dirtyEntities.has(id)
? state.dirtyEntities ? state.dirtyEntities
: new Set<string>([...state.dirtyEntities, id]), : new Set<string>([...state.dirtyEntities, id]),
})), })),
clearDirty: (id: string) => clearDirty: (id: string) =>
set((state: DesignerState) => { set((state: DesignerState) => {
if (!state.dirtyEntities.has(id)) return state; if (!state.dirtyEntities.has(id)) return state;
const next = new Set(state.dirtyEntities); const next = new Set(state.dirtyEntities);
next.delete(id); next.delete(id);
return { dirtyEntities: next }; return { dirtyEntities: next };
}), }),
clearAllDirty: () => set({ dirtyEntities: new Set<string>() }), clearAllDirty: () => set({ dirtyEntities: new Set<string>() }),
/* ------------------------------- Hashing --------------------------------- */ /* ------------------------------- Hashing --------------------------------- */
recomputeHash: async (options?: { forceFull?: boolean }) => { recomputeHash: async (options?: { forceFull?: boolean }) => {
const { steps, incremental } = get(); const { steps, incremental } = get();
if (steps.length === 0) { if (steps.length === 0) {
set({ currentDesignHash: undefined }); set({ currentDesignHash: undefined });
return null; return null;
}
set({ busyHashing: true });
try {
const result = await computeIncrementalDesignHash(
steps,
options?.forceFull ? undefined : incremental,
);
set({
currentDesignHash: result.designHash,
incremental: {
actionHashes: result.actionHashes,
stepHashes: result.stepHashes,
},
});
return result;
} finally {
set({ busyHashing: false });
}
},
setPersistedHash: (hash: string) => set({ lastPersistedHash: hash }),
setValidatedHash: (hash: string) => set({ lastValidatedHash: hash }),
/* ----------------------------- Validation -------------------------------- */
setValidationIssues: (entityId: string, issues: ValidationIssue[]) =>
set((state: DesignerState) => ({
validationIssues: {
...state.validationIssues,
[entityId]: issues,
},
})),
clearValidationIssues: (entityId: string) =>
set((state: DesignerState) => {
if (!state.validationIssues[entityId]) return state;
const next = { ...state.validationIssues };
delete next[entityId];
return { validationIssues: next };
}),
clearAllValidationIssues: () => set({ validationIssues: {} }),
/* ------------------------- Action Signature Drift ------------------------ */
setActionSignature: (actionId: string, signature: string) =>
set((state: DesignerState) => {
const index = new Map(state.actionSignatureIndex);
index.set(actionId, signature);
return { actionSignatureIndex: index };
}),
detectActionSignatureDrift: (
action: ExperimentAction,
latestSignature: string,
) =>
set((state: DesignerState) => {
const current = state.actionSignatureIndex.get(action.id);
if (!current) {
const idx = new Map(state.actionSignatureIndex);
idx.set(action.id, latestSignature);
return { actionSignatureIndex: idx };
} }
if (current === latestSignature) return {}; set({ busyHashing: true });
const drift = new Set(state.actionSignatureDrift); try {
drift.add(action.id); const result = await computeIncrementalDesignHash(
return { actionSignatureDrift: drift }; steps,
}), options?.forceFull ? undefined : incremental,
clearActionSignatureDrift: (actionId: string) => );
set((state: DesignerState) => { set({
if (!state.actionSignatureDrift.has(actionId)) return state; currentDesignHash: result.designHash,
const next = new Set(state.actionSignatureDrift); incremental: {
next.delete(actionId); actionHashes: result.actionHashes,
return { actionSignatureDrift: next }; stepHashes: result.stepHashes,
}), },
});
return result;
} finally {
set({ busyHashing: false });
}
},
/* ------------------------------- Save Flow -------------------------------- */ setPersistedHash: (hash: string) => set({ lastPersistedHash: hash }),
setPendingSave: (pending: boolean) => set({ pendingSave: pending }), setValidatedHash: (hash: string) => set({ lastValidatedHash: hash }),
recordConflict: (serverHash: string, localHash: string) =>
set({
conflict: { serverHash, localHash, at: new Date() },
pendingSave: false,
}),
clearConflict: () => set({ conflict: undefined }),
setVersionStrategy: (strategy: VersionStrategy) =>
set({ versionStrategy: strategy }),
setAutoSaveEnabled: (enabled: boolean) => set({ autoSaveEnabled: enabled }),
/* ------------------------------ Server Sync ------------------------------ */ /* ----------------------------- Validation -------------------------------- */
applyServerSync: (payload: { setValidationIssues: (entityId: string, issues: ValidationIssue[]) =>
steps: ExperimentStep[]; set((state: DesignerState) => ({
persistedHash?: string; validationIssues: {
validatedHash?: string; ...state.validationIssues,
}) => [entityId]: issues,
set((state: DesignerState) => { },
const syncedSteps = reindexSteps(cloneSteps(payload.steps)); })),
const dirty = new Set<string>(); clearValidationIssues: (entityId: string) =>
return { set((state: DesignerState) => {
steps: syncedSteps, if (!state.validationIssues[entityId]) return state;
lastPersistedHash: payload.persistedHash ?? state.lastPersistedHash, const next = { ...state.validationIssues };
lastValidatedHash: payload.validatedHash ?? state.lastValidatedHash, delete next[entityId];
dirtyEntities: dirty, return { validationIssues: next };
conflict: undefined, }),
}; clearAllValidationIssues: () => set({ validationIssues: {} }),
}),
})); /* ------------------------- Action Signature Drift ------------------------ */
setActionSignature: (actionId: string, signature: string) =>
set((state: DesignerState) => {
const index = new Map(state.actionSignatureIndex);
index.set(actionId, signature);
return { actionSignatureIndex: index };
}),
detectActionSignatureDrift: (
action: ExperimentAction,
latestSignature: string,
) =>
set((state: DesignerState) => {
const current = state.actionSignatureIndex.get(action.id);
if (!current) {
const idx = new Map(state.actionSignatureIndex);
idx.set(action.id, latestSignature);
return { actionSignatureIndex: idx };
}
if (current === latestSignature) return {};
const drift = new Set(state.actionSignatureDrift);
drift.add(action.id);
return { actionSignatureDrift: drift };
}),
clearActionSignatureDrift: (actionId: string) =>
set((state: DesignerState) => {
if (!state.actionSignatureDrift.has(actionId)) return state;
const next = new Set(state.actionSignatureDrift);
next.delete(actionId);
return { actionSignatureDrift: next };
}),
/* ------------------------------- Save Flow -------------------------------- */
setPendingSave: (pending: boolean) => set({ pendingSave: pending }),
recordConflict: (serverHash: string, localHash: string) =>
set({
conflict: { serverHash, localHash, at: new Date() },
pendingSave: false,
}),
clearConflict: () => set({ conflict: undefined }),
setVersionStrategy: (strategy: VersionStrategy) =>
set({ versionStrategy: strategy }),
setAutoSaveEnabled: (enabled: boolean) => set({ autoSaveEnabled: enabled }),
/* ------------------------------ Server Sync ------------------------------ */
applyServerSync: (payload: {
steps: ExperimentStep[];
persistedHash?: string;
validatedHash?: string;
}) =>
set((state: DesignerState) => {
const syncedSteps = reindexSteps(cloneSteps(payload.steps));
const dirty = new Set<string>();
return {
steps: syncedSteps,
lastPersistedHash: payload.persistedHash ?? state.lastPersistedHash,
lastValidatedHash: payload.validatedHash ?? state.lastValidatedHash,
dirtyEntities: dirty,
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",
@@ -5,24 +5,30 @@ import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod"; import * as z from "zod";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import {
Form, Form,
FormControl, FormControl,
FormDescription, FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage, FormMessage,
} from "~/components/ui/form"; } from "~/components/ui/form";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { Textarea } from "~/components/ui/textarea"; import { Textarea } from "~/components/ui/textarea";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
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";
@@ -31,264 +37,307 @@ import { Save, ExternalLink } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
const formSchema = z.object({ const formSchema = z.object({
name: z.string().min(2, { name: z.string().min(2, {
message: "Name must be at least 2 characters.", message: "Name must be at least 2 characters.",
}), }),
description: z.string().optional(), description: z.string().optional(),
status: z.enum(experimentStatusEnum.enumValues), status: z.enum(experimentStatusEnum.enumValues),
}); });
interface SettingsTabProps { interface SettingsTabProps {
experiment: { experiment: {
id: string; id: string;
name: string; name: string;
description: string | null; description: string | null;
status: string; status: string;
studyId: string; studyId: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
study: { study: {
id: string; id: string;
name: string; name: string;
};
};
designStats?: {
stepCount: number;
actionCount: number;
}; };
};
designStats?: {
stepCount: number;
actionCount: number;
};
} }
export function SettingsTab({ experiment, designStats }: SettingsTabProps) { export function SettingsTab({ experiment, designStats }: SettingsTabProps) {
const utils = api.useUtils(); const utils = api.useUtils();
const updateExperiment = api.experiments.update.useMutation({ const updateExperiment = api.experiments.update.useMutation({
onSuccess: async () => { onSuccess: async () => {
toast.success("Experiment settings saved successfully"); toast.success("Experiment settings saved successfully");
// Invalidate experiments list to refresh data // Invalidate experiments list to refresh data
await utils.experiments.list.invalidate(); await utils.experiments.list.invalidate();
}, },
onError: (error) => { onError: (error) => {
toast.error(`Error saving settings: ${error.message}`); toast.error(`Error saving settings: ${error.message}`);
}, },
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: experiment.name,
description: experiment.description ?? "",
status: experiment.status as z.infer<typeof formSchema>["status"],
},
});
function onSubmit(values: z.infer<typeof formSchema>) {
updateExperiment.mutate({
id: experiment.id,
name: values.name,
description: values.description,
status: values.status,
}); });
}
const form = useForm<z.infer<typeof formSchema>>({ const isDirty = form.formState.isDirty;
resolver: zodResolver(formSchema),
defaultValues: {
name: experiment.name,
description: experiment.description ?? "",
status: experiment.status as z.infer<typeof formSchema>["status"],
},
});
function onSubmit(values: z.infer<typeof formSchema>) { return (
updateExperiment.mutate({ <div className="h-full overflow-y-auto p-6">
id: experiment.id, <div className="space-y-6">
name: values.name, {/* Header */}
description: values.description, <div>
status: values.status, <h2 className="text-2xl font-bold tracking-tight">
}); Experiment Settings
} </h2>
<p className="text-muted-foreground mt-1">
const isDirty = form.formState.isDirty; Configure experiment metadata and status
</p>
return (
<div className="h-full overflow-y-auto p-6">
<div className="space-y-6">
{/* Header */}
<div>
<h2 className="text-2xl font-bold tracking-tight">Experiment Settings</h2>
<p className="text-muted-foreground mt-1">
Configure experiment metadata and status
</p>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Left Column: Basic Information (Spans 2) */}
<div className="md:col-span-2 space-y-6">
<Card className="h-full">
<CardHeader>
<CardTitle>Basic Information</CardTitle>
<CardDescription>
The name and description help identify this experiment
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Experiment name" {...field} />
</FormControl>
<FormDescription>
A clear, descriptive name for your experiment
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Describe your experiment goals, methodology, and expected outcomes..."
className="resize-none min-h-[300px]"
{...field}
/>
</FormControl>
<FormDescription>
Detailed description of the experiment purpose and design
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
</div>
{/* Right Column: Status & Metadata (Spans 1) */}
<div className="space-y-6">
{/* Status Card */}
<Card>
<CardHeader>
<CardTitle>Status</CardTitle>
<CardDescription>
Track lifecycle stage
</CardDescription>
</CardHeader>
<CardContent>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Current Status</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="draft">
<div className="flex items-center gap-2">
<Badge variant="secondary">Draft</Badge>
<span className="text-xs text-muted-foreground">WIP</span>
</div>
</SelectItem>
<SelectItem value="testing">
<div className="flex items-center gap-2">
<Badge variant="outline">Testing</Badge>
<span className="text-xs text-muted-foreground">Validation</span>
</div>
</SelectItem>
<SelectItem value="ready">
<div className="flex items-center gap-2">
<Badge variant="default" className="bg-green-500">Ready</Badge>
<span className="text-xs text-muted-foreground">Live</span>
</div>
</SelectItem>
<SelectItem value="deprecated">
<div className="flex items-center gap-2">
<Badge variant="destructive">Deprecated</Badge>
<span className="text-xs text-muted-foreground">Retired</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Metadata Card */}
<Card>
<CardHeader>
<CardTitle>Metadata</CardTitle>
<CardDescription>
Read-only information
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Study</p>
<Link
href={`/studies/${experiment.study.id}`}
className="text-sm hover:underline flex items-center gap-1 text-primary truncate"
>
{experiment.study.name}
<ExternalLink className="h-3 w-3 flex-shrink-0" />
</Link>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Experiment ID</p>
<p className="text-xs font-mono bg-muted p-1 rounded select-all">{experiment.id.split('-')[0]}...</p>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Created</p>
<p className="text-xs">{new Date(experiment.createdAt).toLocaleDateString()}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Updated</p>
<p className="text-xs">{new Date(experiment.updatedAt).toLocaleDateString()}</p>
</div>
</div>
</div>
{designStats && (
<div className="pt-4 border-t">
<p className="text-xs font-medium text-muted-foreground mb-2">Statistics</p>
<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">
<span className="font-semibold">{designStats.stepCount}</span>
<span className="text-muted-foreground">Steps</span>
</div>
<div className="flex items-center gap-1.5 bg-muted/50 px-2 py-1 rounded text-xs">
<span className="font-semibold">{designStats.actionCount}</span>
<span className="text-muted-foreground">Actions</span>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end pt-4 border-t">
<Button
type="submit"
disabled={updateExperiment.isPending || !isDirty}
className="min-w-[120px]"
>
{updateExperiment.isPending ? (
"Saving..."
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Changes
</>
)}
</Button>
</div>
</form>
</Form>
</div>
</div> </div>
);
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{/* Left Column: Basic Information (Spans 2) */}
<div className="space-y-6 md:col-span-2">
<Card className="h-full">
<CardHeader>
<CardTitle>Basic Information</CardTitle>
<CardDescription>
The name and description help identify this experiment
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Experiment name" {...field} />
</FormControl>
<FormDescription>
A clear, descriptive name for your experiment
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Describe your experiment goals, methodology, and expected outcomes..."
className="min-h-[300px] resize-none"
{...field}
/>
</FormControl>
<FormDescription>
Detailed description of the experiment purpose and
design
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
</div>
{/* Right Column: Status & Metadata (Spans 1) */}
<div className="space-y-6">
{/* Status Card */}
<Card>
<CardHeader>
<CardTitle>Status</CardTitle>
<CardDescription>Track lifecycle stage</CardDescription>
</CardHeader>
<CardContent>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Current Status</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="draft">
<div className="flex items-center gap-2">
<Badge variant="secondary">Draft</Badge>
<span className="text-muted-foreground text-xs">
WIP
</span>
</div>
</SelectItem>
<SelectItem value="testing">
<div className="flex items-center gap-2">
<Badge variant="outline">Testing</Badge>
<span className="text-muted-foreground text-xs">
Validation
</span>
</div>
</SelectItem>
<SelectItem value="ready">
<div className="flex items-center gap-2">
<Badge
variant="default"
className="bg-green-500"
>
Ready
</Badge>
<span className="text-muted-foreground text-xs">
Live
</span>
</div>
</SelectItem>
<SelectItem value="deprecated">
<div className="flex items-center gap-2">
<Badge variant="destructive">
Deprecated
</Badge>
<span className="text-muted-foreground text-xs">
Retired
</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Metadata Card */}
<Card>
<CardHeader>
<CardTitle>Metadata</CardTitle>
<CardDescription>Read-only information</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div>
<p className="text-muted-foreground mb-1 text-xs font-medium">
Study
</p>
<Link
href={`/studies/${experiment.study.id}`}
className="text-primary flex items-center gap-1 truncate text-sm hover:underline"
>
{experiment.study.name}
<ExternalLink className="h-3 w-3 flex-shrink-0" />
</Link>
</div>
<div>
<p className="text-muted-foreground mb-1 text-xs font-medium">
Experiment ID
</p>
<p className="bg-muted rounded p-1 font-mono text-xs select-all">
{experiment.id.split("-")[0]}...
</p>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<p className="text-muted-foreground mb-1 text-xs font-medium">
Created
</p>
<p className="text-xs">
{new Date(
experiment.createdAt,
).toLocaleDateString()}
</p>
</div>
<div>
<p className="text-muted-foreground mb-1 text-xs font-medium">
Updated
</p>
<p className="text-xs">
{new Date(
experiment.updatedAt,
).toLocaleDateString()}
</p>
</div>
</div>
</div>
{designStats && (
<div className="border-t pt-4">
<p className="text-muted-foreground mb-2 text-xs font-medium">
Statistics
</p>
<div className="flex flex-wrap gap-2">
<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="text-muted-foreground">Steps</span>
</div>
<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="text-muted-foreground">
Actions
</span>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end border-t pt-4">
<Button
type="submit"
disabled={updateExperiment.isPending || !isDirty}
className="min-w-[120px]"
>
{updateExperiment.isPending ? (
"Saving..."
) : (
<>
<Save className="mr-2 h-4 w-4" />
Save Changes
</>
)}
</Button>
</div>
</form>
</Form>
</div>
</div>
);
} }
@@ -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" />
+442 -378
View File
@@ -1,346 +1,398 @@
"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);
export function useTour() { export function useTour() {
const context = useContext(TourContext); const context = useContext(TourContext);
if (!context) { if (!context) {
throw new Error("useTour must be used within a TourProvider"); throw new Error("useTour must be used within a TourProvider");
} }
return context; return context;
} }
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 { theme } = useTheme(); const [isTourActive, setIsTourActive] = useState(false);
const pathname = usePathname(); const { theme } = useTheme();
const pathname = usePathname();
const router = useRouter();
// --- Multi-page Tour Logic --- // --- Multi-page Tour Logic ---
useEffect(() => { useEffect(() => {
// Check if we are in "Full Platform" mode (Local Storage OR Cookie) // Check if we are in "Full Platform" mode (Local Storage OR Cookie)
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(() => {
if (pathname === "/dashboard") { if (pathname === "/dashboard") {
runTourSegment("dashboard"); runTourSegment("dashboard");
} else if (pathname.includes("/studies/new")) { } else if (pathname.includes("/studies/new")) {
runTourSegment("study_creation"); runTourSegment("study_creation");
} else if (pathname.includes("/participants/new")) { } else if (pathname.includes("/participants/new")) {
runTourSegment("participant_creation"); runTourSegment("participant_creation");
} else if (pathname.includes("/designer")) { } else if (pathname.includes("/designer")) {
runTourSegment("designer"); runTourSegment("designer");
} else if (pathname.includes("/wizard")) { } else if (pathname.includes("/wizard")) {
runTourSegment("wizard"); runTourSegment("wizard");
}
}, 500); // Reduced delay for snappier feel, but still safe for render
return () => clearTimeout(timer);
} }
}, [pathname]); }, 500); // Reduced delay for snappier feel, but still safe for render
return () => clearTimeout(timer);
}
}, [pathname]);
useEffect(() => { useEffect(() => {
// Listen for custom tour triggers (from components without context access) // Listen for custom tour triggers (from components without context access)
const handleTourTrigger = (e: Event) => { const handleTourTrigger = (e: Event) => {
const detail = (e as CustomEvent).detail as TourType; const detail = (e as CustomEvent).detail as TourType;
if (detail) { if (detail) {
startTour(detail); startTour(detail);
} }
};
document.addEventListener('hristudio-start-tour', handleTourTrigger);
return () => document.removeEventListener('hristudio-start-tour', handleTourTrigger);
}, []);
const runTourSegment = (segment: "dashboard" | "study_creation" | "participant_creation" | "designer" | "wizard" | "analytics") => {
const isDark = theme === "dark";
// We add a specific class to handle dark/light overrides reliably
const themeClass = isDark ? "driverjs-theme-dark" : "driverjs-theme-light";
let steps: any[] = [];
if (segment === "dashboard") {
steps = [
{
element: "#dashboard-header",
popover: {
title: "Overview",
description: "Welcome to HRIStudio. This dashboard gives you a high-level view of your research activities, active studies, and data collection progress.",
side: "bottom",
align: "start",
},
},
{
element: "#tour-sidebar-overview",
popover: {
title: "Navigation: Overview",
description: "Quickly return to this main dashboard from anywhere in the application.",
side: "right",
},
},
{
element: "#tour-sidebar-studies",
popover: {
title: "Navigation: Studies",
description: "Manage all your research studies, IRBs, and team permissions in one place.",
side: "right",
},
},
{
element: "#tour-sidebar-study-selector",
popover: {
title: "Active Study Selector",
description: "Switch between different studies here. Selecting a study unlocks study-specific tools like the Experiment Designer and Data Analytics.",
side: "right",
},
},
{
element: "#tour-new-study",
popover: {
title: "Create a New Study",
description: "Ready to start? Click here to initialize a new research project and define your protocol.",
side: "right",
},
},
];
} else if (segment === "study_creation") {
steps = [
{
element: "#tour-study-name",
popover: {
title: "Naming Your Study",
description: "Choose a concise, descriptive name. This will properly namespace your data, logs, and robot configurations.",
side: "right",
}
},
{
element: "#tour-study-description",
popover: {
title: "Research Protocol",
description: "Add a short description of your methodology or research questions. This helps team members understand the context.",
side: "right",
}
},
{
element: "#tour-study-submit",
popover: {
title: "Initialize Project",
description: "Create the study to access the full suite of tools: Experiment Designer, Wizard Interface, and Analytics.",
side: "top",
}
}
];
} else if (segment === "participant_creation") {
steps = [
{
element: "#tour-participant-code",
popover: {
title: "Participant ID",
description: "Assign a unique code (e.g., P001) to identify this participant while maintaining anonymity.",
side: "right",
}
},
{
element: "#tour-participant-name",
popover: {
title: "Name (Optional)",
description: "You store their name for internal reference; analytics will use the ID.",
side: "right",
}
},
{
element: "#tour-participant-study-container",
popover: {
title: "Study Association",
description: "Link this participant to a specific research study to enable data collection.",
side: "right",
}
},
{
element: "#tour-participant-consent",
popover: {
title: "Informed Consent",
description: "Mandatory check to confirm you have obtained necessary ethical approvals and consent.",
side: "top",
}
},
{
element: "#tour-participant-submit",
popover: {
title: "Register",
description: "Create the participant record to begin scheduling trials.",
side: "top",
}
}
];
} else if (segment === "designer") {
steps = [
{
element: "#tour-designer-blocks",
popover: {
title: "Action Library",
description: "Drag and drop robot behaviors (Speech, Gestures, Movement) onto the canvas. Includes both core actions and those from installed plugins.",
side: "right",
},
},
{
element: "#tour-designer-canvas",
popover: {
title: "Visual Flow Canvas",
description: "Design your experiment logic here. Connect blocks to create sequences, branches, and loops for the robot to execute.",
side: "top",
},
},
{
element: "#tour-designer-properties",
popover: {
title: "Properties Panel",
description: "Select any block to configure its parameters—like speech text, speed, volume, or timeout durations.",
side: "left",
},
},
];
} else if (segment === "wizard") {
steps = [
{
element: "#tour-wizard-controls",
popover: {
title: "Wizard Dashboard",
description: "The command center for running trials. Manually trigger robot actions or override autonomous behaviors in real-time.",
side: "right",
},
},
{
element: "#tour-wizard-timeline",
popover: {
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.",
side: "top",
},
},
{
element: "#tour-wizard-robot-status",
popover: {
title: "System Health",
description: "Monitor critical telemetry: battery levels, joint temperatures, and network latency to ensure safety.",
side: "left",
},
},
];
}
else if (segment === "analytics") {
steps = [
{
element: "#tour-analytics-table",
popover: {
title: "Study Analytics",
description: "View aggregate data across all participant sessions. Sort and filter to identify trends or specific trials.",
side: "bottom",
},
},
{
element: "#tour-analytics-filter",
popover: {
title: "Filter Data",
description: "Quickly find participants by ID or name using this search bar.",
side: "bottom",
},
},
{
element: "#tour-trial-metrics",
popover: {
title: "Trial Metrics",
description: "High-level KPIs for the selected trial: Duration, Robot Actions, and Intervention counts.",
side: "bottom",
},
},
{
element: "#tour-trial-timeline",
popover: {
title: "Video & Timeline",
description: "Watch the trial recording synced with the event timeline. Click any event to jump to that moment in the video.",
side: "right",
},
},
{
element: "#tour-trial-events",
popover: {
title: "Event Log",
description: "A detailed, searchable log of every system event, robot action, and wizard interaction.",
side: "left",
},
},
];
}
driverObj.current = driver({
showProgress: true,
animate: true,
allowClose: true,
steps: steps.map((step) => ({
...step,
popover: {
...step.popover,
popoverClass: `driver-popover-override ${themeClass}`,
},
})),
onDestroyed: () => {
// Persistence handled by localStorage state
}
});
driverObj.current.drive();
}; };
const startTour = (tour: TourType) => { document.addEventListener("hristudio-start-tour", handleTourTrigger);
if (tour === "full_platform") { return () =>
localStorage.setItem("hristudio_tour_mode", "full"); document.removeEventListener("hristudio-start-tour", handleTourTrigger);
Cookies.set("hristudio_tour_mode", "full", { expires: 7 }); // 7 days persistence }, []);
// Trigger current page immediately const runTourSegment = (
if (pathname === "/dashboard") runTourSegment("dashboard"); segment:
else if (pathname.includes("/studies/new")) runTourSegment("study_creation"); | "dashboard"
else if (pathname.includes("/participants/new")) runTourSegment("participant_creation"); | "study_creation"
else if (pathname.includes("/designer")) runTourSegment("designer"); | "participant_creation"
else if (pathname.includes("/wizard")) runTourSegment("wizard"); | "designer"
else if (pathname.includes("/analysis")) runTourSegment("analytics"); | "wizard"
else runTourSegment("dashboard"); // Fallback | "analytics",
} else { ) => {
localStorage.setItem("hristudio_tour_mode", "manual"); const isDark = theme === "dark";
Cookies.remove("hristudio_tour_mode"); // We add a specific class to handle dark/light overrides reliably
const themeClass = isDark ? "driverjs-theme-dark" : "driverjs-theme-light";
if (tour === "dashboard") runTourSegment("dashboard"); let steps: any[] = [];
if (tour === "study_creation") runTourSegment("study_creation");
if (tour === "participant_creation") runTourSegment("participant_creation");
if (tour === "designer") runTourSegment("designer");
if (tour === "wizard") runTourSegment("wizard");
if (tour === "analytics") runTourSegment("analytics");
}
};
return ( if (segment === "dashboard") {
<TourContext.Provider value={{ startTour }}> steps = [
{children} {
<style jsx global>{` element: "#dashboard-header",
popover: {
title: "Overview",
description:
"Welcome to HRIStudio. This dashboard gives you a high-level view of your research activities, active studies, and data collection progress.",
side: "bottom",
align: "start",
},
},
{
element: "#tour-sidebar-overview",
popover: {
title: "Navigation: Overview",
description:
"Quickly return to this main dashboard from anywhere in the application.",
side: "right",
},
},
{
element: "#tour-sidebar-studies",
popover: {
title: "Navigation: Studies",
description:
"Manage all your research studies, IRBs, and team permissions in one place.",
side: "right",
},
},
{
element: "#tour-sidebar-study-selector",
popover: {
title: "Active Study Selector",
description:
"Switch between different studies here. Selecting a study unlocks study-specific tools like the Experiment Designer and Data Analytics.",
side: "right",
},
},
{
element: "#tour-new-study",
popover: {
title: "Create a New Study",
description:
"Ready to start? Click here to initialize a new research project and define your protocol.",
side: "right",
},
},
];
} else if (segment === "study_creation") {
steps = [
{
element: "#tour-study-name",
popover: {
title: "Naming Your Study",
description:
"Choose a concise, descriptive name. This will properly namespace your data, logs, and robot configurations.",
side: "right",
},
},
{
element: "#tour-study-description",
popover: {
title: "Research Protocol",
description:
"Add a short description of your methodology or research questions. This helps team members understand the context.",
side: "right",
},
},
{
element: "#tour-study-submit",
popover: {
title: "Initialize Project",
description:
"Create the study to access the full suite of tools: Experiment Designer, Wizard Interface, and Analytics.",
side: "top",
},
},
];
} else if (segment === "participant_creation") {
steps = [
{
element: "#tour-participant-code",
popover: {
title: "Participant ID",
description:
"Assign a unique code (e.g., P001) to identify this participant while maintaining anonymity.",
side: "right",
},
},
{
element: "#tour-participant-name",
popover: {
title: "Name (Optional)",
description:
"You store their name for internal reference; analytics will use the ID.",
side: "right",
},
},
{
element: "#tour-participant-study-container",
popover: {
title: "Study Association",
description:
"Link this participant to a specific research study to enable data collection.",
side: "right",
},
},
{
element: "#tour-participant-consent",
popover: {
title: "Informed Consent",
description:
"Mandatory check to confirm you have obtained necessary ethical approvals and consent.",
side: "top",
},
},
{
element: "#tour-participant-submit",
popover: {
title: "Register",
description:
"Create the participant record to begin scheduling trials.",
side: "top",
},
},
];
} else if (segment === "designer") {
steps = [
{
element: "#tour-designer-blocks",
popover: {
title: "Action Library",
description:
"Drag and drop robot behaviors (Speech, Gestures, Movement) onto the canvas. Includes both core actions and those from installed plugins.",
side: "right",
},
},
{
element: "#tour-designer-canvas",
popover: {
title: "Visual Flow Canvas",
description:
"Design your experiment logic here. Connect blocks to create sequences, branches, and loops for the robot to execute.",
side: "top",
},
},
{
element: "#tour-designer-properties",
popover: {
title: "Properties Panel",
description:
"Select any block to configure its parameters—like speech text, speed, volume, or timeout durations.",
side: "left",
},
},
];
} else if (segment === "wizard") {
steps = [
{
element: "#tour-wizard-controls",
popover: {
title: "Wizard Dashboard",
description:
"The command center for running trials. Manually trigger robot actions or override autonomous behaviors in real-time.",
side: "right",
},
},
{
element: "#tour-wizard-timeline",
popover: {
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.",
side: "top",
},
},
{
element: "#tour-wizard-robot-status",
popover: {
title: "System Health",
description:
"Monitor critical telemetry: battery levels, joint temperatures, and network latency to ensure safety.",
side: "left",
},
},
];
} else if (segment === "analytics") {
steps = [
{
element: "#tour-analytics-table",
popover: {
title: "Study Analytics",
description:
"View aggregate data across all participant sessions. Sort and filter to identify trends or specific trials.",
side: "bottom",
},
},
{
element: "#tour-analytics-filter",
popover: {
title: "Filter Data",
description:
"Quickly find participants by ID or name using this search bar.",
side: "bottom",
},
},
{
element: "#tour-trial-metrics",
popover: {
title: "Trial Metrics",
description:
"High-level KPIs for the selected trial: Duration, Robot Actions, and Intervention counts.",
side: "bottom",
},
},
{
element: "#tour-trial-timeline",
popover: {
title: "Video & Timeline",
description:
"Watch the trial recording synced with the event timeline. Click any event to jump to that moment in the video.",
side: "right",
},
},
{
element: "#tour-trial-events",
popover: {
title: "Event Log",
description:
"A detailed, searchable log of every system event, robot action, and wizard interaction.",
side: "left",
},
},
];
}
driverObj.current = driver({
showProgress: true,
animate: true,
allowClose: true,
steps: steps.map((step) => ({
...step,
popover: {
...step.popover,
popoverClass: `driver-popover-override ${themeClass}`,
},
})),
onDestroyed: () => {
// Persistence handled by localStorage state
setIsTourActive(false);
},
});
driverObj.current.drive();
setIsTourActive(true);
};
const startTour = (tour: TourType) => {
if (tour === "full_platform") {
localStorage.setItem("hristudio_tour_mode", "full");
Cookies.set("hristudio_tour_mode", "full", { expires: 7 }); // 7 days persistence
if (pathname !== "/dashboard") {
router.push("/dashboard");
return;
}
// We are already on dashboard, trigger it immediately
runTourSegment("dashboard");
} else {
localStorage.setItem("hristudio_tour_mode", "manual");
Cookies.remove("hristudio_tour_mode");
if (tour === "dashboard") runTourSegment("dashboard");
if (tour === "study_creation") runTourSegment("study_creation");
if (tour === "participant_creation")
runTourSegment("participant_creation");
if (tour === "designer") runTourSegment("designer");
if (tour === "wizard") runTourSegment("wizard");
if (tour === "analytics") runTourSegment("analytics");
}
};
return (
<TourContext.Provider value={{ startTour, isTourActive }}>
{children}
<style jsx global>{`
/* /*
SHADCN/UI THEMING OVERRIDES SHADCN/UI THEMING OVERRIDES
CRITICAL: The global variables in globals.css use OKLCH/HSL values directly or with units. CRITICAL: The global variables in globals.css use OKLCH/HSL values directly or with units.
@@ -349,23 +401,23 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
*/ */
.driver-popover-override { .driver-popover-override {
padding: 1.25rem !important; padding: 1.25rem !important;
border-radius: var(--radius) !important; border-radius: var(--radius) !important;
box-shadow: var(--shadow-xl) !important; box-shadow: var(--shadow-xl) !important;
max-width: 420px !important; max-width: 420px !important;
/* Background & Text - Match Card Aesthetic */ /* Background & Text - Match Card Aesthetic */
background-color: var(--card) !important; background-color: var(--card) !important;
color: var(--card-foreground) !important; color: var(--card-foreground) !important;
border: 1px solid var(--border) !important; border: 1px solid var(--border) !important;
/* Typography */ /* Typography */
font-family: var(--font-sans) !important; font-family: var(--font-sans) !important;
} }
/* Arrow Styling - Critical for transparent/card matching */ /* Arrow Styling - Critical for transparent/card matching */
.driver-popover-override .driver-popover-arrow { .driver-popover-override .driver-popover-arrow {
border-width: 8px !important; border-width: 8px !important;
} }
/* /*
@@ -373,93 +425,105 @@ 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
border-left-color: var(--card) !important; .driver-popover-arrow-side-left.driver-popover-arrow {
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
border-right-color: var(--card) !important; .driver-popover-arrow-side-right.driver-popover-arrow {
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
border-top-color: var(--card) !important; .driver-popover-arrow-side-top.driver-popover-arrow {
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
border-bottom-color: var(--card) !important; .driver-popover-arrow-side-bottom.driver-popover-arrow {
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
border-left-color: var(--card) !important; .driver-popover-arrow-side-left.driver-popover-arrow {
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
border-right-color: var(--card) !important; .driver-popover-arrow-side-right.driver-popover-arrow {
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
border-top-color: var(--card) !important; .driver-popover-arrow-side-top.driver-popover-arrow {
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
border-bottom-color: var(--card) !important; .driver-popover-arrow-side-bottom.driver-popover-arrow {
border-bottom-color: var(--card) !important;
} }
/* Title Styling */ /* Title Styling */
.driver-popover-override .driver-popover-title { .driver-popover-override .driver-popover-title {
color: var(--foreground) !important; color: var(--foreground) !important;
font-size: 1.125rem !important; /* 18px */ font-size: 1.125rem !important; /* 18px */
font-weight: 600 !important; font-weight: 600 !important;
margin-bottom: 0.5rem !important; margin-bottom: 0.5rem !important;
letter-spacing: -0.015em !important; letter-spacing: -0.015em !important;
font-family: var(--font-sans) !important; font-family: var(--font-sans) !important;
} }
/* Description Styling */ /* Description Styling */
.driver-popover-override .driver-popover-description { .driver-popover-override .driver-popover-description {
color: var(--muted-foreground) !important; color: var(--muted-foreground) !important;
font-size: 0.875rem !important; /* 14px */ font-size: 0.875rem !important; /* 14px */
line-height: 1.6 !important; line-height: 1.6 !important;
font-family: var(--font-sans) !important; font-family: var(--font-sans) !important;
} }
/* Buttons */ /* Buttons */
.driver-popover-override .driver-popover-footer button { .driver-popover-override .driver-popover-footer button {
background-color: var(--primary) !important; background-color: var(--primary) !important;
color: var(--primary-foreground) !important; color: var(--primary-foreground) !important;
border-radius: calc(var(--radius) - 2px) !important; border-radius: calc(var(--radius) - 2px) !important;
padding: 0.5rem 1rem !important; padding: 0.5rem 1rem !important;
font-size: 0.875rem !important; font-size: 0.875rem !important;
font-weight: 500 !important; font-weight: 500 !important;
border: none !important; border: none !important;
text-shadow: none !important; text-shadow: none !important;
transition-all: 0.2s !important; transition-all: 0.2s !important;
font-family: var(--font-sans) !important; font-family: var(--font-sans) !important;
} }
.driver-popover-override .driver-popover-footer button:hover { .driver-popover-override .driver-popover-footer button:hover {
opacity: 0.9 !important; opacity: 0.9 !important;
transform: translateY(-1px); transform: translateY(-1px);
} }
/* Navigation Buttons (Previous/Next) specifically */ /* Navigation Buttons (Previous/Next) specifically */
.driver-popover-override .driver-popover-footer .driver-popover-prev-btn { .driver-popover-override
background-color: transparent !important; .driver-popover-footer
color: var(--muted-foreground) !important; .driver-popover-prev-btn {
border: 1px solid var(--border) !important; background-color: transparent !important;
color: var(--muted-foreground) !important;
border: 1px solid var(--border) !important;
} }
.driver-popover-override .driver-popover-footer .driver-popover-prev-btn:hover { .driver-popover-override
background-color: var(--accent) !important; .driver-popover-footer
color: var(--accent-foreground) !important; .driver-popover-prev-btn:hover {
background-color: var(--accent) !important;
color: var(--accent-foreground) !important;
} }
/* Close Button */ /* Close Button */
.driver-popover-override .driver-popover-close-btn { .driver-popover-override .driver-popover-close-btn {
color: var(--muted-foreground) !important; color: var(--muted-foreground) !important;
opacity: 0.7 !important; opacity: 0.7 !important;
transition: opacity 0.2s !important; transition: opacity 0.2s !important;
} }
.driver-popover-override .driver-popover-close-btn:hover { .driver-popover-override .driver-popover-close-btn:hover {
color: var(--foreground) !important; color: var(--foreground) !important;
opacity: 1 !important; opacity: 1 !important;
} }
`}</style> `}</style>
</TourContext.Provider> </TourContext.Provider>
); );
} }
+211 -175
View File
@@ -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";
@@ -9,186 +16,215 @@ import { toast } from "sonner";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
interface ConsentUploadFormProps { interface ConsentUploadFormProps {
studyId: string; studyId: string;
participantId: string; participantId: string;
consentFormId: string; consentFormId: string;
onSuccess: () => void; onSuccess: () => void;
onCancel: () => void; onCancel: () => void;
} }
export function ConsentUploadForm({ export function ConsentUploadForm({
studyId, studyId,
participantId, participantId,
consentFormId, consentFormId,
onSuccess, onSuccess,
onCancel, onCancel,
}: ConsentUploadFormProps) { }: ConsentUploadFormProps) {
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0); const [uploadProgress, setUploadProgress] = useState(0);
// Mutations // Mutations
const getUploadUrlMutation = api.participants.getConsentUploadUrl.useMutation(); const getUploadUrlMutation =
const recordConsentMutation = api.participants.recordConsent.useMutation(); api.participants.getConsentUploadUrl.useMutation();
const recordConsentMutation = api.participants.recordConsent.useMutation();
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) { if (e.target.files && e.target.files[0]) {
const selectedFile = e.target.files[0]; const selectedFile = e.target.files[0];
// Validate size (10MB) // Validate size (10MB)
if (selectedFile.size > 10 * 1024 * 1024) { if (selectedFile.size > 10 * 1024 * 1024) {
toast.error("File too large", { toast.error("File too large", {
description: "Maximum file size is 10MB", description: "Maximum file size is 10MB",
}); });
return; return;
}
// Validate type
const allowedTypes = [
"application/pdf",
"image/png",
"image/jpeg",
"image/jpg",
];
if (!allowedTypes.includes(selectedFile.type)) {
toast.error("Invalid file type", {
description: "Please upload a PDF, PNG, or JPG file",
});
return;
}
setFile(selectedFile);
}
};
const handleUpload = async () => {
if (!file) return;
try {
setIsUploading(true);
setUploadProgress(0);
// 1. Get Presigned URL
const { url, key } = await getUploadUrlMutation.mutateAsync({
studyId,
participantId,
filename: file.name,
contentType: file.type,
size: file.size,
});
// 2. Upload to MinIO using XMLHttpRequest for progress
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", url, true);
xhr.setRequestHeader("Content-Type", file.type);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percentCompleted = Math.round(
(event.loaded * 100) / event.total,
);
setUploadProgress(percentCompleted);
}
};
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);
});
// 3. Record Consent in DB
await recordConsentMutation.mutateAsync({
participantId,
consentFormId,
storagePath: key,
});
toast.success("Consent Recorded", {
description:
"The consent form has been uploaded and recorded successfully.",
});
onSuccess();
} catch (error) {
console.error("Upload failed:", error);
toast.error("Upload Failed", {
description:
error instanceof Error
? error.message
: "An unexpected error occurred",
});
setIsUploading(false);
}
};
return (
<div className="space-y-4">
{!file ? (
<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="text-muted-foreground mb-4 h-8 w-8" />
<h3 className="mb-1 text-sm font-semibold">Upload Signed Consent</h3>
<p className="text-muted-foreground mb-4 text-center text-xs">
Drag and drop or click to select
<br />
PDF, PNG, JPG up to 10MB
</p>
<input
type="file"
id="consent-file-upload"
className="hidden"
accept=".pdf,.png,.jpg,.jpeg"
onChange={handleFileChange}
/>
<Button
variant="secondary"
size="sm"
onClick={() =>
document.getElementById("consent-file-upload")?.click()
} }
// Validate type >
const allowedTypes = ["application/pdf", "image/png", "image/jpeg", "image/jpg"]; Select File
if (!allowedTypes.includes(selectedFile.type)) { </Button>
toast.error("Invalid file type", {
description: "Please upload a PDF, PNG, or JPG file",
});
return;
}
setFile(selectedFile);
}
};
const handleUpload = async () => {
if (!file) return;
try {
setIsUploading(true);
setUploadProgress(0);
// 1. Get Presigned URL
const { url, key } = await getUploadUrlMutation.mutateAsync({
studyId,
participantId,
filename: file.name,
contentType: file.type,
size: file.size,
});
// 2. Upload to MinIO using XMLHttpRequest for progress
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", url, true);
xhr.setRequestHeader("Content-Type", file.type);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percentCompleted = Math.round(
(event.loaded * 100) / event.total
);
setUploadProgress(percentCompleted);
}
};
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);
});
// 3. Record Consent in DB
await recordConsentMutation.mutateAsync({
participantId,
consentFormId,
storagePath: key,
});
toast.success("Consent Recorded", {
description: "The consent form has been uploaded and recorded successfully.",
});
onSuccess();
} catch (error) {
console.error("Upload failed:", error);
toast.error("Upload Failed", {
description: error instanceof Error ? error.message : "An unexpected error occurred",
});
setIsUploading(false);
}
};
return (
<div className="space-y-4">
{!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">
<Upload className="h-8 w-8 text-muted-foreground mb-4" />
<h3 className="font-semibold text-sm mb-1">Upload Signed Consent</h3>
<p className="text-xs text-muted-foreground mb-4 text-center">
Drag and drop or click to select<br />
PDF, PNG, JPG up to 10MB
</p>
<input
type="file"
id="consent-file-upload"
className="hidden"
accept=".pdf,.png,.jpg,.jpeg"
onChange={handleFileChange}
/>
<Button variant="secondary" size="sm" onClick={() => document.getElementById("consent-file-upload")?.click()}>
Select File
</Button>
</div>
) : (
<div className="border rounded-lg p-4 bg-muted/5">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="h-10 w-10 bg-primary/10 rounded flex items-center justify-center">
<FileText className="h-5 w-5 text-primary" />
</div>
<div>
<p className="text-sm font-medium line-clamp-1 break-all">{file.name}</p>
<p className="text-xs text-muted-foreground">
{(file.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
{!isUploading && (
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setFile(null)}>
<X className="h-4 w-4" />
</Button>
)}
</div>
{isUploading && (
<div className="space-y-2 mb-4">
<div className="flex justify-between text-xs text-muted-foreground">
<span>Uploading...</span>
<span>{uploadProgress}%</span>
</div>
<Progress value={uploadProgress} className="h-2" />
</div>
)}
<div className="flex gap-2 justify-end">
<Button variant="outline" size="sm" onClick={onCancel} disabled={isUploading}>
Cancel
</Button>
<Button size="sm" onClick={handleUpload} disabled={isUploading}>
{isUploading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Uploading
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
Upload & Record
</>
)}
</Button>
</div>
</div>
)}
</div> </div>
); ) : (
<div className="bg-muted/5 rounded-lg border p-4">
<div className="mb-4 flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded">
<FileText className="text-primary h-5 w-5" />
</div>
<div>
<p className="line-clamp-1 text-sm font-medium break-all">
{file.name}
</p>
<p className="text-muted-foreground text-xs">
{(file.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
{!isUploading && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setFile(null)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
{isUploading && (
<div className="mb-4 space-y-2">
<div className="text-muted-foreground flex justify-between text-xs">
<span>Uploading...</span>
<span>{uploadProgress}%</span>
</div>
<Progress value={uploadProgress} className="h-2" />
</div>
)}
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={onCancel}
disabled={isUploading}
>
Cancel
</Button>
<Button size="sm" onClick={handleUpload} disabled={isUploading}>
{isUploading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Uploading
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
Upload & Record
</>
)}
</Button>
</div>
</div>
)}
</div>
);
} }
@@ -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>
);
}
@@ -4,158 +4,235 @@ import { useState } from "react";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
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;
participantId: string; participantId: string;
consentGiven: boolean; consentGiven: boolean;
consentDate: Date | null; consentDate: Date | null;
existingConsent: { existingConsent: {
id: string; id: string;
storagePath: string | null; storagePath: string | null;
signedAt: Date; signedAt: Date;
consentForm: { consentForm: {
title: string; title: string;
version: number; version: number;
}; };
} | null; } | null;
} }
export function ParticipantConsentManager({ export function ParticipantConsentManager({
studyId,
participantId,
consentGiven,
consentDate,
existingConsent,
participantName,
participantCode,
}: ParticipantConsentManagerProps & { participantName?: string | null; participantCode: string }) {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
// Fetch active consent forms to know which form to sign/upload against
const { data: consentForms } = api.participants.getConsentForms.useQuery({
studyId, studyId,
participantId, });
consentGiven, const activeForm = consentForms?.find((f) => f.active) ?? consentForms?.[0];
consentDate,
existingConsent,
}: ParticipantConsentManagerProps) {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
// Fetch active consent forms to know which form to sign/upload against // Helper to get download URL
const { data: consentForms } = api.participants.getConsentForms.useQuery({ studyId }); const { refetch: fetchDownloadUrl } = api.files.getDownloadUrl.useQuery(
const activeForm = consentForms?.find((f) => f.active) ?? consentForms?.[0]; { storagePath: existingConsent?.storagePath ?? "" },
{ enabled: false },
);
// Helper to get download URL const handleDownload = async () => {
const { refetch: fetchDownloadUrl } = api.files.getDownloadUrl.useQuery( if (!existingConsent?.storagePath) return;
{ storagePath: existingConsent?.storagePath ?? "" }, try {
{ enabled: false } const result = await fetchDownloadUrl();
); if (result.data?.url) {
window.open(result.data.url, "_blank");
} else {
toast.error("Error", { description: "Could not retrieve document" });
}
} catch (error) {
toast.error("Error", { description: "Failed to get download URL" });
}
};
const handleDownload = async () => { const handleSuccess = () => {
if (!existingConsent?.storagePath) return; setIsOpen(false);
try { utils.participants.get.invalidate({ id: participantId });
const result = await fetchDownloadUrl(); toast.success("Success", { description: "Consent recorded successfully" });
if (result.data?.url) { };
window.open(result.data.url, "_blank");
} else {
toast.error("Error", { description: "Could not retrieve document" });
}
} catch (error) {
toast.error("Error", { description: "Failed to get download URL" });
}
};
const handleSuccess = () => { const handleDownloadUnsigned = async () => {
setIsOpen(false); if (!activeForm) return;
utils.participants.get.invalidate({ id: participantId }); try {
toast.success("Success", { description: "Consent recorded successfully" }); toast.loading("Generating custom document...", { id: "pdf-gen" });
};
return ( // Substitute placeholders in markdown
<div className="rounded-lg border bg-card text-card-foreground shadow-sm"> let customMd = activeForm.content;
<div className="p-6 flex flex-row items-center justify-between space-y-0 pb-2"> customMd = customMd.replace(/{{PARTICIPANT_NAME}}/g, participantName ?? "_________________");
<div className="flex flex-col space-y-1.5"> customMd = customMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
<h3 className="font-semibold leading-none tracking-tight flex items-center gap-2"> customMd = customMd.replace(/{{DATE}}/g, "_________________");
<FileText className="h-5 w-5" /> customMd = customMd.replace(/{{SIGNATURE_IMAGE}}/g, ""); // Blank ready for physical signature
Consent Status
</h3>
<p className="text-sm text-muted-foreground">
Manage participant consent and forms.
</p>
</div>
<Badge variant={consentGiven ? "default" : "destructive"}>
{consentGiven ? "Consent Given" : "Not Recorded"}
</Badge>
</div>
<div className="p-6 pt-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
{consentGiven ? (
<>
<div className="flex items-center gap-2 text-sm font-medium">
<CheckCircle className="h-4 w-4 text-green-600" />
Signed on {consentDate ? new Date(consentDate).toLocaleDateString() : "Unknown date"}
</div>
{existingConsent && (
<p className="text-xs text-muted-foreground">
Form: {existingConsent.consentForm.title} (v{existingConsent.consentForm.version})
</p>
)}
</>
) : (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<AlertCircle className="h-4 w-4" />
No consent recorded for this participant.
</div>
)}
</div>
<div className="flex gap-2">
{consentGiven && existingConsent?.storagePath && (
<Button variant="outline" size="sm" onClick={handleDownload}>
<Download className="mr-2 h-4 w-4" />
Download PDF
</Button>
)}
<Dialog open={isOpen} onOpenChange={setIsOpen}> // Use headless Tiptap to parse MD to HTML via same extensions
<DialogTrigger asChild> const editor = new Editor({
<Button size="sm" variant={consentGiven ? "secondary" : "default"}> extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown],
<Upload className="mr-2 h-4 w-4" /> content: customMd,
{consentGiven ? "Update Consent" : "Record Consent"} });
</Button>
</DialogTrigger> const htmlContent = editor.getHTML();
<DialogContent> editor.destroy();
<DialogHeader>
<DialogTitle>Upload Signed Consent Form</DialogTitle> await downloadPdfFromHtml(htmlContent, {
<DialogDescription> filename: `Consent_${participantCode}_${activeForm.version}.pdf`,
Upload the signed PDF or image of the consent form for this participant. });
{activeForm && (
<span className="block mt-1 font-medium text-foreground"> toast.success("Document Downloaded", { id: "pdf-gen" });
Active Form: {activeForm.title} (v{activeForm.version}) } catch (e) {
</span> toast.error("Error generating customized PDF", { id: "pdf-gen" });
)} console.error(e);
</DialogDescription> }
</DialogHeader> };
{activeForm ? (
<ConsentUploadForm return (
studyId={studyId} <div className="bg-card text-card-foreground rounded-lg border shadow-sm">
participantId={participantId} <div className="flex flex-row items-center justify-between space-y-0 p-6 pb-2">
consentFormId={activeForm.id} <div className="flex flex-col space-y-1.5">
onSuccess={handleSuccess} <h3 className="flex items-center gap-2 leading-none font-semibold tracking-tight">
onCancel={() => setIsOpen(false)} <FileText className="h-5 w-5" />
/> Consent Status
) : ( </h3>
<div className="py-4 text-center text-muted-foreground"> <p className="text-muted-foreground text-sm">
No active consent form found for this study. Please create one first. Manage participant consent and forms.
</div> </p>
)}
</DialogContent>
</Dialog>
</div>
</div>
</div>
</div> </div>
); <Badge variant={consentGiven ? "default" : "destructive"}>
{consentGiven ? "Consent Given" : "Not Recorded"}
</Badge>
</div>
<div className="p-6 pt-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
{consentGiven ? (
<>
<div className="flex items-center gap-2 text-sm font-medium">
<CheckCircle className="h-4 w-4 text-green-600" />
Signed on{" "}
{consentDate
? new Date(consentDate).toLocaleDateString()
: "Unknown date"}
</div>
{existingConsent && (
<p className="text-muted-foreground text-xs">
Form: {existingConsent.consentForm.title} (v
{existingConsent.consentForm.version})
</p>
)}
</>
) : (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<AlertCircle className="h-4 w-4" />
No consent recorded for this participant.
</div>
)}
</div>
<div className="flex gap-2">
{consentGiven && existingConsent?.storagePath && (
<Button variant="outline" size="sm" onClick={handleDownload}>
<Download className="mr-2 h-4 w-4" />
Download PDF
</Button>
)}
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
size="sm"
variant={consentGiven ? "secondary" : "default"}
>
<Upload className="mr-2 h-4 w-4" />
{consentGiven ? "Update Consent" : "Upload Consent"}
</Button>
</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>
<DialogHeader>
<DialogTitle>Upload Signed Consent Form</DialogTitle>
<DialogDescription>
Upload the signed PDF or image of the consent form for this
participant.
{activeForm && (
<span className="text-foreground mt-1 block font-medium">
Active Form: {activeForm.title} (v{activeForm.version})
</span>
)}
</DialogDescription>
</DialogHeader>
{activeForm ? (
<ConsentUploadForm
studyId={studyId}
participantId={participantId}
consentFormId={activeForm.id}
onSuccess={handleSuccess}
onCancel={() => setIsOpen(false)}
/>
) : (
<div className="text-muted-foreground py-4 text-center">
No active consent form found for this study. Please create
one first.
</div>
)}
</DialogContent>
</Dialog>
</div>
</div>
</div>
</div>
);
} }
+73 -43
View File
@@ -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,44 +337,46 @@ 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">
<FormField> {!contextStudyId && (
<Label htmlFor="studyId" id="tour-participant-study-label">Study *</Label> <FormField>
<div id="tour-participant-study-container"> <Label htmlFor="studyId" id="tour-participant-study-label">
<Select Study *
value={form.watch("studyId")} </Label>
onValueChange={(value) => form.setValue("studyId", value)} <div id="tour-participant-study-container">
disabled={studiesLoading || mode === "edit"} <Select
> value={form.watch("studyId")}
<SelectTrigger onValueChange={(value) => form.setValue("studyId", value)}
className={ disabled={studiesLoading || mode === "edit"}
form.formState.errors.studyId ? "border-red-500" : ""
}
> >
<SelectValue <SelectTrigger
placeholder={ className={
studiesLoading ? "Loading..." : "Select study" form.formState.errors.studyId ? "border-red-500" : ""
} }
/> >
</SelectTrigger> <SelectValue
<SelectContent> placeholder={studiesLoading ? "Loading..." : "Select study"}
{studiesData?.studies?.map((study) => ( />
<SelectItem key={study.id} value={study.id}> </SelectTrigger>
{study.name} <SelectContent>
</SelectItem> {studiesData?.studies?.map((study) => (
))} <SelectItem key={study.id} value={study.id}>
</SelectContent> {study.name}
</Select> </SelectItem>
{form.formState.errors.studyId && ( ))}
<p className="text-sm text-red-600"> </SelectContent>
{form.formState.errors.studyId.message} </Select>
</p> {form.formState.errors.studyId && (
)} <p className="text-sm text-red-600">
</div> {form.formState.errors.studyId.message}
</FormField> </p>
)}
</div>
</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>
); );
}, },
}, },
+20 -7
View File
@@ -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">
+28 -15
View File
@@ -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";
@@ -14,12 +21,12 @@ import { Card, CardContent } from "~/components/ui/card";
import { Checkbox } from "~/components/ui/checkbox"; import { Checkbox } from "~/components/ui/checkbox";
import { DataTable } from "~/components/ui/data-table"; import { DataTable } from "~/components/ui/data-table";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
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}
+11 -10
View File
@@ -1,15 +1,16 @@
"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";
import { import {
Card, Card,
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>
+16 -11
View File
@@ -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>
} }
+6 -6
View File
@@ -4,10 +4,10 @@ import { Monitor, Moon, Sun } from "lucide-react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { 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>
+41 -35
View File
@@ -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(() => {
@@ -213,33 +217,33 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
{ label: "Studies", href: "/studies" }, { label: "Studies", href: "/studies" },
...(contextStudyId ...(contextStudyId
? [ ? [
{ {
label: "Study", label: "Study",
href: `/studies/${contextStudyId}`, href: `/studies/${contextStudyId}`,
}, },
{ label: "Trials", href: `/studies/${contextStudyId}/trials` }, { label: "Trials", href: `/studies/${contextStudyId}/trials` },
...(mode === "edit" && trial ...(mode === "edit" && trial
? [ ? [
{ {
label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`, label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
href: `/studies/${contextStudyId}/trials/${trial.id}`, href: `/studies/${contextStudyId}/trials/${trial.id}`,
}, },
{ label: "Edit" }, { label: "Edit" },
] ]
: [{ label: "New Trial" }]), : [{ label: "New Trial" }]),
] ]
: [ : [
{ label: "Trials", href: `/studies/${contextStudyId}/trials` }, { label: "Trials", href: `/studies/${contextStudyId}/trials` },
...(mode === "edit" && trial ...(mode === "edit" && trial
? [ ? [
{ {
label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`, label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
href: `/studies/${contextStudyId}/trials/${trial.id}`, href: `/studies/${contextStudyId}/trials/${trial.id}`,
}, },
{ label: "Edit" }, { label: "Edit" },
] ]
: [{ label: "New Trial" }]), : [{ label: "New Trial" }]),
]), ]),
]; ];
useBreadcrumbsEffect(breadcrumbs); useBreadcrumbsEffect(breadcrumbs);
@@ -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>
+24 -15
View File
@@ -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>
+180 -130
View File
@@ -3,149 +3,199 @@
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 {
id: string; id: string;
trialId: string; trialId: string;
eventType: string; eventType: string;
timestamp: Date | string; timestamp: Date | string;
data: any; data: any;
createdBy: string | null; createdBy: string | null;
} }
// Helper to format timestamp relative to start // Helper to format timestamp relative to start
function formatRelativeTime(timestamp: Date | string, startTime?: Date) { function formatRelativeTime(timestamp: Date | string, startTime?: Date) {
if (!startTime) return "--:--"; if (!startTime) return "--:--";
const date = new Date(timestamp); const date = new Date(timestamp);
const diff = date.getTime() - startTime.getTime(); const diff = date.getTime() - startTime.getTime();
if (diff < 0) return "0:00"; if (diff < 0) return "0:00";
const totalSeconds = Math.floor(diff / 1000); const totalSeconds = Math.floor(diff / 1000);
const m = Math.floor(totalSeconds / 60); const m = Math.floor(totalSeconds / 60);
const s = Math.floor(totalSeconds % 60); const s = Math.floor(totalSeconds % 60);
// Optional: extended formatting for longer durations // Optional: extended formatting for longer durations
const h = Math.floor(m / 60); const h = Math.floor(m / 60);
if (h > 0) { if (h > 0) {
return `${h}:${(m % 60).toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; return `${h}:${(m % 60).toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
} }
return `${m}:${s.toString().padStart(2, "0")}`; return `${m}:${s.toString().padStart(2, "0")}`;
} }
export const eventsColumns = (startTime?: Date): ColumnDef<TrialEvent>[] => [ export const eventsColumns = (startTime?: Date): ColumnDef<TrialEvent>[] => [
{ {
id: "timestamp", id: "timestamp",
header: "Time", header: "Time",
accessorKey: "timestamp", accessorKey: "timestamp",
size: 90, size: 90,
meta: { meta: {
style: { width: '90px', minWidth: '90px' } style: { width: "90px", minWidth: "90px" },
},
cell: ({ row }) => {
const date = new Date(row.original.timestamp);
return (
<div className="flex flex-col py-0.5">
<span className="font-mono font-medium text-xs">
{formatRelativeTime(row.original.timestamp, startTime)}
</span>
<span className="text-[10px] text-muted-foreground hidden group-hover:block">
{date.toLocaleTimeString()}
</span>
</div>
);
},
}, },
{ cell: ({ row }) => {
accessorKey: "eventType", const date = new Date(row.original.timestamp);
header: "Event Type", return (
size: 160, <div className="flex flex-col py-0.5">
meta: { <span className="font-mono text-xs font-medium">
style: { width: '160px', minWidth: '160px' } {formatRelativeTime(row.original.timestamp, startTime)}
}, </span>
cell: ({ row }) => { <span className="text-muted-foreground hidden text-[10px] group-hover:block">
const type = row.getValue("eventType") as string; {date.toLocaleTimeString()}
const isError = type.includes("error"); </span>
const isIntervention = type.includes("intervention"); </div>
const isRobot = type.includes("robot"); );
const isStep = type.includes("step");
const isObservation = type.includes("annotation") || type.includes("note");
const isJump = type.includes("jump"); // intervention_step_jump
const isActionComplete = type.includes("marked_complete");
let Icon = Activity;
if (isError) Icon = AlertTriangle;
else if (isIntervention || isJump) Icon = User; // Jumps are interventions
else if (isRobot) Icon = Bot;
else if (isStep) Icon = Flag;
else if (isObservation) Icon = MessageSquare;
else if (type.includes("completed") || isActionComplete) Icon = CheckCircle;
return (
<div className="flex items-center py-0.5">
<Badge variant="outline" className={cn(
"capitalize font-medium flex w-fit items-center gap-1.5 px-2 py-0.5 text-[10px]",
isError && "border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400",
(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",
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" />
{type.replace(/_/g, " ")}
</Badge>
</div>
);
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id));
},
}, },
{ },
accessorKey: "data", {
header: "Details", accessorKey: "eventType",
cell: ({ row }) => { header: "Event Type",
const data = row.original.data; size: 160,
const type = row.getValue("eventType") as string; meta: {
style: { width: "160px", minWidth: "160px" },
// Wrapper for density and alignment
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<div className="py-0.5 min-w-[300px] whitespace-normal break-words text-xs leading-normal">
{children}
</div>
);
if (!data || Object.keys(data).length === 0) return <Wrapper><span className="text-muted-foreground">-</span></Wrapper>;
// Smart Formatting
if (type.includes("jump")) {
return (
<Wrapper>
Jumped to step <strong>{data.stepName || (data.toIndex !== undefined ? data.toIndex + 1 : "?")}</strong>
<span className="text-muted-foreground ml-1">(Manual)</span>
</Wrapper>
);
}
if (type.includes("skipped")) {
return <Wrapper><span className="text-orange-600 dark:text-orange-400">Skipped: {data.actionId}</span></Wrapper>;
}
if (type.includes("marked_complete")) {
return <Wrapper><span className="text-green-600 dark:text-green-400">Manually marked complete</span></Wrapper>;
}
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>
<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">
{JSON.stringify(data).replace(/[{""}]/g, " ").trim()}
</code>
</Wrapper>
);
},
}, },
cell: ({ row }) => {
const type = row.getValue("eventType") as string;
const isError = type.includes("error");
const isIntervention = type.includes("intervention");
const isRobot = type.includes("robot");
const isStep = type.includes("step");
const isObservation =
type.includes("annotation") || type.includes("note");
const isJump = type.includes("jump"); // intervention_step_jump
const isActionComplete = type.includes("marked_complete");
let Icon = Activity;
if (isError) Icon = AlertTriangle;
else if (isIntervention || isJump)
Icon = User; // Jumps are interventions
else if (isRobot) Icon = Bot;
else if (isStep) Icon = Flag;
else if (isObservation) Icon = MessageSquare;
else if (type.includes("completed") || isActionComplete)
Icon = CheckCircle;
return (
<div className="flex items-center py-0.5">
<Badge
variant="outline"
className={cn(
"flex w-fit items-center gap-1.5 px-2 py-0.5 text-[10px] font-medium capitalize",
isError &&
"border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400",
(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",
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" />
{type.replace(/_/g, " ")}
</Badge>
</div>
);
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id));
},
},
{
accessorKey: "data",
header: "Details",
cell: ({ row }) => {
const data = row.original.data;
const type = row.getValue("eventType") as string;
// Wrapper for density and alignment
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<div className="min-w-[300px] py-0.5 text-xs leading-normal break-words whitespace-normal">
{children}
</div>
);
if (!data || Object.keys(data).length === 0)
return (
<Wrapper>
<span className="text-muted-foreground">-</span>
</Wrapper>
);
// Smart Formatting
if (type.includes("jump")) {
return (
<Wrapper>
Jumped to step{" "}
<strong>
{data.stepName ||
(data.toIndex !== undefined ? data.toIndex + 1 : "?")}
</strong>
<span className="text-muted-foreground ml-1">(Manual)</span>
</Wrapper>
);
}
if (type.includes("skipped")) {
return (
<Wrapper>
<span className="text-orange-600 dark:text-orange-400">
Skipped: {data.actionId}
</span>
</Wrapper>
);
}
if (type.includes("marked_complete")) {
return (
<Wrapper>
<span className="text-green-600 dark:text-green-400">
Manually marked complete
</span>
</Wrapper>
);
}
if (type.includes("annotation") || type.includes("note")) {
return (
<Wrapper>
<span className="text-foreground/80 italic">
{data.description || data.note || data.message || "No content"}
</span>
</Wrapper>
);
}
return (
<Wrapper>
<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()}
</code>
</Wrapper>
);
},
},
]; ];
@@ -2,295 +2,405 @@
import * as React from "react"; import * as React from "react";
import { import {
Table, Table,
TableBody, TableBody,
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 {
CheckCircle, CheckCircle,
AlertTriangle, AlertTriangle,
Bot, Bot,
User, User,
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";
interface EventsDataTableProps { interface EventsDataTableProps {
data: TrialEvent[]; data: TrialEvent[];
startTime?: Date; startTime?: Date;
} }
// Helper to format timestamp relative to start // Helper to format timestamp relative to start
function formatRelativeTime(timestamp: Date | string, startTime?: Date) { function formatRelativeTime(timestamp: Date | string, startTime?: Date) {
if (!startTime) return "--:--"; if (!startTime) return "--:--";
const date = new Date(timestamp); const date = new Date(timestamp);
const diff = date.getTime() - startTime.getTime(); const diff = date.getTime() - startTime.getTime();
if (diff < 0) return "0:00"; if (diff < 0) return "0:00";
const totalSeconds = Math.floor(diff / 1000); const totalSeconds = Math.floor(diff / 1000);
const m = Math.floor(totalSeconds / 60); const m = Math.floor(totalSeconds / 60);
const s = Math.floor(totalSeconds % 60); const s = Math.floor(totalSeconds % 60);
// Optional: extended formatting for longer durations // Optional: extended formatting for longer durations
const h = Math.floor(m / 60); const h = Math.floor(m / 60);
if (h > 0) { if (h > 0) {
return `${h}:${(m % 60).toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; return `${h}:${(m % 60).toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
} }
return `${m}:${s.toString().padStart(2, "0")}`; return `${m}:${s.toString().padStart(2, "0")}`;
} }
export function EventsDataTable({ data, startTime }: EventsDataTableProps) { export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
const { seekTo, events, currentEventIndex } = usePlayback(); const { seekTo, events, currentEventIndex } = usePlayback();
const [eventTypeFilter, setEventTypeFilter] = React.useState<string>("all"); const [eventTypeFilter, setEventTypeFilter] = React.useState<string>("all");
const [globalFilter, setGlobalFilter] = React.useState<string>(""); const [globalFilter, setGlobalFilter] = React.useState<string>("");
// 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 (
return false; eventTypeFilter !== "all" &&
} !event.eventType.includes(eventTypeFilter)
) {
return false;
}
// Global text search (checks type and data) // Global text search (checks type and data)
if (globalFilter) { if (globalFilter) {
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
const dataMatch = dataString.includes(searchLower); ? JSON.stringify(event.data).toLowerCase()
: "";
const dataMatch = dataString.includes(searchLower);
return typeMatch || dataMatch; return typeMatch || dataMatch;
} }
return true; return true;
}); });
}, [data, eventTypeFilter, globalFilter]); }, [data, eventTypeFilter, globalFilter]);
// Active Event Logic & Auto-scroll // Active Event Logic & Auto-scroll
// Match filtered events with global playback "active event" via ID // Match filtered events with global playback "active event" via ID
const activeEventId = React.useMemo(() => { const activeEventId = React.useMemo(() => {
if (currentEventIndex >= 0 && currentEventIndex < events.length) { if (currentEventIndex >= 0 && currentEventIndex < events.length) {
// We need to match the type of ID used in data/events // We need to match the type of ID used in data/events
// Assuming events from context are TrialEvent compatible // Assuming events from context are TrialEvent compatible
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const evt = events[currentEventIndex] as any; const evt = events[currentEventIndex] as any;
return evt?.id; return evt?.id;
} }
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]) {
rowRefs.current[activeEventId]?.scrollIntoView({ rowRefs.current[activeEventId]?.scrollIntoView({
behavior: "smooth", behavior: "smooth",
block: "center", block: "center",
}); });
} }
}, [activeEventId]); }, [activeEventId]);
const handleRowClick = (event: TrialEvent) => { const handleRowClick = (event: TrialEvent) => {
if (!startTime) return; if (!startTime) return;
const timeMs = new Date(event.timestamp).getTime(); const timeMs = new Date(event.timestamp).getTime();
const seekSeconds = (timeMs - startTime.getTime()) / 1000; const seekSeconds = (timeMs - startTime.getTime()) / 1000;
seekTo(Math.max(0, seekSeconds)); seekTo(Math.max(0, seekSeconds));
}; };
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex flex-1 items-center space-x-2"> <div className="flex flex-1 items-center space-x-2">
<Input <Input
id="tour-analytics-filter" id="tour-analytics-filter"
placeholder="Search event data..." placeholder="Search event data..."
value={globalFilter} value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)} onChange={(e) => setGlobalFilter(e.target.value)}
className="h-8 w-[150px] lg:w-[250px]" className="h-8 w-[150px] lg:w-[250px]"
/> />
<Select value={eventTypeFilter} onValueChange={setEventTypeFilter}> <Select value={eventTypeFilter} onValueChange={setEventTypeFilter}>
<SelectTrigger className="h-8 w-[160px]"> <SelectTrigger className="h-8 w-[160px]">
<SelectValue placeholder="All Events" /> <SelectValue placeholder="All Events" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">All Events</SelectItem> <SelectItem value="all">All Events</SelectItem>
<SelectItem value="action_executed">Actions</SelectItem> <SelectItem value="action_executed">Actions</SelectItem>
<SelectItem value="action_skipped">Skipped Actions</SelectItem> <SelectItem value="action_skipped">Skipped Actions</SelectItem>
<SelectItem value="intervention">Interventions</SelectItem> <SelectItem value="intervention">Interventions</SelectItem>
<SelectItem value="robot">Robot Actions</SelectItem> <SelectItem value="robot">Robot Actions</SelectItem>
<SelectItem value="step">Step Changes</SelectItem> <SelectItem value="step">Step Changes</SelectItem>
<SelectItem value="error">Errors</SelectItem> <SelectItem value="error">Errors</SelectItem>
<SelectItem value="annotation">Notes</SelectItem> <SelectItem value="annotation">Notes</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div>
<div className="text-xs text-muted-foreground mr-2">
{filteredData.length} events
</div>
</div>
<div id="tour-analytics-table" className="rounded-md border bg-background">
<div>
<Table className="w-full">
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
<TableRow className="bg-muted/50 hover:bg-muted/50">
<TableHead className="w-[100px]">Time</TableHead>
<TableHead className="w-[180px]">Event Type</TableHead>
<TableHead className="w-auto">Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredData.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
) : (
filteredData.map((event, index) => {
const type = event.eventType;
const data = event.data;
// Type Logic
const isError = type.includes("error");
const isIntervention = type.includes("intervention");
const isRobot = type.includes("robot");
const isStep = type.includes("step");
const isObservation = type.includes("annotation") || type.includes("note");
const isJump = type.includes("jump");
const isActionComplete = type.includes("marked_complete");
const isCamera = type.includes("camera");
let Icon = Activity;
if (isError) Icon = AlertTriangle;
else if (isIntervention || isJump) Icon = User;
else if (isRobot) Icon = Bot;
else if (isStep) Icon = Flag;
else if (isObservation) Icon = MessageSquare;
else if (isCamera) Icon = Video;
else if (type.includes("completed") || isActionComplete) Icon = CheckCircle;
// Details Logic
let detailsContent;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = data as any; // Cast for easier access
if (type.includes("jump")) {
detailsContent = (
<>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")) {
detailsContent = <span className="text-orange-600 dark:text-orange-400">Skipped: {d?.actionId}</span>;
} else if (type.includes("marked_complete")) {
detailsContent = <span className="text-green-600 dark:text-green-400">Manually marked complete</span>;
} else if (type.includes("annotation") || type.includes("note")) {
detailsContent = <span className="italic text-foreground/80">{d?.description || d?.note || d?.message || "No content"}</span>;
} else if (type.includes("step")) {
detailsContent = <span>Step: <strong>{d?.stepName || d?.name || (d?.index !== undefined ? `Index ${d.index}` : "")}</strong></span>;
} else if (type.includes("action_executed")) {
const name = d?.actionName || d?.actionId;
const meta = d?.actionType ? `(${d.actionType})` : d?.type ? `(${d.type})` : "";
detailsContent = <span>Executed: <strong>{name}</strong> <span className="text-muted-foreground text-[10px] ml-1">{meta}</span></span>;
} else if (type.includes("robot") || type.includes("say") || type.includes("speech")) {
const text = d?.text || d?.message || d?.data?.text;
detailsContent = (
<span>
Robot: <strong>{d?.command || d?.type || "Action"}</strong>
{text && <span className="text-muted-foreground ml-1">"{text}"</span>}
</span>
);
} else if (type.includes("intervention")) {
detailsContent = <span className="text-orange-600 dark:text-orange-400">Intervention: {d?.type || "Manual Action"}</span>;
} else if (type === "trial_started") {
detailsContent = <span className="text-green-600 font-medium">Trial Started</span>;
} else if (type === "trial_completed") {
detailsContent = <span className="text-blue-600 font-medium">Trial Completed</span>;
} else if (type === "trial_paused") {
detailsContent = <span className="text-yellow-600 font-medium">Trial Paused</span>;
} 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>;
} else {
// Default
if (d && Object.keys(d).length > 0) {
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]">
{JSON.stringify(d).replace(/[{"}]/g, " ").trim()}
</code>
);
} else {
detailsContent = <span className="text-muted-foreground text-xs">-</span>;
}
}
const isActive = activeEventId === event.id;
return (
<TableRow
key={event.id || index}
ref={(el) => {
if (event.id) rowRefs.current[event.id] = el;
}}
className={cn(
"cursor-pointer h-auto border-l-2 border-transparent transition-colors",
isActive
? "bg-muted border-l-primary"
: "hover:bg-muted/50"
)}
onClick={() => handleRowClick(event)}
>
<TableCell className="py-1 align-top w-[100px]">
<div className="flex flex-col">
<span className="font-mono font-medium text-xs">
{formatRelativeTime(event.timestamp, startTime)}
</span>
<span className="text-[10px] text-muted-foreground hidden group-hover:block">
{new Date(event.timestamp).toLocaleTimeString()}
</span>
</div>
</TableCell>
<TableCell className="py-1 align-top w-[180px]">
<div className="flex items-center">
<Badge variant="outline" className={cn(
"capitalize font-medium flex w-fit items-center gap-1.5 px-2 py-0.5 text-[10px]",
isError && "border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400",
(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",
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" />
{type.replace(/_/g, " ")}
</Badge>
</div>
</TableCell>
<TableCell className="py-1 align-top w-auto">
<div className="text-xs break-words whitespace-normal leading-normal min-w-0">
{detailsContent}
</div>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</div>
</div> </div>
); <div className="text-muted-foreground mr-2 text-xs">
{filteredData.length} events
</div>
</div>
<div
id="tour-analytics-table"
className="bg-background rounded-md border"
>
<div>
<Table className="w-full">
<TableHeader className="bg-background sticky top-0 z-10 shadow-sm">
<TableRow className="bg-muted/50 hover:bg-muted/50">
<TableHead className="w-[100px]">Time</TableHead>
<TableHead className="w-[180px]">Event Type</TableHead>
<TableHead className="w-auto">Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredData.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
) : (
filteredData.map((event, index) => {
const type = event.eventType;
const data = event.data;
// Type Logic
const isError = type.includes("error");
const isIntervention = type.includes("intervention");
const isRobot = type.includes("robot");
const isStep = type.includes("step");
const isObservation =
type.includes("annotation") || type.includes("note");
const isJump = type.includes("jump");
const isActionComplete = type.includes("marked_complete");
const isCamera = type.includes("camera");
let Icon = Activity;
if (isError) Icon = AlertTriangle;
else if (isIntervention || isJump) Icon = User;
else if (isRobot) Icon = Bot;
else if (isStep) Icon = Flag;
else if (isObservation) Icon = MessageSquare;
else if (isCamera) Icon = Video;
else if (type.includes("completed") || isActionComplete)
Icon = CheckCircle;
// Details Logic
let detailsContent;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = data as any; // Cast for easier access
if (type.includes("jump")) {
detailsContent = (
<>
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")) {
detailsContent = (
<span className="text-orange-600 dark:text-orange-400">
Skipped: {d?.actionId}
</span>
);
} else if (type.includes("marked_complete")) {
detailsContent = (
<span className="text-green-600 dark:text-green-400">
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")) {
detailsContent = (
<span>
Step:{" "}
<strong>
{d?.stepName ||
d?.name ||
(d?.index !== undefined ? `Index ${d.index}` : "")}
</strong>
</span>
);
} else if (type.includes("action_executed")) {
const name = d?.actionName || d?.actionId;
const meta = d?.actionType
? `(${d.actionType})`
: 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;
detailsContent = (
<span>
Robot:{" "}
<strong>{d?.command || d?.type || "Action"}</strong>
{text && (
<span className="text-muted-foreground ml-1">
"{text}"
</span>
)}
</span>
);
} else if (type.includes("intervention")) {
detailsContent = (
<span className="text-orange-600 dark:text-orange-400">
Intervention: {d?.type || "Manual Action"}
</span>
);
} else if (type === "trial_started") {
detailsContent = (
<span className="font-medium text-green-600">
Trial Started
</span>
);
} else if (type === "trial_completed") {
detailsContent = (
<span className="font-medium text-blue-600">
Trial Completed
</span>
);
} else if (type === "trial_paused") {
detailsContent = (
<span className="font-medium text-yellow-600">
Trial Paused
</span>
);
} 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>
);
} else {
// Default
if (d && Object.keys(d).length > 0) {
detailsContent = (
<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()}
</code>
);
} else {
detailsContent = (
<span className="text-muted-foreground text-xs">-</span>
);
}
}
const isActive = activeEventId === event.id;
return (
<TableRow
key={event.id || index}
ref={(el) => {
if (event.id) rowRefs.current[event.id] = el;
}}
className={cn(
"h-auto cursor-pointer border-l-2 border-transparent transition-colors",
isActive
? "bg-muted border-l-primary"
: "hover:bg-muted/50",
)}
onClick={() => handleRowClick(event)}
>
<TableCell className="w-[100px] py-1 align-top">
<div className="flex flex-col">
<span className="font-mono text-xs font-medium">
{formatRelativeTime(event.timestamp, startTime)}
</span>
<span className="text-muted-foreground hidden text-[10px] group-hover:block">
{new Date(event.timestamp).toLocaleTimeString()}
</span>
</div>
</TableCell>
<TableCell className="w-[180px] py-1 align-top">
<div className="flex items-center">
<Badge
variant="outline"
className={cn(
"flex w-fit items-center gap-1.5 px-2 py-0.5 text-[10px] font-medium capitalize",
isError &&
"border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400",
(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",
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" />
{type.replace(/_/g, " ")}
</Badge>
</div>
</TableCell>
<TableCell className="w-auto py-1 align-top">
<div className="min-w-0 text-xs leading-normal break-words whitespace-normal">
{detailsContent}
</div>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</div>
</div>
);
} }
+225 -184
View File
@@ -4,198 +4,239 @@ import React, { useMemo, useRef, useState } from "react";
import { usePlayback } from "./PlaybackContext"; import { usePlayback } from "./PlaybackContext";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { import {
AlertTriangle, AlertTriangle,
CheckCircle, CheckCircle,
Flag, Flag,
MessageSquare, MessageSquare,
Zap, Zap,
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) {
const min = Math.floor(seconds / 60); const min = Math.floor(seconds / 60);
const sec = Math.floor(seconds % 60); const sec = Math.floor(seconds % 60);
return `${min}:${sec.toString().padStart(2, "0")}`; return `${min}:${sec.toString().padStart(2, "0")}`;
} }
export function EventTimeline() { export function EventTimeline() {
const { const {
duration, duration,
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(
}, [events]); (a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
const startTime = useMemo(() => {
if (contextStartTime) return new Date(contextStartTime).getTime();
return 0;
}, [contextStartTime]);
const effectiveDuration = useMemo(() => {
if (duration > 0) return duration * 1000;
return 60000; // 1 min default
}, [duration]);
// Dimensions
const containerRef = useRef<HTMLDivElement>(null);
// Helpers
const getPercentage = (timestampMs: number) => {
const offset = timestampMs - startTime;
return Math.max(0, Math.min(100, (offset / effectiveDuration) * 100));
};
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const pct = Math.max(0, Math.min(1, x / rect.width));
seekTo(pct * (effectiveDuration / 1000));
};
const currentProgress = (currentTime * 1000 / effectiveDuration) * 100;
// Generate ticks
const ticks = useMemo(() => {
const count = 10;
return Array.from({ length: count + 1 }).map((_, i) => ({
pct: (i / count) * 100,
label: formatTime((effectiveDuration / 1000) * (i / count))
}));
}, [effectiveDuration]);
const getEventIcon = (type: string) => {
if (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("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("error")) return <AlertTriangle className="h-4 w-4" />;
return <Activity className="h-4 w-4" />;
};
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 (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";
return "bg-slate-100 text-slate-600 border-slate-200";
};
return (
<div className="w-full h-28 flex flex-col justify-center px-8 select-none">
<TooltipProvider delayDuration={0}>
{/* Main Interactive Area */}
<div
ref={containerRef}
className="relative w-full h-16 flex items-center cursor-pointer group"
onClick={handleSeek}
>
{/* 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" />
{/* Progress Fill */}
<div
className="absolute left-0 h-0.5 bg-primary/30 pointer-events-none"
style={{ width: `${currentProgress}%`, top: '50%', marginTop: '-1px' }}
/>
{/* Playhead (Scanner) */}
<div
className="absolute h-16 w-px bg-red-500 z-30 pointer-events-none transition-all duration-75"
style={{ left: `${currentProgress}%`, top: '50%', transform: 'translateY(-50%)' }}
>
{/* 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>
{/* Events (Avatars/Dots) */}
{sortedEvents.map((event, i) => {
const pct = getPercentage(new Date(event.timestamp).getTime());
// Smart Formatting Logic
const details = (() => {
const { eventType, data } = event;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = data as any;
if (eventType.includes("jump")) return `Jumped to step ${d?.stepName || d?.toIndex + 1 || "?"} (Manual)`;
if (eventType.includes("skipped")) 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;
return JSON.stringify(d).slice(0, 100).replace(/[{""}]/g, " ").trim();
})();
return (
<Tooltip key={i}>
<TooltipTrigger asChild>
<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"
style={{ left: `${pct}%` }}
onClick={(e) => {
e.stopPropagation();
// startTime is in ms, timestamp is Date string or obj
const timeMs = new Date(event.timestamp).getTime();
const seekSeconds = (timeMs - startTime) / 1000;
seekTo(Math.max(0, seekSeconds));
}}
>
<div className={cn(
"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",
getEventColor(event.eventType)
)}>
{getEventIcon(event.eventType)}
</div>
</div>
</TooltipTrigger>
<TooltipContent side="top">
<div className="text-xs font-semibold uppercase tracking-wider mb-0.5">{event.eventType.replace(/_/g, " ")}</div>
<div className="text-[10px] font-mono opacity-70 mb-1">
{new Date(event.timestamp).toLocaleTimeString()}
</div>
{!!details && (
<div className="bg-muted/50 p-1.5 rounded text-[10px] max-w-[220px] break-words whitespace-normal border">
{details}
</div>
)}
</TooltipContent>
</Tooltip>
);
})}
{/* Ticks (Below) */}
{ticks.map((tick, i) => (
<div
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"
style={{ left: `${tick.pct}%` }}
>
{/* Tick Mark */}
<div className="w-px h-2 bg-border mb-1" />
{tick.label}
</div>
))}
</div>
</TooltipProvider>
</div>
); );
}, [events]);
const startTime = useMemo(() => {
if (contextStartTime) return new Date(contextStartTime).getTime();
return 0;
}, [contextStartTime]);
const effectiveDuration = useMemo(() => {
if (duration > 0) return duration * 1000;
return 60000; // 1 min default
}, [duration]);
// Dimensions
const containerRef = useRef<HTMLDivElement>(null);
// Helpers
const getPercentage = (timestampMs: number) => {
const offset = timestampMs - startTime;
return Math.max(0, Math.min(100, (offset / effectiveDuration) * 100));
};
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const pct = Math.max(0, Math.min(1, x / rect.width));
seekTo(pct * (effectiveDuration / 1000));
};
const currentProgress = ((currentTime * 1000) / effectiveDuration) * 100;
// Generate ticks
const ticks = useMemo(() => {
const count = 10;
return Array.from({ length: count + 1 }).map((_, i) => ({
pct: (i / count) * 100,
label: formatTime((effectiveDuration / 1000) * (i / count)),
}));
}, [effectiveDuration]);
const getEventIcon = (type: string) => {
if (
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("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("error")) return <AlertTriangle className="h-4 w-4" />;
return <Activity className="h-4 w-4" />;
};
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 (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";
return "bg-slate-100 text-slate-600 border-slate-200";
};
return (
<div className="flex h-28 w-full flex-col justify-center px-8 select-none">
<TooltipProvider delayDuration={0}>
{/* Main Interactive Area */}
<div
ref={containerRef}
className="group relative flex h-16 w-full cursor-pointer items-center"
onClick={handleSeek}
>
{/* The Timeline Line (Horizontal) */}
<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 */}
<div
className="bg-primary/30 pointer-events-none absolute left-0 h-0.5"
style={{
width: `${currentProgress}%`,
top: "50%",
marginTop: "-1px",
}}
/>
{/* Playhead (Scanner) */}
<div
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%)",
}}
>
{/* Knob */}
<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>
{/* Events (Avatars/Dots) */}
{sortedEvents.map((event, i) => {
const pct = getPercentage(new Date(event.timestamp).getTime());
// Smart Formatting Logic
const details = (() => {
const { eventType, data } = event;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = data as any;
if (eventType.includes("jump"))
return `Jumped to step ${d?.stepName || d?.toIndex + 1 || "?"} (Manual)`;
if (eventType.includes("skipped"))
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;
return JSON.stringify(d)
.slice(0, 100)
.replace(/[{""}]/g, " ")
.trim();
})();
return (
<Tooltip key={i}>
<TooltipTrigger asChild>
<div
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}%` }}
onClick={(e) => {
e.stopPropagation();
// startTime is in ms, timestamp is Date string or obj
const timeMs = new Date(event.timestamp).getTime();
const seekSeconds = (timeMs - startTime) / 1000;
seekTo(Math.max(0, seekSeconds));
}}
>
<div
className={cn(
"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)}
</div>
</div>
</TooltipTrigger>
<TooltipContent side="top">
<div className="mb-0.5 text-xs font-semibold tracking-wider uppercase">
{event.eventType.replace(/_/g, " ")}
</div>
<div className="mb-1 font-mono text-[10px] opacity-70">
{new Date(event.timestamp).toLocaleTimeString()}
</div>
{!!details && (
<div className="bg-muted/50 max-w-[220px] rounded border p-1.5 text-[10px] break-words whitespace-normal">
{details}
</div>
)}
</TooltipContent>
</Tooltip>
);
})}
{/* Ticks (Below) */}
{ticks.map((tick, i) => (
<div
key={i}
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}%` }}
>
{/* Tick Mark */}
<div className="bg-border mb-1 h-2 w-px" />
{tick.label}
</div>
))}
</div>
</TooltipProvider>
</div>
);
} }
+114 -100
View File
@@ -1,130 +1,144 @@
"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;
timestamp: Date; timestamp: Date;
data?: unknown; data?: unknown;
} }
interface PlaybackContextType { interface PlaybackContextType {
// State // State
currentTime: number; currentTime: number;
duration: number; duration: number;
isPlaying: boolean; isPlaying: boolean;
playbackRate: number; playbackRate: number;
startTime?: Date; startTime?: Date;
endTime?: Date; endTime?: Date;
// Actions // Actions
play: () => void; play: () => void;
pause: () => void; pause: () => void;
togglePlay: () => void; togglePlay: () => void;
seekTo: (time: number) => void; seekTo: (time: number) => void;
setPlaybackRate: (rate: number) => void; setPlaybackRate: (rate: number) => void;
setDuration: (duration: number) => void; setDuration: (duration: number) => void;
setCurrentTime: (time: number) => void; // Used by VideoPlayer to update state setCurrentTime: (time: number) => void; // Used by VideoPlayer to update state
// Data // Data
events: TrialEvent[]; events: TrialEvent[];
currentEventIndex: number; // Index of the last event that happened before currentTime currentEventIndex: number; // Index of the last event that happened before currentTime
} }
const PlaybackContext = createContext<PlaybackContextType | null>(null); const PlaybackContext = createContext<PlaybackContextType | null>(null);
export function usePlayback() { export function usePlayback() {
const context = useContext(PlaybackContext); const context = useContext(PlaybackContext);
if (!context) { if (!context) {
throw new Error("usePlayback must be used within a PlaybackProvider"); throw new Error("usePlayback must be used within a PlaybackProvider");
} }
return context; return context;
} }
interface PlaybackProviderProps { interface PlaybackProviderProps {
children: React.ReactNode; children: React.ReactNode;
events?: TrialEvent[]; events?: TrialEvent[];
startTime?: Date; startTime?: Date;
endTime?: Date; endTime?: Date;
} }
export function PlaybackProvider({ children, events = [], startTime, endTime }: PlaybackProviderProps) { export function PlaybackProvider({
const trialDuration = React.useMemo(() => { children,
if (startTime && endTime) return (new Date(endTime).getTime() - new Date(startTime).getTime()) / 1000; events = [],
return 0; startTime,
}, [startTime, endTime]); endTime,
}: PlaybackProviderProps) {
const trialDuration = React.useMemo(() => {
if (startTime && endTime)
return (
(new Date(endTime).getTime() - new Date(startTime).getTime()) / 1000
);
return 0;
}, [startTime, endTime]);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(trialDuration); const [duration, setDuration] = useState(trialDuration);
useEffect(() => { useEffect(() => {
if (trialDuration > 0 && duration === 0) { if (trialDuration > 0 && duration === 0) {
setDuration(trialDuration); setDuration(trialDuration);
} }
}, [trialDuration, duration]); }, [trialDuration, duration]);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [playbackRate, setPlaybackRate] = useState(1); const [playbackRate, setPlaybackRate] = useState(1);
// Derived state: find the latest event index based on currentTime // Derived state: find the latest event index based on currentTime
const currentEventIndex = React.useMemo(() => { const currentEventIndex = React.useMemo(() => {
if (!startTime || events.length === 0) return -1; if (!startTime || events.length === 0) return -1;
// Find the last event that occurred before or at currentTime // Find the last event that occurred before or at currentTime
// Events are assumed to be sorted by timestamp // Events are assumed to be sorted by timestamp
// Using basic iteration for now, optimization possible for large lists // Using basic iteration for now, optimization possible for large lists
let lastIndex = -1; let lastIndex = -1;
for (let i = 0; i < events.length; i++) { for (let i = 0; i < events.length; i++) {
const eventTime = new Date(events[i]!.timestamp).getTime(); const eventTime = new Date(events[i]!.timestamp).getTime();
const startStr = new Date(startTime).getTime(); const startStr = new Date(startTime).getTime();
const relativeSeconds = (eventTime - startStr) / 1000; const relativeSeconds = (eventTime - startStr) / 1000;
if (relativeSeconds <= currentTime) { if (relativeSeconds <= currentTime) {
lastIndex = i; lastIndex = i;
} else { } else {
break; // Events are sorted, so we can stop break; // Events are sorted, so we can stop
} }
} }
return lastIndex; return lastIndex;
}, [currentTime, events, startTime]); }, [currentTime, events, startTime]);
// 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);
// Dispatch seek event to video player via some mechanism if needed, // Dispatch seek event to video player via some mechanism if needed,
// usually VideoPlayer observes this context or we use a Ref to control it. // usually VideoPlayer observes this context or we use a Ref to control it.
// Actually, simple way: Context holds state, VideoPlayer listens to state? // Actually, simple way: Context holds state, VideoPlayer listens to state?
// No, VideoPlayer usually drives time. // No, VideoPlayer usually drives time.
// Let's assume VideoPlayer updates `setCurrentTime` as it plays. // Let's assume VideoPlayer updates `setCurrentTime` as it plays.
// But if *we* seek (e.g. from timeline), we need to tell VideoPlayer to jump. // But if *we* seek (e.g. from timeline), we need to tell VideoPlayer to jump.
// We might need a `seekRequest` timestamp or similar signal. // We might need a `seekRequest` timestamp or similar signal.
}; };
const value: PlaybackContextType = { const value: PlaybackContextType = {
currentTime, currentTime,
duration, duration,
isPlaying, isPlaying,
playbackRate, playbackRate,
play, play,
pause, pause,
togglePlay, togglePlay,
seekTo, seekTo,
setPlaybackRate, setPlaybackRate,
setDuration, setDuration,
setCurrentTime, setCurrentTime,
events, events,
currentEventIndex, currentEventIndex,
startTime, startTime,
endTime, endTime,
}; };
return ( return (
<PlaybackContext.Provider value={value}> <PlaybackContext.Provider value={value}>
{children} {children}
</PlaybackContext.Provider> </PlaybackContext.Provider>
); );
} }
+133 -121
View File
@@ -8,146 +8,158 @@ import { Slider } from "~/components/ui/slider";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
interface PlaybackPlayerProps { interface PlaybackPlayerProps {
src: string; src: string;
} }
export function PlaybackPlayer({ src }: PlaybackPlayerProps) { export function PlaybackPlayer({ src }: PlaybackPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const { const {
currentTime, currentTime,
isPlaying, isPlaying,
playbackRate, playbackRate,
setCurrentTime, setCurrentTime,
setDuration, setDuration,
togglePlay, togglePlay,
play, play,
pause pause,
} = usePlayback(); } = usePlayback();
const [isBuffering, setIsBuffering] = React.useState(true); const [isBuffering, setIsBuffering] = React.useState(true);
const [volume, setVolume] = React.useState(1); const [volume, setVolume] = React.useState(1);
const [muted, setMuted] = React.useState(false); const [muted, setMuted] = React.useState(false);
// Sync Play/Pause state // Sync Play/Pause state
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
if (!video) return; if (!video) return;
if (isPlaying && video.paused) { if (isPlaying && video.paused) {
video.play().catch(console.error); video.play().catch(console.error);
} else if (!isPlaying && !video.paused) { } else if (!isPlaying && !video.paused) {
video.pause(); video.pause();
} }
}, [isPlaying]); }, [isPlaying]);
// Sync Playback Rate // Sync Playback Rate
useEffect(() => { useEffect(() => {
if (videoRef.current) { if (videoRef.current) {
videoRef.current.playbackRate = playbackRate; videoRef.current.playbackRate = playbackRate;
} }
}, [playbackRate]); }, [playbackRate]);
// Sync Seek (External seek request) // Sync Seek (External seek request)
// Note: This is tricky because normal playback also updates currentTime. // Note: This is tricky because normal playback also updates currentTime.
// We need to differentiate between "time updated by video" and "time updated by user seek". // We need to differentiate between "time updated by video" and "time updated by user seek".
// For now, we'll let the video drive the context time, and rely on the Parent/Context // For now, we'll let the video drive the context time, and rely on the Parent/Context
// to call a imperative sync if needed, or we implement a "seekRequest" signal in context. // to call a imperative sync if needed, or we implement a "seekRequest" signal in context.
// simpler: If context time differs significantly from video time, we seek. // simpler: If context time differs significantly from video time, we seek.
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
if (!video) return; if (!video) return;
if (Math.abs(video.currentTime - currentTime) > 0.5) { if (Math.abs(video.currentTime - currentTime) > 0.5) {
video.currentTime = currentTime; video.currentTime = currentTime;
} }
}, [currentTime]); }, [currentTime]);
const handleTimeUpdate = () => { const handleTimeUpdate = () => {
if (videoRef.current) { if (videoRef.current) {
setCurrentTime(videoRef.current.currentTime); setCurrentTime(videoRef.current.currentTime);
} }
}; };
const handleLoadedMetadata = () => { const handleLoadedMetadata = () => {
if (videoRef.current) { if (videoRef.current) {
setIsBuffering(false); setIsBuffering(false);
} }
}; };
const handleWaiting = () => setIsBuffering(true); const handleWaiting = () => setIsBuffering(true);
const handlePlaying = () => setIsBuffering(false); const handlePlaying = () => setIsBuffering(false);
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}
onPlaying={handlePlaying} onPlaying={handlePlaying}
onEnded={handleEnded} onEnded={handleEnded}
/> />
{/* 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
<div className="flex items-center gap-4"> 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"
<Button data-paused={!isPlaying}
variant="ghost" >
size="icon" <div className="flex items-center gap-4">
className="text-white hover:bg-white/20" <Button
onClick={togglePlay} variant="ghost"
> size="icon"
{isPlaying ? <Pause className="h-6 w-6" /> : <Play className="h-6 w-6 fill-current" />} className="text-white hover:bg-white/20"
</Button> onClick={togglePlay}
>
{isPlaying ? (
<Pause className="h-6 w-6" />
) : (
<Play className="h-6 w-6 fill-current" />
)}
</Button>
<div className="flex-1"> <div className="flex-1">
<Slider <Slider
value={[currentTime]} value={[currentTime]}
min={0} min={0}
max={videoRef.current?.duration || 100} max={videoRef.current?.duration || 100}
step={0.1} step={0.1}
onValueChange={([val]) => { onValueChange={([val]) => {
if (videoRef.current) { if (videoRef.current) {
videoRef.current.currentTime = val ?? 0; videoRef.current.currentTime = val ?? 0;
setCurrentTime(val ?? 0); setCurrentTime(val ?? 0);
} }
}} }}
className="cursor-pointer" className="cursor-pointer"
/> />
</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)} /{" "}
</div> {formatTime(videoRef.current?.duration || 0)}
</div>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
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 ? (
</Button> <VolumeX className="h-5 w-5" />
</div> ) : (
</div> <Volume2 className="h-5 w-5" />
)}
{isBuffering && ( </Button>
<div className="absolute inset-0 flex items-center justify-center bg-black/20 pointer-events-none"> </div>
<Loader2 className="h-10 w-10 animate-spin text-white/80" />
</div>
)}
</AspectRatio>
</div> </div>
);
{isBuffering && (
<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" />
</div>
)}
</AspectRatio>
</div>
);
} }
function formatTime(seconds: number) { function formatTime(seconds: number) {
const m = Math.floor(seconds / 60); const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60); const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`; return `${m}:${s.toString().padStart(2, "0")}`;
} }
@@ -4,200 +4,229 @@ 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;
timestamp: Date; timestamp: Date;
message?: string; message?: string;
data?: unknown; data?: unknown;
} }
interface HorizontalTimelineProps { interface HorizontalTimelineProps {
events: TimelineEvent[]; events: TimelineEvent[];
startTime?: Date; startTime?: Date;
endTime?: Date; endTime?: Date;
} }
export function HorizontalTimeline({ events, startTime, endTime }: HorizontalTimelineProps) { export function HorizontalTimeline({
const [selectedEvent, setSelectedEvent] = useState<TimelineEvent | null>(null); events,
startTime,
if (events.length === 0) { endTime,
return ( }: HorizontalTimelineProps) {
<div className="text-center text-sm text-muted-foreground py-8"> const [selectedEvent, setSelectedEvent] = useState<TimelineEvent | null>(
No events recorded yet null,
</div> );
);
}
// Calculate time range
const timestamps = events.map(e => e.timestamp.getTime());
const minTime = startTime?.getTime() ?? Math.min(...timestamps);
const maxTime = endTime?.getTime() ?? Math.max(...timestamps);
const duration = maxTime - minTime;
// Generate time markers (every 10 seconds or appropriate interval)
const getTimeMarkers = () => {
const markers: Date[] = [];
const interval = duration > 300000 ? 60000 : duration > 60000 ? 30000 : 10000; // 1min, 30s, or 10s intervals
for (let time = minTime; time <= maxTime; time += interval) {
markers.push(new Date(time));
}
if (markers[markers.length - 1]?.getTime() !== maxTime) {
markers.push(new Date(maxTime));
}
return markers;
};
const timeMarkers = getTimeMarkers();
// Get position percentage for a timestamp
const getPosition = (timestamp: Date) => {
if (duration === 0) return 50;
return ((timestamp.getTime() - minTime) / duration) * 100;
};
// Get color and icon for event type
const getEventStyle = (eventType: string) => {
if (eventType.includes("start") || eventType === "trial_started") {
return { color: "bg-blue-500", Icon: Flag };
} else if (eventType.includes("complete") || eventType === "trial_completed") {
return { color: "bg-green-500", Icon: CheckCircle };
} else if (eventType.includes("robot") || eventType.includes("action")) {
return { color: "bg-purple-500", Icon: Bot };
} else if (eventType.includes("wizard") || eventType.includes("intervention")) {
return { color: "bg-orange-500", Icon: User };
} else if (eventType.includes("note") || eventType.includes("annotation")) {
return { color: "bg-yellow-500", Icon: MessageSquare };
} else if (eventType.includes("error") || eventType.includes("issue")) {
return { color: "bg-red-500", Icon: AlertTriangle };
}
return { color: "bg-gray-500", Icon: Activity };
};
if (events.length === 0) {
return ( return (
<div className="space-y-4"> <div className="text-muted-foreground py-8 text-center text-sm">
{/* Timeline visualization */} No events recorded yet
<div className="relative"> </div>
<ScrollArea className="w-full"> );
<div className="min-w-[800px] px-4 py-8"> }
{/* Time markers */}
<div className="relative h-20 mb-8">
{/* Main horizontal line */}
<div className="absolute top-1/2 left-0 right-0 h-0.5 bg-border" style={{ transform: 'translateY(-50%)' }} />
{/* Time labels */} // Calculate time range
{timeMarkers.map((marker, i) => { const timestamps = events.map((e) => e.timestamp.getTime());
const pos = getPosition(marker); const minTime = startTime?.getTime() ?? Math.min(...timestamps);
return ( const maxTime = endTime?.getTime() ?? Math.max(...timestamps);
<div const duration = maxTime - minTime;
key={i}
className="absolute"
style={{ left: `${pos}%`, top: '50%', transform: 'translate(-50%, -50%)' }}
>
<div className="flex flex-col items-center">
<div className="text-[10px] font-mono text-muted-foreground mb-2">
{marker.toLocaleTimeString([], {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})}
</div>
<div className="w-px h-4 bg-border" />
</div>
</div>
);
})}
</div>
{/* Event markers */} // Generate time markers (every 10 seconds or appropriate interval)
<div className="relative h-40"> const getTimeMarkers = () => {
{/* Timeline line for events */} const markers: Date[] = [];
<div className="absolute top-20 left-0 right-0 h-0.5 bg-border" /> const interval =
duration > 300000 ? 60000 : duration > 60000 ? 30000 : 10000; // 1min, 30s, or 10s intervals
{events.map((event, i) => { for (let time = minTime; time <= maxTime; time += interval) {
const pos = getPosition(event.timestamp); markers.push(new Date(time));
const { color, Icon } = getEventStyle(event.type); }
const isSelected = selectedEvent === event; if (markers[markers.length - 1]?.getTime() !== maxTime) {
markers.push(new Date(maxTime));
}
return markers;
};
return ( const timeMarkers = getTimeMarkers();
<div
key={i}
className="absolute"
style={{
left: `${pos}%`,
top: '50%',
transform: 'translate(-50%, -50%)'
}}
>
{/* Clickable marker group */}
<button
onClick={() => setSelectedEvent(isSelected ? null : event)}
className="flex flex-col items-center gap-1 cursor-pointer group"
title={event.message || event.type}
>
{/* Vertical dash */}
<div className={`
w-1 h-20 ${color} rounded-full
group-hover:w-1.5 transition-all
${isSelected ? 'w-1.5 ring-2 ring-offset-2 ring-offset-background ring-primary' : ''}
`} />
{/* Icon indicator */} // Get position percentage for a timestamp
<div className={` const getPosition = (timestamp: Date) => {
p-1.5 rounded-full ${color} bg-opacity-20 if (duration === 0) return 50;
group-hover:bg-opacity-30 transition-all return ((timestamp.getTime() - minTime) / duration) * 100;
${isSelected ? 'ring-2 ring-primary bg-opacity-40' : ''} };
`}>
<Icon className={`h-3.5 w-3.5 ${color.replace('bg-', 'text-')}`} /> // Get color and icon for event type
</div> const getEventStyle = (eventType: string) => {
</button> if (eventType.includes("start") || eventType === "trial_started") {
</div> return { color: "bg-blue-500", Icon: Flag };
); } else if (
})} eventType.includes("complete") ||
</div> eventType === "trial_completed"
) {
return { color: "bg-green-500", Icon: CheckCircle };
} else if (eventType.includes("robot") || eventType.includes("action")) {
return { color: "bg-purple-500", Icon: Bot };
} else if (
eventType.includes("wizard") ||
eventType.includes("intervention")
) {
return { color: "bg-orange-500", Icon: User };
} else if (eventType.includes("note") || eventType.includes("annotation")) {
return { color: "bg-yellow-500", Icon: MessageSquare };
} else if (eventType.includes("error") || eventType.includes("issue")) {
return { color: "bg-red-500", Icon: AlertTriangle };
}
return { color: "bg-gray-500", Icon: Activity };
};
return (
<div className="space-y-4">
{/* Timeline visualization */}
<div className="relative">
<ScrollArea className="w-full">
<div className="min-w-[800px] px-4 py-8">
{/* Time markers */}
<div className="relative mb-8 h-20">
{/* Main horizontal line */}
<div
className="bg-border absolute top-1/2 right-0 left-0 h-0.5"
style={{ transform: "translateY(-50%)" }}
/>
{/* Time labels */}
{timeMarkers.map((marker, i) => {
const pos = getPosition(marker);
return (
<div
key={i}
className="absolute"
style={{
left: `${pos}%`,
top: "50%",
transform: "translate(-50%, -50%)",
}}
>
<div className="flex flex-col items-center">
<div className="text-muted-foreground mb-2 font-mono text-[10px]">
{marker.toLocaleTimeString([], {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})}
</div>
<div className="bg-border h-4 w-px" />
</div> </div>
</ScrollArea> </div>
);
})}
</div> </div>
{/* Selected event details */} {/* Event markers */}
{selectedEvent && ( <div className="relative h-40">
<Card> {/* Timeline line for events */}
<CardContent className="pt-4"> <div className="bg-border absolute top-20 right-0 left-0 h-0.5" />
<div className="space-y-2">
<div className="flex items-center gap-2"> {events.map((event, i) => {
<Badge variant="outline" className="text-xs"> const pos = getPosition(event.timestamp);
{selectedEvent.type.replace(/_/g, " ")} const { color, Icon } = getEventStyle(event.type);
</Badge> const isSelected = selectedEvent === event;
<span className="text-xs font-mono text-muted-foreground">
{selectedEvent.timestamp.toLocaleTimeString([], { return (
hour12: false, <div
hour: '2-digit', key={i}
minute: '2-digit', className="absolute"
second: '2-digit', style={{
fractionalSecondDigits: 3 left: `${pos}%`,
})} top: "50%",
</span> transform: "translate(-50%, -50%)",
</div> }}
{selectedEvent.message && ( >
<p className="text-sm">{selectedEvent.message}</p> {/* Clickable marker group */}
)} <button
{selectedEvent.data !== undefined && selectedEvent.data !== null && ( onClick={() =>
<details className="text-xs"> setSelectedEvent(isSelected ? null : event)
<summary className="cursor-pointer text-muted-foreground hover:text-foreground"> }
Event data className="group flex cursor-pointer flex-col items-center gap-1"
</summary> title={event.message || event.type}
<pre className="mt-2 p-2 bg-muted rounded text-[10px] overflow-auto"> >
{JSON.stringify(selectedEvent.data, null, 2)} {/* Vertical dash */}
</pre> <div
</details> 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" : ""} `}
)} />
</div>
</CardContent> {/* Icon indicator */}
</Card> <div
)} 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" : ""} `}
</div> >
); <Icon
className={`h-3.5 w-3.5 ${color.replace("bg-", "text-")}`}
/>
</div>
</button>
</div>
);
})}
</div>
</div>
</ScrollArea>
</div>
{/* Selected event details */}
{selectedEvent && (
<Card>
<CardContent className="pt-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{selectedEvent.type.replace(/_/g, " ")}
</Badge>
<span className="text-muted-foreground font-mono text-xs">
{selectedEvent.timestamp.toLocaleTimeString([], {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
fractionalSecondDigits: 3,
})}
</span>
</div>
{selectedEvent.message && (
<p className="text-sm">{selectedEvent.message}</p>
)}
{selectedEvent.data !== undefined &&
selectedEvent.data !== null && (
<details className="text-xs">
<summary className="text-muted-foreground hover:text-foreground cursor-pointer">
Event data
</summary>
<pre className="bg-muted mt-2 overflow-auto rounded p-2 text-[10px]">
{JSON.stringify(selectedEvent.data, null, 2)}
</pre>
</details>
)}
</div>
</CardContent>
</Card>
)}
</div>
);
} }
+408 -311
View File
@@ -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";
@@ -15,336 +28,420 @@ import { api } from "~/trpc/react";
import { ScrollArea } from "~/components/ui/scroll-area"; import { ScrollArea } from "~/components/ui/scroll-area";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { import {
ResizableHandle, ResizableHandle,
ResizablePanel, ResizablePanel,
ResizablePanelGroup, ResizablePanelGroup,
} from "~/components/ui/resizable"; } from "~/components/ui/resizable";
import { EventsDataTable } from "../analysis/events-data-table"; import { EventsDataTable } from "../analysis/events-data-table";
interface TrialAnalysisViewProps { interface TrialAnalysisViewProps {
trial: { trial: {
id: string; id: string;
status: string; status: string;
startedAt: Date | null; startedAt: Date | null;
completedAt: Date | null; completedAt: Date | null;
duration: number | null; duration: number | null;
experiment: { name: string; studyId: string }; experiment: { name: string; studyId: string };
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;
backHref: string; mediaType: string;
format?: string;
contentType?: 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, {
limit: 1000 trialId: trial.id,
}, { 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();
}, 1000); }, 1000);
} }
}, []); }, []);
const videoMedia = trial.media?.find(m => m.mediaType === "video" || (m as any).contentType?.startsWith("video/")); const videoMedia = trial.media?.find(
const videoUrl = videoMedia?.url; (m) =>
m.mediaType === "video" || (m as any).contentType?.startsWith("video/"),
);
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
{/* Header Context */} id="trial-analysis-content"
<PageHeader className="flex h-full flex-col gap-2 p-3 text-sm"
title={trial.experiment.name} >
description={`Session ${trial.id.slice(0, 8)}${trial.startedAt?.toLocaleDateString() ?? 'Unknown Date'} ${trial.startedAt?.toLocaleTimeString() ?? ''}`} {/* Header Context */}
badges={[ <PageHeader
{ title={trial.experiment.name}
label: trial.status.toUpperCase(), description={`Session ${trial.id.slice(0, 8)}${trial.startedAt?.toLocaleDateString() ?? "Unknown Date"} ${trial.startedAt?.toLocaleTimeString() ?? ""}`}
variant: trial.status === 'completed' ? 'default' : 'secondary', badges={[
className: trial.status === 'completed' ? 'bg-green-500 hover:bg-green-600' : '' {
} label: trial.status.toUpperCase(),
]} variant: trial.status === "completed" ? "default" : "secondary",
actions={ className:
<div className="flex items-center gap-2"> trial.status === "completed"
<style jsx global>{` ? "bg-green-500 hover:bg-green-600"
@media print { : "",
@page { },
size: auto; ]}
margin: 15mm; actions={
} <div className="flex items-center gap-2">
body { <style jsx global>{`
background: white; @media print {
color: black; @page {
-webkit-print-color-adjust: exact; size: auto;
print-color-adjust: exact; margin: 15mm;
} }
/* Hide everything by default */ body {
body * { background: white;
visibility: hidden; color: black;
} -webkit-print-color-adjust: exact;
/* Show only our content */ print-color-adjust: exact;
#trial-analysis-content, #trial-analysis-content * { }
visibility: visible; /* Hide everything by default */
} body * {
#trial-analysis-content { visibility: hidden;
position: absolute; }
left: 0; /* Show only our content */
top: 0; #trial-analysis-content,
width: 100%; #trial-analysis-content * {
height: auto; visibility: visible;
overflow: visible; }
padding: 0; #trial-analysis-content {
margin: 0; position: absolute;
background: white; left: 0;
} top: 0;
width: 100%;
height: auto;
overflow: visible;
padding: 0;
margin: 0;
background: white;
}
/* Hide specific non-printable elements */ /* Hide specific non-printable elements */
#tour-trial-video, #tour-trial-video,
button, button,
.no-print, .no-print,
[role="dialog"], [role="dialog"],
header, header,
nav { nav {
display: none !important; display: none !important;
} }
/* Adjust Metrics for Print */ /* Adjust Metrics for Print */
#tour-trial-metrics { #tour-trial-metrics {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
gap: 1rem; gap: 1rem;
margin-bottom: 2rem; margin-bottom: 2rem;
page-break-inside: avoid; page-break-inside: avoid;
} }
#tour-trial-metrics .rounded-xl { #tour-trial-metrics .rounded-xl {
border: 1px solid #ddd; border: 1px solid #ddd;
box-shadow: none; box-shadow: none;
} }
/* Expand Timeline */ /* Expand Timeline */
.h-28 { .h-28 {
height: 120px !important; height: 120px !important;
page-break-inside: avoid; page-break-inside: avoid;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
/* Remove Panel Resizing constraints */ /* Remove Panel Resizing constraints */
[data-panel-group-direction="vertical"] { [data-panel-group-direction="vertical"] {
flex-direction: column !important; flex-direction: column !important;
display: block !important; display: block !important;
height: auto !important; height: auto !important;
} }
[data-panel] { [data-panel] {
flex: none !important; flex: none !important;
height: auto !important; height: auto !important;
overflow: visible !important; overflow: visible !important;
} }
[data-panel-resize-handle] { [data-panel-resize-handle] {
display: none !important; display: none !important;
} }
/* Table Styles: Clean & Full Width */ /* Table Styles: Clean & Full Width */
#tour-trial-events { #tour-trial-events {
display: block !important; display: block !important;
border: none !important; border: none !important;
height: auto !important; height: auto !important;
} }
#tour-trial-events [data-radix-scroll-area-viewport] { #tour-trial-events [data-radix-scroll-area-viewport] {
overflow: visible !important; overflow: visible !important;
height: auto !important; height: auto !important;
} }
/* Hide "Filter" input wrapper if visible */ /* Hide "Filter" input wrapper if visible */
#tour-trial-events .border-b { #tour-trial-events .border-b {
border-bottom: 2px solid #000 !important; border-bottom: 2px solid #000 !important;
} }
} }
`}</style> `}</style>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="gap-2" className="gap-2"
onClick={() => window.print()} onClick={() => window.print()}
> >
<Printer className="h-4 w-4" /> <Printer className="h-4 w-4" />
Export Report Export Report
</Button> </Button>
</div>
}
/>
{/* Top Section: Metrics & Optional Video Grid */}
<div className="flex flex-col xl:flex-row gap-3 shrink-0">
<Card id="tour-trial-metrics" className="shadow-sm flex-1">
<CardContent className="p-0 h-full">
<div className="grid grid-cols-2 grid-rows-2 h-full divide-x divide-y">
<div className="flex flex-col p-4 md:p-6 justify-center">
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-2">
<Clock className="h-4 w-4 text-blue-500" /> Duration
</p>
<p className="text-2xl font-bold">
{trial.duration ? <span>{Math.floor(trial.duration / 60)}m {trial.duration % 60}s</span> : "--:--"}
</p>
</div>
<div className="flex flex-col p-4 md:p-6 justify-center border-t-0">
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-2">
<Bot className="h-4 w-4 text-purple-500" /> Robot Actions
</p>
<p className="text-2xl font-bold">{robotActionCount}</p>
</div>
<div className="flex flex-col p-4 md:p-6 justify-center">
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-2">
<AlertTriangle className="h-4 w-4 text-orange-500" /> Interventions
</p>
<p className="text-2xl font-bold">{interventionCount}</p>
</div>
<div className="flex flex-col p-4 md:p-6 justify-center">
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-2">
<Activity className="h-4 w-4 text-green-500" /> Completeness
</p>
<div className="flex items-center gap-2 text-2xl font-bold">
<span className={cn(
"inline-block h-3 w-3 rounded-full",
trial.status === 'completed' ? "bg-green-500" : "bg-yellow-500"
)} />
{trial.status === 'completed' ? '100%' : 'Incomplete'}
</div>
</div>
</div>
</CardContent>
</Card>
{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">
<div className="aspect-video w-full h-full relative flex items-center justify-center bg-black">
<div className="absolute inset-0">
<PlaybackPlayer src={videoUrl} />
</div>
</div>
</Card>
)}
</div>
{/* Main Workspace: Vertical Layout */}
<div className="flex-1 min-h-0 rounded-xl border shadow-sm overflow-hidden bg-background flex flex-col">
{/* 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">
<EventTimeline />
</div>
{/* BOTTOM: Events Table */}
<div className="flex-1 flex flex-col min-h-0 bg-background" id="tour-trial-events">
<Tabs defaultValue="events" className="flex flex-col h-full">
<div className="flex items-center justify-between px-3 py-2 border-b shrink-0 bg-muted/10">
<div className="flex items-center gap-2">
<TabsList className="h-8">
<TabsTrigger value="events" className="text-xs">All Events</TabsTrigger>
<TabsTrigger value="observations" className="text-xs">Observations ({events.filter(e => e.eventType.startsWith('annotation') || e.eventType === 'wizard_note').length})</TabsTrigger>
</TabsList>
</div>
<div className="flex items-center gap-2">
<Input
placeholder="Filter..."
className="h-7 w-[150px] text-xs"
disabled
style={{ display: 'none' }}
/>
<Badge variant="outline" className="text-[10px] font-normal">{events.length} Total</Badge>
</div>
</div>
<TabsContent value="events" className="flex-1 min-h-0 mt-0">
<ScrollArea className="h-full">
<div className="p-0">
<EventsDataTable
data={events.map(e => ({ ...e, timestamp: new Date(e.timestamp) }))}
startTime={trial.startedAt ?? undefined}
/>
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="observations" className="flex-1 min-h-0 mt-0 bg-muted/5">
<ScrollArea className="h-full">
<div className="p-4 space-y-3 max-w-2xl mx-auto">
{events.filter(e => e.eventType.startsWith('annotation') || e.eventType === 'wizard_note').length > 0 ? (
events
.filter(e => e.eventType.startsWith('annotation') || e.eventType === 'wizard_note')
.map((e, i) => {
const data = e.data as any;
return (
<Card key={i} className="border shadow-none">
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between space-y-0">
<div className="flex items-center gap-2">
<Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-200">
{data?.category || "Note"}
</Badge>
<span className="text-xs text-muted-foreground font-mono">
{trial.startedAt ? formatTime(new Date(e.timestamp).getTime() - new Date(trial.startedAt).getTime()) : '--:--'}
</span>
</div>
<span className="text-[10px] text-muted-foreground">
{new Date(e.timestamp).toLocaleTimeString()}
</span>
</CardHeader>
<CardContent className="p-3 pt-2">
<p className="text-sm">
{data?.description || data?.note || data?.message || "No content"}
</p>
{data?.tags && data.tags.length > 0 && (
<div className="flex gap-1 mt-2">
{data.tags.map((t: string, ti: number) => (
<Badge key={ti} variant="secondary" className="text-[10px] h-5 px-1.5">
{t}
</Badge>
))}
</div>
)}
</CardContent>
</Card>
);
})
) : (
<div className="text-center py-12 text-muted-foreground text-sm">
<Info className="h-8 w-8 mx-auto mb-2 opacity-20" />
No observations recorded for this session.
</div>
)}
</div>
</ScrollArea>
</TabsContent>
</Tabs>
</div>
</div>
</div> </div>
</PlaybackProvider> }
); />
{/* Top Section: Metrics & Optional Video Grid */}
<div className="flex shrink-0 flex-col gap-3 xl:flex-row">
<Card id="tour-trial-metrics" className="flex-1 shadow-sm">
<CardContent className="h-full p-0">
<div className="grid h-full grid-cols-2 grid-rows-2 divide-x divide-y">
<div className="flex flex-col justify-center p-4 md:p-6">
<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
</p>
<p className="text-2xl font-bold">
{trial.duration ? (
<span>
{Math.floor(trial.duration / 60)}m {trial.duration % 60}
s
</span>
) : (
"--:--"
)}
</p>
</div>
<div className="flex flex-col justify-center border-t-0 p-4 md:p-6">
<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
</p>
<p className="text-2xl font-bold">{robotActionCount}</p>
</div>
<div className="flex flex-col justify-center p-4 md:p-6">
<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
</p>
<p className="text-2xl font-bold">{interventionCount}</p>
</div>
<div className="flex flex-col justify-center p-4 md:p-6">
<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
</p>
<div className="flex items-center gap-2 text-2xl font-bold">
<span
className={cn(
"inline-block h-3 w-3 rounded-full",
trial.status === "completed"
? "bg-green-500"
: "bg-yellow-500",
)}
/>
{trial.status === "completed" ? "100%" : "Incomplete"}
</div>
</div>
</div>
</CardContent>
</Card>
{videoUrl && (
<Card
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">
<PlaybackPlayer src={videoUrl} />
</div>
</div>
</Card>
)}
</div>
{/* Main Workspace: Vertical Layout */}
<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 */}
<div
id="tour-trial-timeline"
className="bg-background/95 supports-[backdrop-filter]:bg-background/60 shrink-0 border-b p-1 backdrop-blur"
>
<EventTimeline />
</div>
{/* BOTTOM: Events Table */}
<div
className="bg-background flex min-h-0 flex-1 flex-col"
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">
<TabsList className="h-8">
<TabsTrigger value="events" className="text-xs">
All Events
</TabsTrigger>
<TabsTrigger value="observations" className="text-xs">
Observations (
{
events.filter(
(e) =>
e.eventType.startsWith("annotation") ||
e.eventType === "wizard_note",
).length
}
)
</TabsTrigger>
</TabsList>
</div>
<div className="flex items-center gap-2">
<Input
placeholder="Filter..."
className="h-7 w-[150px] text-xs"
disabled
style={{ display: "none" }}
/>
<Badge variant="outline" className="text-[10px] font-normal">
{events.length} Total
</Badge>
</div>
</div>
<TabsContent value="events" className="mt-0 min-h-0 flex-1">
<ScrollArea className="h-full">
<div className="p-0">
<EventsDataTable
data={events.map((e) => ({
...e,
timestamp: new Date(e.timestamp),
}))}
startTime={trial.startedAt ?? undefined}
/>
</div>
</ScrollArea>
</TabsContent>
<TabsContent
value="observations"
className="bg-muted/5 mt-0 min-h-0 flex-1"
>
<ScrollArea className="h-full">
<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",
)
.map((e, i) => {
const data = e.data as any;
return (
<Card key={i} className="border shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-3 pb-0">
<div className="flex items-center gap-2">
<Badge
variant="outline"
className="border-yellow-200 bg-yellow-50 text-yellow-700"
>
{data?.category || "Note"}
</Badge>
<span className="text-muted-foreground font-mono text-xs">
{trial.startedAt
? formatTime(
new Date(e.timestamp).getTime() -
new Date(trial.startedAt).getTime(),
)
: "--:--"}
</span>
</div>
<span className="text-muted-foreground text-[10px]">
{new Date(e.timestamp).toLocaleTimeString()}
</span>
</CardHeader>
<CardContent className="p-3 pt-2">
<p className="text-sm">
{data?.description ||
data?.note ||
data?.message ||
"No content"}
</p>
{data?.tags && data.tags.length > 0 && (
<div className="mt-2 flex gap-1">
{data.tags.map((t: string, ti: number) => (
<Badge
key={ti}
variant="secondary"
className="h-5 px-1.5 text-[10px]"
>
{t}
</Badge>
))}
</div>
)}
</CardContent>
</Card>
);
})
) : (
<div className="text-muted-foreground py-12 text-center text-sm">
<Info className="mx-auto mb-2 h-8 w-8 opacity-20" />
No observations recorded for this session.
</div>
)}
</div>
</ScrollArea>
</TabsContent>
</Tabs>
</div>
</div>
</div>
</PlaybackProvider>
);
} }
// Helper specific to this file if needed, otherwise ignore. // Helper specific to this file if needed, otherwise ignore.
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
function formatTime(ms: number) { function formatTime(ms: number) {
if (ms < 0) return "0:00"; if (ms < 0) return "0:00";
const totalSeconds = Math.floor(ms / 1000); const totalSeconds = Math.floor(ms / 1000);
const m = Math.floor(totalSeconds / 60); const m = Math.floor(totalSeconds / 60);
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")}`;
} }
@@ -255,10 +255,10 @@ export function RobotActionsPanel({
// Look for ROS2 configuration in the action definition // Look for ROS2 configuration in the action definition
const actionConfig = (actionDef as any).ros2 const actionConfig = (actionDef as any).ros2
? { ? {
topic: (actionDef as any).ros2.topic, topic: (actionDef as any).ros2.topic,
messageType: (actionDef as any).ros2.messageType, messageType: (actionDef as any).ros2.messageType,
payloadMapping: (actionDef as any).ros2.payloadMapping, payloadMapping: (actionDef as any).ros2.payloadMapping,
} }
: undefined; : undefined;
await executeRosAction( await executeRosAction(
@@ -635,7 +635,7 @@ export function RobotActionsPanel({
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Parameters */} {/* Parameters */}
{selectedAction.parameters && {selectedAction.parameters &&
selectedAction.parameters.length > 0 ? ( selectedAction.parameters.length > 0 ? (
<div className="space-y-4"> <div className="space-y-4">
<Label className="text-base">Parameters</Label> <Label className="text-base">Parameters</Label>
{selectedAction.parameters.map((param, index) => {selectedAction.parameters.map((param, index) =>
@@ -662,9 +662,9 @@ export function RobotActionsPanel({
className="w-full" className="w-full"
> >
{selectedPluginData && {selectedPluginData &&
executingActions.has( executingActions.has(
`${selectedPluginData.plugin.name}.${selectedAction.id}`, `${selectedPluginData.plugin.name}.${selectedAction.id}`,
) ? ( ) ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
Executing... Executing...
@@ -962,7 +962,7 @@ export function RobotActionsPanel({
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Parameters */} {/* Parameters */}
{selectedAction?.parameters && {selectedAction?.parameters &&
(selectedAction?.parameters?.length ?? 0) > 0 ? ( (selectedAction?.parameters?.length ?? 0) > 0 ? (
<div className="space-y-4"> <div className="space-y-4">
<Label className="text-base">Parameters</Label> <Label className="text-base">Parameters</Label>
{selectedAction?.parameters?.map((param, index) => {selectedAction?.parameters?.map((param, index) =>
@@ -990,10 +990,10 @@ export function RobotActionsPanel({
className="w-full" className="w-full"
> >
{selectedPluginData && {selectedPluginData &&
selectedAction && selectedAction &&
executingActions.has( executingActions.has(
`${selectedPluginData?.plugin.name}.${selectedAction?.id}`, `${selectedPluginData?.plugin.name}.${selectedAction?.id}`,
) ? ( ) ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
Executing... Executing...
@@ -1,273 +1,315 @@
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";
import { toast } from "sonner"; import { toast } from "sonner";
interface RobotSettingsModalProps { interface RobotSettingsModalProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
studyId: string; studyId: string;
pluginId: string; pluginId: string;
settingsSchema: SettingsSchema | null; settingsSchema: SettingsSchema | null;
} }
interface SettingsSchema { interface SettingsSchema {
type: "object"; type: "object";
title?: string; title?: string;
description?: string; description?: string;
properties: Record<string, PropertySchema>; properties: Record<string, PropertySchema>;
} }
interface PropertySchema { interface PropertySchema {
type: "object" | "string" | "number" | "integer" | "boolean"; type: "object" | "string" | "number" | "integer" | "boolean";
title?: string; title?: string;
description?: string; description?: string;
properties?: Record<string, PropertySchema>; properties?: Record<string, PropertySchema>;
enum?: string[]; enum?: string[];
enumNames?: string[]; enumNames?: string[];
minimum?: number; minimum?: number;
maximum?: number; maximum?: number;
default?: unknown; default?: unknown;
pattern?: string; pattern?: string;
} }
export function RobotSettingsModal({ export function RobotSettingsModal({
open, open,
onOpenChange, onOpenChange,
studyId, studyId,
pluginId, pluginId,
settingsSchema, settingsSchema,
}: RobotSettingsModalProps) { }: RobotSettingsModalProps) {
const [settings, setSettings] = useState<Record<string, unknown>>({}); const [settings, setSettings] = useState<Record<string, unknown>>({});
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 } =
{ studyId, pluginId }, api.studies.getPluginConfiguration.useQuery(
{ enabled: open } { studyId, pluginId },
{ enabled: open },
); );
// Update settings mutation // Update settings mutation
const updateSettings = api.studies.updatePluginConfiguration.useMutation({ const updateSettings = api.studies.updatePluginConfiguration.useMutation({
onSuccess: () => { onSuccess: () => {
toast.success("Robot settings updated successfully"); toast.success("Robot settings updated successfully");
onOpenChange(false); onOpenChange(false);
}, },
onError: (error: { message: string }) => { onError: (error: { message: string }) => {
toast.error(`Failed to update settings: ${error.message}`); toast.error(`Failed to update settings: ${error.message}`);
}, },
}); });
// Initialize settings from current configuration // Initialize settings from current configuration
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
useState(() => { useState(() => {
if (currentSettings) { if (currentSettings) {
setSettings(currentSettings as Record<string, unknown>); setSettings(currentSettings as Record<string, unknown>);
} }
}); });
const handleSave = async () => { const handleSave = async () => {
setIsSaving(true); setIsSaving(true);
try { try {
await updateSettings.mutateAsync({ await updateSettings.mutateAsync({
studyId, studyId,
pluginId, pluginId,
configuration: settings, configuration: settings,
}); });
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
};
const renderField = (
key: string,
schema: PropertySchema,
parentPath: string = "",
) => {
const fullPath = parentPath ? `${parentPath}.${key}` : key;
const value = getNestedValue(settings, fullPath);
const defaultValue = schema.default;
const updateValue = (newValue: unknown) => {
setSettings((prev) => setNestedValue({ ...prev }, fullPath, newValue));
}; };
const renderField = (key: string, schema: PropertySchema, parentPath: string = "") => { // Object type - render nested fields
const fullPath = parentPath ? `${parentPath}.${key}` : key; if (schema.type === "object" && schema.properties) {
const value = getNestedValue(settings, fullPath); return (
const defaultValue = schema.default; <div key={fullPath} className="space-y-4">
<div className="space-y-1">
const updateValue = (newValue: unknown) => { <h4 className="text-sm font-semibold">{schema.title || key}</h4>
setSettings((prev) => setNestedValue({ ...prev }, fullPath, newValue)); {schema.description && (
}; <p className="text-muted-foreground text-xs">
{schema.description}
// Object type - render nested fields </p>
if (schema.type === "object" && schema.properties) { )}
return ( </div>
<div key={fullPath} className="space-y-4"> <div className="ml-4 space-y-3">
<div className="space-y-1"> {Object.entries(schema.properties).map(([subKey, subSchema]) =>
<h4 className="text-sm font-semibold">{schema.title || key}</h4> renderField(subKey, subSchema, fullPath),
{schema.description && ( )}
<p className="text-xs text-muted-foreground">{schema.description}</p> </div>
)} </div>
</div> );
<div className="ml-4 space-y-3">
{Object.entries(schema.properties).map(([subKey, subSchema]) =>
renderField(subKey, subSchema, fullPath)
)}
</div>
</div>
);
}
// Boolean type - render switch
if (schema.type === "boolean") {
return (
<div key={fullPath} className="flex items-center justify-between space-x-2">
<div className="space-y-0.5 flex-1">
<Label htmlFor={fullPath}>{schema.title || key}</Label>
{schema.description && (
<p className="text-xs text-muted-foreground">{schema.description}</p>
)}
</div>
<Switch
id={fullPath}
checked={(value ?? defaultValue) as boolean}
onCheckedChange={updateValue}
/>
</div>
);
}
// Enum type - render select
if (schema.enum) {
return (
<div key={fullPath} className="space-y-2">
<Label htmlFor={fullPath}>{schema.title || key}</Label>
{schema.description && (
<p className="text-xs text-muted-foreground">{schema.description}</p>
)}
<Select
value={(value ?? defaultValue) as string}
onValueChange={updateValue}
>
<SelectTrigger id={fullPath}>
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
{schema.enum.map((option, idx) => (
<SelectItem key={option} value={option}>
{schema.enumNames?.[idx] || option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
// Number/Integer type - render number input
if (schema.type === "number" || schema.type === "integer") {
return (
<div key={fullPath} className="space-y-2">
<Label htmlFor={fullPath}>{schema.title || key}</Label>
{schema.description && (
<p className="text-xs text-muted-foreground">{schema.description}</p>
)}
<Input
id={fullPath}
type="number"
min={schema.minimum}
max={schema.maximum}
step={schema.type === "integer" ? 1 : 0.1}
value={(value ?? defaultValue) as number}
onChange={(e) => {
const newValue = schema.type === "integer"
? parseInt(e.target.value, 10)
: parseFloat(e.target.value);
updateValue(isNaN(newValue) ? defaultValue : newValue);
}}
/>
</div>
);
}
// String type - render text input
return (
<div key={fullPath} className="space-y-2">
<Label htmlFor={fullPath}>{schema.title || key}</Label>
{schema.description && (
<p className="text-xs text-muted-foreground">{schema.description}</p>
)}
<Input
id={fullPath}
type="text"
pattern={schema.pattern}
value={(value ?? defaultValue) as string}
onChange={(e) => updateValue(e.target.value)}
/>
</div>
);
};
if (!settingsSchema) {
return null;
} }
// Boolean type - render switch
if (schema.type === "boolean") {
return (
<div
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>
{schema.description && (
<p className="text-muted-foreground text-xs">
{schema.description}
</p>
)}
</div>
<Switch
id={fullPath}
checked={(value ?? defaultValue) as boolean}
onCheckedChange={updateValue}
/>
</div>
);
}
// Enum type - render select
if (schema.enum) {
return (
<div key={fullPath} className="space-y-2">
<Label htmlFor={fullPath}>{schema.title || key}</Label>
{schema.description && (
<p className="text-muted-foreground text-xs">
{schema.description}
</p>
)}
<Select
value={(value ?? defaultValue) as string}
onValueChange={updateValue}
>
<SelectTrigger id={fullPath}>
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
{schema.enum.map((option, idx) => (
<SelectItem key={option} value={option}>
{schema.enumNames?.[idx] || option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
// Number/Integer type - render number input
if (schema.type === "number" || schema.type === "integer") {
return (
<div key={fullPath} className="space-y-2">
<Label htmlFor={fullPath}>{schema.title || key}</Label>
{schema.description && (
<p className="text-muted-foreground text-xs">
{schema.description}
</p>
)}
<Input
id={fullPath}
type="number"
min={schema.minimum}
max={schema.maximum}
step={schema.type === "integer" ? 1 : 0.1}
value={(value ?? defaultValue) as number}
onChange={(e) => {
const newValue =
schema.type === "integer"
? parseInt(e.target.value, 10)
: parseFloat(e.target.value);
updateValue(isNaN(newValue) ? defaultValue : newValue);
}}
/>
</div>
);
}
// String type - render text input
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <div key={fullPath} className="space-y-2">
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto"> <Label htmlFor={fullPath}>{schema.title || key}</Label>
<DialogHeader> {schema.description && (
<DialogTitle className="flex items-center gap-2"> <p className="text-muted-foreground text-xs">{schema.description}</p>
<Settings2 className="h-5 w-5" /> )}
{settingsSchema.title || "Robot Settings"} <Input
</DialogTitle> id={fullPath}
{settingsSchema.description && ( type="text"
<DialogDescription>{settingsSchema.description}</DialogDescription> pattern={schema.pattern}
)} value={(value ?? defaultValue) as string}
</DialogHeader> onChange={(e) => updateValue(e.target.value)}
/>
{isLoading ? ( </div>
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-6 py-4">
{Object.entries(settingsSchema.properties).map(([key, schema], idx) => (
<div key={key}>
{renderField(key, schema)}
{idx < Object.keys(settingsSchema.properties).length - 1 && (
<Separator className="mt-6" />
)}
</div>
))}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSaving || isLoading}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save Settings
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
); );
};
if (!settingsSchema) {
return null;
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[80vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings2 className="h-5 w-5" />
{settingsSchema.title || "Robot Settings"}
</DialogTitle>
{settingsSchema.description && (
<DialogDescription>{settingsSchema.description}</DialogDescription>
)}
</DialogHeader>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
) : (
<div className="space-y-6 py-4">
{Object.entries(settingsSchema.properties).map(
([key, schema], idx) => (
<div key={key}>
{renderField(key, schema)}
{idx < Object.keys(settingsSchema.properties).length - 1 && (
<Separator className="mt-6" />
)}
</div>
),
)}
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSaving || isLoading}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save Settings
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
} }
// 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"
}, obj as unknown); ? (current as Record<string, unknown>)[key]
: undefined;
}, obj as unknown);
} }
function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): Record<string, unknown> { function setNestedValue(
const keys = path.split("."); obj: Record<string, unknown>,
const lastKey = keys.pop()!; path: string,
const target = keys.reduce((current, key) => { value: unknown,
if (!current[key] || typeof current[key] !== "object") { ): Record<string, unknown> {
current[key] = {}; const keys = path.split(".");
} const lastKey = keys.pop()!;
return current[key] as Record<string, unknown>; const target = keys.reduce((current, key) => {
}, obj); if (!current[key] || typeof current[key] !== "object") {
target[lastKey] = value; current[key] = {};
return obj; }
return current[key] as Record<string, unknown>;
}, obj);
target[lastKey] = value;
return obj;
} }
+54 -47
View File
@@ -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";
@@ -22,10 +22,10 @@ interface TrialProgressProps {
id: string; id: string;
name: string; name: string;
type: type:
| "wizard_action" | "wizard_action"
| "robot_action" | "robot_action"
| "parallel_steps" | "parallel_steps"
| "conditional_branch"; | "conditional_branch";
description?: string; description?: string;
duration?: number; duration?: number;
parameters?: Record<string, unknown>; parameters?: Record<string, unknown>;
@@ -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,12 +212,13 @@ export function TrialProgress({
</div> </div>
<Progress <Progress
value={progress} value={progress}
className={`h-2 ${trialStatus === "completed" className={`h-2 ${
? "bg-green-100" trialStatus === "completed"
: trialStatus === "aborted" || trialStatus === "failed" ? "bg-green-100"
? "bg-red-100" : trialStatus === "aborted" || trialStatus === "failed"
: "bg-blue-100" ? "bg-red-100"
}`} : "bg-blue-100"
}`}
/> />
<div className="flex justify-between text-xs text-slate-500"> <div className="flex justify-between text-xs text-slate-500">
<span>Start</span> <span>Start</span>
@@ -255,47 +257,51 @@ 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"
: "bg-slate-300" : "bg-slate-300"
}`} }`}
/> />
)} )}
{/* 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 ${
? `${statusConfig.bgColor} ${statusConfig.borderColor} shadow-md ring-2 ring-blue-200` status === "active"
: status === "completed" ? `${statusConfig.bgColor} ${statusConfig.borderColor} shadow-md ring-2 ring-blue-200`
? `${statusConfig.bgColor} ${statusConfig.borderColor}` : status === "completed"
: status === "aborted"
? `${statusConfig.bgColor} ${statusConfig.borderColor}` ? `${statusConfig.bgColor} ${statusConfig.borderColor}`
: "border-slate-200 bg-slate-50" : status === "aborted"
}`} ? `${statusConfig.bgColor} ${statusConfig.borderColor}`
: "border-slate-200 bg-slate-50"
}`}
> >
{/* 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 ${
? statusConfig.bgColor status === "active"
: status === "completed" ? statusConfig.bgColor
? "bg-green-100" : status === "completed"
: status === "aborted" ? "bg-green-100"
? "bg-red-100" : status === "aborted"
: "bg-slate-100" ? "bg-red-100"
}`} : "bg-slate-100"
}`}
> >
<span <span
className={`text-sm font-medium ${status === "active" className={`text-sm font-medium ${
? statusConfig.textColor status === "active"
: status === "completed" ? statusConfig.textColor
? "text-green-700" : status === "completed"
: status === "aborted" ? "text-green-700"
? "text-red-700" : status === "aborted"
: "text-slate-600" ? "text-red-700"
}`} : "text-slate-600"
}`}
> >
{index + 1} {index + 1}
</span> </span>
@@ -312,14 +318,15 @@ 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 ${
? "text-slate-900" status === "active"
: status === "completed" ? "text-slate-900"
? "text-green-900" : status === "completed"
: status === "aborted" ? "text-green-900"
? "text-red-900" : status === "aborted"
: "text-slate-700" ? "text-red-900"
}`} : "text-slate-700"
}`}
> >
{step.name} {step.name}
</h5> </h5>
+239 -154
View File
@@ -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(
toast.error(`Robot action failed: ${execution.actionId}`, { (execution: { actionId: string; error?: string }) => {
description: execution.error, toast.error(`Robot action failed: ${execution.actionId}`, {
}); 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,60 +298,80 @@ export const WizardInterface = React.memo(function WizardInterface({
message?: string; message?: string;
}> }>
>(() => { >(() => {
return (fetchedEvents ?? []).map(event => { return (fetchedEvents ?? [])
let message: string | undefined; .map((event) => {
const eventData = event.data as any; let message: string | undefined;
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_")) {
message = `Robot action: ${actionName}`; const actionName = event.eventType
} else if (event.eventType === 'trial_started') { .replace("robot_action_", "")
message = 'Trial started'; .replace(/_/g, " ");
} else if (event.eventType === 'trial_completed') { message = `Robot action: ${actionName}`;
message = 'Trial completed'; } else if (event.eventType === "trial_started") {
} else if (event.eventType === 'step_changed') { message = "Trial started";
message = `Step changed to: ${eventData?.stepName || 'next step'}`; } else if (event.eventType === "trial_completed") {
} else if (event.eventType.startsWith('wizard_')) { message = "Trial completed";
message = eventData?.notes || eventData?.message || event.eventType.replace('wizard_', '').replace(/_/g, ' '); } else if (event.eventType === "step_changed") {
} else { message = `Step changed to: ${eventData?.stepName || "next step"}`;
// Generic fallback } else if (event.eventType.startsWith("wizard_")) {
message = eventData?.notes || eventData?.message || eventData?.description || event.eventType.replace(/_/g, ' '); message =
} eventData?.notes ||
eventData?.message ||
event.eventType.replace("wizard_", "").replace(/_/g, " ");
} else {
// Generic fallback
message =
eventData?.notes ||
eventData?.message ||
eventData?.description ||
event.eventType.replace(/_/g, " ");
}
return { return {
type: event.eventType, type: event.eventType,
timestamp: new Date(event.timestamp), timestamp: new Date(event.timestamp),
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) => ({ () =>
id: step.id, experimentSteps?.map((step, index) => ({
name: step.name ?? `Step ${index + 1}`, id: step.id,
description: step.description, name: step.name ?? `Step ${index + 1}`,
type: mapStepType(step.type), description: step.description,
// Fix: Conditions are at root level from API type: mapStepType(step.type),
conditions: (step as any).conditions ?? (step as any).trigger?.conditions ?? undefined, // Fix: Conditions are at root level from API
parameters: step.parameters ?? {}, conditions:
order: step.order ?? index, (step as any).conditions ??
actions: step.actions?.filter(a => a.type !== 'branch').map((action) => ({ (step as any).trigger?.conditions ??
id: action.id, undefined,
name: action.name, parameters: step.parameters ?? {},
description: action.description, order: step.order ?? index,
type: action.type, actions:
parameters: action.parameters ?? {}, step.actions
order: action.order, ?.filter((a) => a.type !== "branch")
pluginId: action.pluginId, .map((action) => ({
id: action.id,
name: action.name,
description: action.description,
type: action.type,
parameters: action.parameters ?? {},
order: action.order,
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(
logEventMutation.mutate({ (type: string, data?: any) => {
trialId: trial.id, logEventMutation.mutate({
type, trialId: trial.id,
data type,
}); 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>
@@ -2,12 +2,12 @@
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import { import {
GitBranch, GitBranch,
Sparkles, Sparkles,
CheckCircle2, CheckCircle2,
Clock, Clock,
Play, Play,
StickyNote, StickyNote,
} from "lucide-react"; } from "lucide-react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
@@ -16,118 +16,126 @@ import { cn } from "~/lib/utils";
import { Progress } from "~/components/ui/progress"; import { Progress } from "~/components/ui/progress";
export interface TrialStatusBarProps { export interface TrialStatusBarProps {
currentStepIndex: number; currentStepIndex: number;
totalSteps: number; totalSteps: number;
trialStatus: "scheduled" | "in_progress" | "completed" | "aborted" | "failed"; trialStatus: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
rosConnected: boolean; rosConnected: boolean;
eventsCount: number; eventsCount: number;
completedActionsCount: number; completedActionsCount: number;
totalActionsCount: number; totalActionsCount: number;
onAddNote?: () => void; onAddNote?: () => void;
className?: string; className?: string;
} }
export function TrialStatusBar({ export function TrialStatusBar({
currentStepIndex, currentStepIndex,
totalSteps, totalSteps,
trialStatus, trialStatus,
rosConnected, rosConnected,
eventsCount, eventsCount,
completedActionsCount, completedActionsCount,
totalActionsCount, totalActionsCount,
onAddNote, onAddNote,
className, className,
}: TrialStatusBarProps) { }: TrialStatusBarProps) {
const progressPercentage = useMemo( const progressPercentage = useMemo(
() => (totalSteps > 0 ? ((currentStepIndex + 1) / totalSteps) * 100 : 0), () => (totalSteps > 0 ? ((currentStepIndex + 1) / totalSteps) * 100 : 0),
[currentStepIndex, totalSteps], [currentStepIndex, totalSteps],
); );
const actionProgress = useMemo( const actionProgress = useMemo(
() => () =>
totalActionsCount > 0 totalActionsCount > 0
? (completedActionsCount / totalActionsCount) * 100 ? (completedActionsCount / totalActionsCount) * 100
: 0, : 0,
[completedActionsCount, totalActionsCount], [completedActionsCount, totalActionsCount],
); );
return ( return (
<div <div
className={cn( className={cn(
"border-border/60 bg-muted/40 supports-[backdrop-filter]:bg-muted/30 backdrop-blur", "border-border/60 bg-muted/40 supports-[backdrop-filter]:bg-muted/30 backdrop-blur",
"flex h-9 w-full flex-shrink-0 items-center gap-4 border-t px-3 text-xs font-medium", "flex h-9 w-full flex-shrink-0 items-center gap-4 border-t px-3 text-xs font-medium",
className, className,
)} )}
> >
{/* 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>
<span className="text-muted-foreground/70">{Math.round(progressPercentage)}%</span>
</div>
<Separator orientation="vertical" className="h-4 opacity-50" />
{/* Action Progress */}
{totalActionsCount > 0 && (
<>
<div className="flex items-center gap-2">
<span className="flex items-center gap-1.5 text-muted-foreground">
<Sparkles className="h-3.5 w-3.5 opacity-70" />
{completedActionsCount}/{totalActionsCount} actions
</span>
<div className="w-16">
<Progress value={actionProgress} className="h-1.5" />
</div>
</div>
<Separator orientation="vertical" className="h-4 opacity-50" />
</>
)}
{/* Trial Stats */}
<div className="flex items-center gap-3 text-muted-foreground">
<span className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 opacity-70" />
{eventsCount} events
</span>
{trialStatus === "in_progress" && (
<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" />
Live
</Badge>
)}
{trialStatus === "completed" && (
<Badge variant="secondary" className="h-5 gap-1 px-1.5 text-[10px] font-normal">
<CheckCircle2 className="h-2.5 w-2.5" />
Completed
</Badge>
)}
</div>
<div className="flex-1" />
{/* Quick Actions */}
<div className="flex items-center gap-1">
{onAddNote && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={onAddNote}
title="Add Quick Note"
>
<StickyNote className="mr-1.5 h-3.5 w-3.5" />
Note
</Button>
)}
</div>
</div> </div>
); <span className="text-muted-foreground/70">
{Math.round(progressPercentage)}%
</span>
</div>
<Separator orientation="vertical" className="h-4 opacity-50" />
{/* Action Progress */}
{totalActionsCount > 0 && (
<>
<div className="flex items-center gap-2">
<span className="text-muted-foreground flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 opacity-70" />
{completedActionsCount}/{totalActionsCount} actions
</span>
<div className="w-16">
<Progress value={actionProgress} className="h-1.5" />
</div>
</div>
<Separator orientation="vertical" className="h-4 opacity-50" />
</>
)}
{/* Trial Stats */}
<div className="text-muted-foreground flex items-center gap-3">
<span className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 opacity-70" />
{eventsCount} events
</span>
{trialStatus === "in_progress" && (
<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" />
Live
</Badge>
)}
{trialStatus === "completed" && (
<Badge
variant="secondary"
className="h-5 gap-1 px-1.5 text-[10px] font-normal"
>
<CheckCircle2 className="h-2.5 w-2.5" />
Completed
</Badge>
)}
</div>
<div className="flex-1" />
{/* Quick Actions */}
<div className="flex items-center gap-1">
{onAddNote && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={onAddNote}
title="Add Quick Note"
>
<StickyNote className="mr-1.5 h-3.5 w-3.5" />
Note
</Button>
)}
</div>
</div>
);
} }
export default TrialStatusBar; export default TrialStatusBar;
@@ -9,295 +9,312 @@ 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({
const [isCameraEnabled, setIsCameraEnabled] = useState(false); readOnly = false,
const [isRecording, setIsRecording] = useState(false); trialId,
const [uploading, setUploading] = useState(false); trialStatus,
const [error, setError] = useState<string | null>(null); }: {
readOnly?: boolean;
trialId?: string;
trialStatus?: string;
}) {
const [isCameraEnabled, setIsCameraEnabled] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const webcamRef = useRef<Webcam>(null); const webcamRef = useRef<Webcam>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null); const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]); const chunksRef = useRef<Blob[]>([]);
// TRPC mutation for presigned URL // TRPC mutation for presigned URL
const getUploadUrlMutation = api.storage.getUploadPresignedUrl.useMutation(); const getUploadUrlMutation = api.storage.getUploadPresignedUrl.useMutation();
// Mutation to save recording metadata to DB // Mutation to save recording metadata to DB
const saveRecordingMutation = api.storage.saveRecording.useMutation(); const saveRecordingMutation = api.storage.saveRecording.useMutation();
const logEventMutation = api.trials.logEvent.useMutation(); const logEventMutation = api.trials.logEvent.useMutation();
const [isMounted, setIsMounted] = useState(false); const [isMounted, setIsMounted] = useState(false);
React.useEffect(() => { React.useEffect(() => {
setIsMounted(true); setIsMounted(true);
}, []); }, []);
const handleEnableCamera = () => { const handleEnableCamera = () => {
setIsCameraEnabled(true);
setError(null);
};
const handleDisableCamera = () => {
if (isRecording) {
handleStopRecording();
}
setIsCameraEnabled(false);
};
// Auto-record based on trial status
React.useEffect(() => {
if (!trialStatus || readOnly) return;
if (trialStatus === "in_progress") {
if (!isCameraEnabled) {
console.log("Auto-enabling camera for trial start");
setIsCameraEnabled(true); setIsCameraEnabled(true);
setError(null); } else if (!isRecording && webcamRef.current?.stream) {
}; handleStartRecording();
}
} else if (trialStatus === "completed" && isRecording) {
handleStopRecording();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [trialStatus, isCameraEnabled, isRecording, readOnly]);
const handleDisableCamera = () => { const handleUserMedia = () => {
if (isRecording) { if (trialStatus === "in_progress" && !isRecording && !readOnly) {
handleStopRecording(); console.log("Stream ready, auto-starting camera recording");
handleStartRecording();
}
};
const handleStartRecording = () => {
if (!webcamRef.current?.stream) return;
if (
mediaRecorderRef.current &&
mediaRecorderRef.current.state === "recording"
) {
console.log("Already recording, skipping start");
return;
}
setIsRecording(true);
chunksRef.current = [];
try {
const recorder = new MediaRecorder(webcamRef.current.stream, {
mimeType: "video/webm",
});
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
} }
setIsCameraEnabled(false); };
};
// Auto-record based on trial status recorder.onstop = async () => {
React.useEffect(() => { const blob = new Blob(chunksRef.current, { type: "video/webm" });
if (!trialStatus || readOnly) return; await handleUpload(blob);
};
if (trialStatus === "in_progress") { recorder.start();
if (!isCameraEnabled) { mediaRecorderRef.current = recorder;
console.log("Auto-enabling camera for trial start"); if (trialId) {
setIsCameraEnabled(true); logEventMutation.mutate({
} else if (!isRecording && webcamRef.current?.stream) { trialId,
handleStartRecording(); type: "camera_started",
} data: { action: "recording_started" },
} else if (trialStatus === "completed" && isRecording) { });
handleStopRecording(); }
} toast.success("Recording started");
// eslint-disable-next-line react-hooks/exhaustive-deps } catch (e) {
}, [trialStatus, isCameraEnabled, isRecording, readOnly]); console.error("Failed to start recorder:", e);
toast.error("Failed to start recording");
setIsRecording(false);
}
};
const handleUserMedia = () => { const handleStopRecording = () => {
if (trialStatus === "in_progress" && !isRecording && !readOnly) { if (
console.log("Stream ready, auto-starting camera recording"); mediaRecorderRef.current &&
handleStartRecording(); isRecording &&
} mediaRecorderRef.current.state === "recording"
}; ) {
mediaRecorderRef.current.stop();
setIsRecording(false);
if (trialId) {
logEventMutation.mutate({
trialId,
type: "camera_stopped",
data: { action: "recording_stopped" },
});
}
}
};
const handleStartRecording = () => { const handleUpload = async (blob: Blob) => {
if (!webcamRef.current?.stream) return; setUploading(true);
if (mediaRecorderRef.current && mediaRecorderRef.current.state === "recording") { const filename = `recording-${Date.now()}.webm`;
console.log("Already recording, skipping start");
return;
}
setIsRecording(true); try {
chunksRef.current = []; // 1. Get Presigned URL
const { url } = await getUploadUrlMutation.mutateAsync({
filename,
contentType: "video/webm",
});
// 2. Upload to S3
const response = await fetch(url, {
method: "PUT",
body: blob,
headers: {
"Content-Type": "video/webm",
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Upload failed: ${errorText} | Status: ${response.status}`,
);
}
// 3. Save metadata to DB
if (trialId) {
console.log("Attempting to link recording to trial:", trialId);
try { try {
const recorder = new MediaRecorder(webcamRef.current.stream, { await saveRecordingMutation.mutateAsync({
mimeType: "video/webm" trialId,
}); storagePath: filename,
mediaType: "video",
recorder.ondataavailable = (event) => { format: "webm",
if (event.data.size > 0) { fileSize: blob.size,
chunksRef.current.push(event.data); });
} console.log("Recording successfully linked to trial:", trialId);
}; toast.success("Recording saved to trial log");
} catch (mutationError) {
recorder.onstop = async () => { console.error("Failed to link recording to trial:", mutationError);
const blob = new Blob(chunksRef.current, { type: "video/webm" }); toast.error("Video uploaded but failed to link to trial");
await handleUpload(blob);
};
recorder.start();
mediaRecorderRef.current = recorder;
if (trialId) {
logEventMutation.mutate({
trialId,
type: "camera_started",
data: { action: "recording_started" }
});
}
toast.success("Recording started");
} catch (e) {
console.error("Failed to start recorder:", e);
toast.error("Failed to start recording");
setIsRecording(false);
} }
}; } else {
console.warn(
"No trialId provided, recording uploaded but not linked. Props:",
{ trialId },
);
toast.warning("Trial ID missing - recording not linked");
}
const handleStopRecording = () => { toast.success("Recording uploaded successfully");
if (mediaRecorderRef.current && isRecording && mediaRecorderRef.current.state === "recording") { console.log("Uploaded recording:", filename);
mediaRecorderRef.current.stop(); } catch (e) {
setIsRecording(false); console.error("Upload error:", e);
if (trialId) { toast.error("Failed to upload recording");
logEventMutation.mutate({ } finally {
trialId, setUploading(false);
type: "camera_stopped", }
data: { action: "recording_stopped" } };
});
}
}
};
const handleUpload = async (blob: Blob) => { return (
setUploading(true); <div className="flex h-full flex-col">
const filename = `recording-${Date.now()}.webm`; <div className="bg-muted/10 flex h-10 shrink-0 items-center justify-end border-b px-2 py-1">
{!readOnly && (
<div className="flex items-center gap-2">
{isCameraEnabled &&
(!isRecording ? (
<Button
variant="destructive"
size="sm"
className="animate-in fade-in h-7 px-2 text-xs"
onClick={handleStartRecording}
disabled={uploading}
>
<Video className="mr-1 h-3 w-3" />
Record
</Button>
) : (
<Button
variant="secondary"
size="sm"
className="h-7 border border-red-500 px-2 text-xs text-red-500 hover:bg-red-50"
onClick={handleStopRecording}
>
<StopCircle className="mr-1 h-3 w-3 animate-pulse" />
Stop Rec
</Button>
))}
try { {isCameraEnabled ? (
// 1. Get Presigned URL <Button
const { url } = await getUploadUrlMutation.mutateAsync({ variant="ghost"
filename, size="sm"
contentType: "video/webm", className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
}); onClick={handleDisableCamera}
disabled={isRecording}
>
<CameraOff className="mr-1 h-3 w-3" />
Off
</Button>
) : (
<Button
variant="default"
size="sm"
className="h-7 px-2 text-xs"
onClick={handleEnableCamera}
>
<Camera className="mr-1 h-3 w-3" />
Start Camera
</Button>
)}
</div>
)}
</div>
// 2. Upload to S3 <div className="bg-muted/50 relative flex flex-1 items-center justify-center overflow-hidden p-4">
const response = await fetch(url, { {isCameraEnabled ? (
method: "PUT", <div className="border-border relative w-full overflow-hidden rounded-lg border bg-black shadow-sm">
body: blob, <AspectRatio ratio={16 / 9}>
headers: { <Webcam
"Content-Type": "video/webm", ref={webcamRef}
}, audio={false}
}); width="100%"
height="100%"
onUserMedia={handleUserMedia}
onUserMediaError={(err) => setError(String(err))}
className="h-full w-full object-contain"
/>
</AspectRatio>
if (!response.ok) { {/* Recording Overlay */}
const errorText = await response.text(); {isRecording && (
throw new Error(`Upload failed: ${errorText} | Status: ${response.status}`); <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="h-2 w-2 animate-pulse rounded-full bg-red-500" />
<span className="text-[10px] font-medium text-white">REC</span>
</div>
)}
// 3. Save metadata to DB {/* Uploading Overlay */}
if (trialId) { {uploading && (
console.log("Attempting to link recording to trial:", trialId); <div className="absolute inset-0 flex items-center justify-center bg-black/60 backdrop-blur-sm">
try { <div className="flex flex-col items-center gap-2 text-white">
await saveRecordingMutation.mutateAsync({ <Loader2 className="h-6 w-6 animate-spin" />
trialId, <span className="text-xs font-medium">Uploading...</span>
storagePath: filename, </div>
mediaType: "video", </div>
format: "webm", )}
fileSize: blob.size,
});
console.log("Recording successfully linked to trial:", trialId);
toast.success("Recording saved to trial log");
} catch (mutationError) {
console.error("Failed to link recording to trial:", mutationError);
toast.error("Video uploaded but failed to link to trial");
}
} else {
console.warn("No trialId provided, recording uploaded but not linked. Props:", { trialId });
toast.warning("Trial ID missing - recording not linked");
}
toast.success("Recording uploaded successfully"); {error && (
console.log("Uploaded recording:", filename); <div className="absolute inset-0 flex items-center justify-center bg-black/80">
} catch (e) { <Alert variant="destructive" className="max-w-xs">
console.error("Upload error:", e); <AlertDescription>{error}</AlertDescription>
toast.error("Failed to upload recording"); </Alert>
} finally { </div>
setUploading(false); )}
} </div>
}; ) : (
<div className="text-muted-foreground/50 text-center">
return ( <div className="bg-muted mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full">
<div className="flex h-full flex-col"> <CameraOff className="h-6 w-6 opacity-50" />
<div className="flex items-center justify-end border-b px-2 py-1 bg-muted/10 h-10 shrink-0">
{!readOnly && (
<div className="flex items-center gap-2">
{isCameraEnabled && (
!isRecording ? (
<Button
variant="destructive"
size="sm"
className="h-7 px-2 text-xs animate-in fade-in"
onClick={handleStartRecording}
disabled={uploading}
>
<Video className="mr-1 h-3 w-3" />
Record
</Button>
) : (
<Button
variant="secondary"
size="sm"
className="h-7 px-2 text-xs border-red-500 border text-red-500 hover:bg-red-50"
onClick={handleStopRecording}
>
<StopCircle className="mr-1 h-3 w-3 animate-pulse" />
Stop Rec
</Button>
)
)}
{isCameraEnabled ? (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={handleDisableCamera}
disabled={isRecording}
>
<CameraOff className="mr-1 h-3 w-3" />
Off
</Button>
) : (
<Button
variant="default"
size="sm"
className="h-7 px-2 text-xs"
onClick={handleEnableCamera}
>
<Camera className="mr-1 h-3 w-3" />
Start Camera
</Button>
)}
</div>
)}
</div> </div>
<p className="text-sm font-medium">Camera is disabled</p>
<div className="flex-1 overflow-hidden bg-muted/50 p-4 flex items-center justify-center relative"> <Button
{isCameraEnabled ? ( variant="secondary"
<div className="w-full relative rounded-lg overflow-hidden border border-border shadow-sm bg-black"> size="sm"
<AspectRatio ratio={16 / 9}> className="mt-4"
<Webcam onClick={handleEnableCamera}
ref={webcamRef} >
audio={false} Enable Camera
width="100%" </Button>
height="100%" </div>
onUserMedia={handleUserMedia} )}
onUserMediaError={(err) => setError(String(err))} </div>
className="object-contain w-full h-full" </div>
/> );
</AspectRatio>
{/* Recording Overlay */}
{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="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
<span className="text-[10px] font-medium text-white">REC</span>
</div>
)}
{/* Uploading Overlay */}
{uploading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="flex flex-col items-center gap-2 text-white">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="text-xs font-medium">Uploading...</span>
</div>
</div>
)}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-black/80">
<Alert variant="destructive" className="max-w-xs">
<AlertDescription>{error}</AlertDescription>
</Alert>
</div>
)}
</div>
) : (
<div className="text-center text-muted-foreground/50">
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<CameraOff className="h-6 w-6 opacity-50" />
</div>
<p className="text-sm font-medium">Camera is disabled</p>
<Button
variant="secondary"
size="sm"
className="mt-4"
onClick={handleEnableCamera}
>
Enable Camera
</Button>
</div>
)}
</div>
</div >
);
} }
File diff suppressed because it is too large Load Diff
@@ -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,12 +287,13 @@ 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 ${
? "text-foreground" isCurrent
: isCompleted ? "text-foreground"
? "text-muted-foreground" : isCompleted
: "text-muted-foreground/60" ? "text-muted-foreground"
}`} : "text-muted-foreground/60"
}`}
title={step.name} title={step.name}
> >
{step.name} {step.name}
@@ -303,8 +303,11 @@ 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"
}`}
/> />
)} )}
</div> </div>
@@ -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 ${
? "border-primary bg-primary text-primary-foreground" isCompleted
: isActive ? "border-primary bg-primary text-primary-foreground"
? "border-primary ring-4 ring-primary/10 scale-110" : isActive
: "border-muted-foreground/30 text-muted-foreground" ? "border-primary ring-primary/10 scale-110 ring-4"
}`} : "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
? "bg-blue-600 hover:bg-blue-700" ? onCompleteTrial
: "bg-green-600 hover:bg-green-700" : 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-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,24 +72,27 @@ 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(
setAutonomousLife(checked); // Optimistic update async (checked: boolean) => {
if (onSetAutonomousLife) { setAutonomousLife(checked); // Optimistic update
try { if (onSetAutonomousLife) {
const result = await onSetAutonomousLife(checked); try {
if (result === false) { const result = await onSetAutonomousLife(checked);
throw new Error("Service unavailable"); if (result === false) {
throw new Error("Service unavailable");
}
} catch (error) {
console.error("Failed to set autonomous life:", error);
setAutonomousLife(!checked); // Revert on failure
} }
} catch (error) {
console.error("Failed to set autonomous life:", error);
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(),

Some files were not shown because too many files have changed in this diff Show More