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
+5 -5
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,10 +921,10 @@ 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.
// If dragging over the container *background* (empty space), append is usually expected. // If dragging over the container *background* (empty space), append is usually expected.
// Let's try 9999? // Let's try 9999?
index = 9999; index = 9999;
@@ -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" />
+450 -386
View File
@@ -1,371 +1,423 @@
"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.
DO NOT wrap variables in hsl() if they are already defined as colors. DO NOT wrap variables in hsl() if they are already defined as colors.
Use direct assignment. Use direct assignment.
*/ */
.driver-popover-override {
padding: 1.25rem !important;
border-radius: var(--radius) !important;
box-shadow: var(--shadow-xl) !important;
max-width: 420px !important;
/* Background & Text - Match Card Aesthetic */
background-color: var(--card) !important;
color: var(--card-foreground) !important;
border: 1px solid var(--border) !important;
/* Typography */ .driver-popover-override {
font-family: var(--font-sans) !important; padding: 1.25rem !important;
border-radius: var(--radius) !important;
box-shadow: var(--shadow-xl) !important;
max-width: 420px !important;
/* Background & Text - Match Card Aesthetic */
background-color: var(--card) !important;
color: var(--card-foreground) !important;
border: 1px solid var(--border) !important;
/* Typography */
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%;
/* Hide specific non-printable elements */ height: auto;
#tour-trial-video, overflow: visible;
button, padding: 0;
.no-print, margin: 0;
[role="dialog"], background: white;
header, }
nav {
display: none !important;
}
/* Adjust Metrics for Print */ /* Hide specific non-printable elements */
#tour-trial-metrics { #tour-trial-video,
display: grid; button,
grid-template-columns: repeat(4, 1fr); .no-print,
gap: 1rem; [role="dialog"],
margin-bottom: 2rem; header,
page-break-inside: avoid; nav {
} display: none !important;
#tour-trial-metrics .rounded-xl { }
border: 1px solid #ddd;
box-shadow: none;
}
/* Expand Timeline */ /* Adjust Metrics for Print */
.h-28 { #tour-trial-metrics {
height: 120px !important; display: grid;
page-break-inside: avoid; grid-template-columns: repeat(4, 1fr);
border-bottom: 1px solid #eee; gap: 1rem;
margin-bottom: 1rem; margin-bottom: 2rem;
} page-break-inside: avoid;
}
#tour-trial-metrics .rounded-xl {
border: 1px solid #ddd;
box-shadow: none;
}
/* Remove Panel Resizing constraints */ /* Expand Timeline */
[data-panel-group-direction="vertical"] { .h-28 {
flex-direction: column !important; height: 120px !important;
display: block !important; page-break-inside: avoid;
height: auto !important; border-bottom: 1px solid #eee;
} margin-bottom: 1rem;
[data-panel] { }
flex: none !important;
height: auto !important;
overflow: visible !important;
}
[data-panel-resize-handle] {
display: none !important;
}
/* Table Styles: Clean & Full Width */ /* Remove Panel Resizing constraints */
#tour-trial-events { [data-panel-group-direction="vertical"] {
display: block !important; flex-direction: column !important;
border: none !important; display: block !important;
height: auto !important; height: auto !important;
} }
#tour-trial-events [data-radix-scroll-area-viewport] { [data-panel] {
overflow: visible !important; flex: none !important;
height: auto !important; height: auto !important;
} overflow: visible !important;
/* Hide "Filter" input wrapper if visible */ }
#tour-trial-events .border-b { [data-panel-resize-handle] {
border-bottom: 2px solid #000 !important; display: none !important;
} }
}
`}</style>
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={() => window.print()}
>
<Printer className="h-4 w-4" />
Export Report
</Button>
</div>
}
/>
{/* Top Section: Metrics & Optional Video Grid */} /* Table Styles: Clean & Full Width */
<div className="flex flex-col xl:flex-row gap-3 shrink-0"> #tour-trial-events {
<Card id="tour-trial-metrics" className="shadow-sm flex-1"> display: block !important;
<CardContent className="p-0 h-full"> border: none !important;
<div className="grid grid-cols-2 grid-rows-2 h-full divide-x divide-y"> height: auto !important;
<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"> #tour-trial-events [data-radix-scroll-area-viewport] {
<Clock className="h-4 w-4 text-blue-500" /> Duration overflow: visible !important;
</p> height: auto !important;
<p className="text-2xl font-bold"> }
{trial.duration ? <span>{Math.floor(trial.duration / 60)}m {trial.duration % 60}s</span> : "--:--"} /* Hide "Filter" input wrapper if visible */
</p> #tour-trial-events .border-b {
</div> border-bottom: 2px solid #000 !important;
<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 `}</style>
</p> <Button
<p className="text-2xl font-bold">{robotActionCount}</p> variant="outline"
</div> size="sm"
<div className="flex flex-col p-4 md:p-6 justify-center"> className="gap-2"
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-2"> onClick={() => window.print()}
<AlertTriangle className="h-4 w-4 text-orange-500" /> Interventions >
</p> <Printer className="h-4 w-4" />
<p className="text-2xl font-bold">{interventionCount}</p> Export Report
</div> </Button>
<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>
+240 -155
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,13 +710,13 @@ 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?
// Maybe better to only mark on "Next" or explicit complete. // Maybe better to only mark on "Next" or explicit complete.
// If I jump away, I might not be done. // If I jump away, I might not be done.
// I'll leave 'completedSteps' update to explicit actions or completion. // I'll leave 'completedSteps' update to explicit actions or completion.
setCurrentStepIndex(index); setCurrentStepIndex(index);
@@ -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