mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 22:54:45 -05:00
feat: Implement visual experiment designer and enhance landing page
- Add drag-and-drop experiment design capabilities using @dnd-kit libraries - Introduce new experiment-related database schema and API routes - Enhance landing page with modern design, gradients, and improved call-to-action sections - Update app sidebar to include experiments navigation - Add new dependencies for experiment design and visualization (reactflow, react-zoom-pan-pinch) - Modify study and experiment schemas to support more flexible experiment configuration - Implement initial experiment creation and management infrastructure
This commit is contained in:
123
bun.lock
123
bun.lock
@@ -8,6 +8,9 @@
|
|||||||
"@aws-sdk/client-s3": "^3.735.0",
|
"@aws-sdk/client-s3": "^3.735.0",
|
||||||
"@aws-sdk/lib-storage": "^3.735.0",
|
"@aws-sdk/lib-storage": "^3.735.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.735.0",
|
"@aws-sdk/s3-request-presigner": "^3.735.0",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||||
"@radix-ui/react-avatar": "^1.1.2",
|
"@radix-ui/react-avatar": "^1.1.2",
|
||||||
@@ -16,10 +19,12 @@
|
|||||||
"@radix-ui/react-dropdown-menu": "^2.1.5",
|
"@radix-ui/react-dropdown-menu": "^2.1.5",
|
||||||
"@radix-ui/react-label": "^2.1.1",
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
"@radix-ui/react-popover": "^1.1.5",
|
"@radix-ui/react-popover": "^1.1.5",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||||
"@radix-ui/react-select": "^2.1.5",
|
"@radix-ui/react-select": "^2.1.5",
|
||||||
"@radix-ui/react-separator": "^1.1.1",
|
"@radix-ui/react-separator": "^1.1.1",
|
||||||
"@radix-ui/react-slider": "^1.2.2",
|
"@radix-ui/react-slider": "^1.2.2",
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
|
"@radix-ui/react-switch": "^1.1.3",
|
||||||
"@radix-ui/react-tabs": "^1.1.3",
|
"@radix-ui/react-tabs": "^1.1.3",
|
||||||
"@radix-ui/react-tooltip": "^1.1.7",
|
"@radix-ui/react-tooltip": "^1.1.7",
|
||||||
"@t3-oss/env-nextjs": "^0.10.1",
|
"@t3-oss/env-nextjs": "^0.10.1",
|
||||||
@@ -45,6 +50,8 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-easy-crop": "^5.2.0",
|
"react-easy-crop": "^5.2.0",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
|
"react-zoom-pan-pinch": "^3.7.0",
|
||||||
|
"reactflow": "^11.11.4",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"sonner": "^1.7.2",
|
"sonner": "^1.7.2",
|
||||||
"superjson": "^2.2.1",
|
"superjson": "^2.2.1",
|
||||||
@@ -163,6 +170,16 @@
|
|||||||
|
|
||||||
"@babel/runtime": ["@babel/runtime@7.26.7", "", { "dependencies": { "regenerator-runtime": "^0.14.0" } }, "sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ=="],
|
"@babel/runtime": ["@babel/runtime@7.26.7", "", { "dependencies": { "regenerator-runtime": "^0.14.0" } }, "sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ=="],
|
||||||
|
|
||||||
|
"@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/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
|
||||||
|
|
||||||
|
"@dnd-kit/modifiers": ["@dnd-kit/modifiers@9.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw=="],
|
||||||
|
|
||||||
|
"@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="],
|
||||||
|
|
||||||
|
"@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
|
||||||
|
|
||||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
||||||
|
|
||||||
"@emnapi/runtime": ["@emnapi/runtime@1.3.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw=="],
|
"@emnapi/runtime": ["@emnapi/runtime@1.3.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw=="],
|
||||||
@@ -371,6 +388,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.2", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0" }, "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-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw=="],
|
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.2", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0" }, "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-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.3", "", { "dependencies": { "@radix-ui/number": "1.1.0", "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0" }, "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-l7+NNBfBYYJa9tNqVcP2AGvxdE3lmE6kFTBXdvHgUaZuy+4wGCL1Cl2AfaR7RKyimj7lZURGLwFO59k4eBnDJQ=="],
|
||||||
|
|
||||||
"@radix-ui/react-select": ["@radix-ui/react-select@2.1.5", "", { "dependencies": { "@radix-ui/number": "1.1.0", "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-dismissable-layer": "1.1.4", "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.1", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-popper": "1.2.1", "@radix-ui/react-portal": "1.1.3", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-slot": "1.1.1", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", "@radix-ui/react-visually-hidden": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.2" }, "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-eVV7N8jBXAXnyrc+PsOF89O9AfVgGnbLxUtBb0clJ8y8ENMWLARGMI/1/SBRLz7u4HqxLgN71BJ17eono3wcjA=="],
|
"@radix-ui/react-select": ["@radix-ui/react-select@2.1.5", "", { "dependencies": { "@radix-ui/number": "1.1.0", "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-dismissable-layer": "1.1.4", "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.1", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-popper": "1.2.1", "@radix-ui/react-portal": "1.1.3", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-slot": "1.1.1", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", "@radix-ui/react-visually-hidden": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.2" }, "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-eVV7N8jBXAXnyrc+PsOF89O9AfVgGnbLxUtBb0clJ8y8ENMWLARGMI/1/SBRLz7u4HqxLgN71BJ17eono3wcjA=="],
|
||||||
|
|
||||||
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.1", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.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-RRiNRSrD8iUiXriq/Y5n4/3iE8HzqgLHsusUSg5jVpU2+3tqcUFPJXHDymwEypunc2sWxDUS3UC+rkZRlHedsw=="],
|
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.1", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.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-RRiNRSrD8iUiXriq/Y5n4/3iE8HzqgLHsusUSg5jVpU2+3tqcUFPJXHDymwEypunc2sWxDUS3UC+rkZRlHedsw=="],
|
||||||
@@ -379,6 +398,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ=="],
|
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.1.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", "@radix-ui/react-use-size": "1.1.0" }, "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-1nc+vjEOQkJVsJtWPSiISGT6OKm4SiOdjMo+/icLxo2G4vxz1GntC5MzfL4v8ey9OEfw787QCD1y3mUv0NiFEQ=="],
|
||||||
|
|
||||||
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-roving-focus": "1.1.2", "@radix-ui/react-use-controllable-state": "1.1.0" }, "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-9mFyI30cuRDImbmFF6O2KUJdgEOsGh9Vmx9x/Dh9tOhL7BngmQPQfwW4aejKm5OHpfWIdmeV6ySyuxoOGjtNng=="],
|
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-roving-focus": "1.1.2", "@radix-ui/react-use-controllable-state": "1.1.0" }, "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-9mFyI30cuRDImbmFF6O2KUJdgEOsGh9Vmx9x/Dh9tOhL7BngmQPQfwW4aejKm5OHpfWIdmeV6ySyuxoOGjtNng=="],
|
||||||
|
|
||||||
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.1.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.4", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-popper": "1.2.1", "@radix-ui/react-portal": "1.1.3", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-slot": "1.1.1", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-visually-hidden": "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-ss0s80BC0+g0+Zc53MvilcnTYSOi4mSuFWBPYPuTOFGjx+pUU+ZrmamMNwS56t8MTFlniA5ocjd4jYm/CdhbOg=="],
|
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.1.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.4", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-popper": "1.2.1", "@radix-ui/react-portal": "1.1.3", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-slot": "1.1.1", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-visually-hidden": "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-ss0s80BC0+g0+Zc53MvilcnTYSOi4mSuFWBPYPuTOFGjx+pUU+ZrmamMNwS56t8MTFlniA5ocjd4jYm/CdhbOg=="],
|
||||||
@@ -401,6 +422,18 @@
|
|||||||
|
|
||||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.0", "", {}, "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg=="],
|
"@radix-ui/rect": ["@radix-ui/rect@1.1.0", "", {}, "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg=="],
|
||||||
|
|
||||||
|
"@reactflow/background": ["@reactflow/background@11.3.14", "", { "dependencies": { "@reactflow/core": "11.11.4", "classcat": "^5.0.3", "zustand": "^4.4.1" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA=="],
|
||||||
|
|
||||||
|
"@reactflow/controls": ["@reactflow/controls@11.2.14", "", { "dependencies": { "@reactflow/core": "11.11.4", "classcat": "^5.0.3", "zustand": "^4.4.1" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw=="],
|
||||||
|
|
||||||
|
"@reactflow/core": ["@reactflow/core@11.11.4", "", { "dependencies": { "@types/d3": "^7.4.0", "@types/d3-drag": "^3.0.1", "@types/d3-selection": "^3.0.3", "@types/d3-zoom": "^3.0.1", "classcat": "^5.0.3", "d3-drag": "^3.0.0", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0", "zustand": "^4.4.1" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q=="],
|
||||||
|
|
||||||
|
"@reactflow/minimap": ["@reactflow/minimap@11.7.14", "", { "dependencies": { "@reactflow/core": "11.11.4", "@types/d3-selection": "^3.0.3", "@types/d3-zoom": "^3.0.1", "classcat": "^5.0.3", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0", "zustand": "^4.4.1" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ=="],
|
||||||
|
|
||||||
|
"@reactflow/node-resizer": ["@reactflow/node-resizer@2.2.14", "", { "dependencies": { "@reactflow/core": "11.11.4", "classcat": "^5.0.4", "d3-drag": "^3.0.0", "d3-selection": "^3.0.0", "zustand": "^4.4.1" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA=="],
|
||||||
|
|
||||||
|
"@reactflow/node-toolbar": ["@reactflow/node-toolbar@1.3.14", "", { "dependencies": { "@reactflow/core": "11.11.4", "classcat": "^5.0.3", "zustand": "^4.4.1" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ=="],
|
||||||
|
|
||||||
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
||||||
|
|
||||||
"@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.10.5", "", {}, "sha512-kkKUDVlII2DQiKy7UstOR1ErJP8kUKAQ4oa+SQtM0K+lPdmmjj0YnnxBgtTVYH7mUKtbsxeFC9y0AmK7Yb78/A=="],
|
"@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.10.5", "", {}, "sha512-kkKUDVlII2DQiKy7UstOR1ErJP8kUKAQ4oa+SQtM0K+lPdmmjj0YnnxBgtTVYH7mUKtbsxeFC9y0AmK7Yb78/A=="],
|
||||||
@@ -525,10 +558,74 @@
|
|||||||
|
|
||||||
"@types/bcryptjs": ["@types/bcryptjs@2.4.6", "", {}, "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ=="],
|
"@types/bcryptjs": ["@types/bcryptjs@2.4.6", "", {}, "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ=="],
|
||||||
|
|
||||||
|
"@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="],
|
||||||
|
|
||||||
|
"@types/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="],
|
||||||
|
|
||||||
|
"@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="],
|
||||||
|
|
||||||
|
"@types/d3-brush": ["@types/d3-brush@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="],
|
||||||
|
|
||||||
|
"@types/d3-chord": ["@types/d3-chord@3.0.6", "", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="],
|
||||||
|
|
||||||
|
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
|
||||||
|
|
||||||
|
"@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="],
|
||||||
|
|
||||||
|
"@types/d3-delaunay": ["@types/d3-delaunay@6.0.4", "", {}, "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="],
|
||||||
|
|
||||||
|
"@types/d3-dispatch": ["@types/d3-dispatch@3.0.6", "", {}, "sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ=="],
|
||||||
|
|
||||||
|
"@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="],
|
||||||
|
|
||||||
|
"@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="],
|
||||||
|
|
||||||
|
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
|
||||||
|
|
||||||
|
"@types/d3-fetch": ["@types/d3-fetch@3.0.7", "", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="],
|
||||||
|
|
||||||
|
"@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="],
|
||||||
|
|
||||||
|
"@types/d3-format": ["@types/d3-format@3.0.4", "", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="],
|
||||||
|
|
||||||
|
"@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="],
|
||||||
|
|
||||||
|
"@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="],
|
||||||
|
|
||||||
|
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
|
||||||
|
|
||||||
|
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
|
||||||
|
|
||||||
|
"@types/d3-polygon": ["@types/d3-polygon@3.0.2", "", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="],
|
||||||
|
|
||||||
|
"@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="],
|
||||||
|
|
||||||
|
"@types/d3-random": ["@types/d3-random@3.0.3", "", {}, "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="],
|
||||||
|
|
||||||
|
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
|
||||||
|
|
||||||
|
"@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="],
|
||||||
|
|
||||||
|
"@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="],
|
||||||
|
|
||||||
|
"@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="],
|
||||||
|
|
||||||
|
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
|
||||||
|
|
||||||
|
"@types/d3-time-format": ["@types/d3-time-format@4.0.3", "", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="],
|
||||||
|
|
||||||
|
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
|
||||||
|
|
||||||
|
"@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="],
|
||||||
|
|
||||||
|
"@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="],
|
||||||
|
|
||||||
"@types/eslint": ["@types/eslint@8.56.12", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-03ruubjWyOHlmljCVoxSuNDdmfZDzsrrz0P2LeJsOXr+ZwFQ+0yQIwNCwt/GYhV7Z31fgtXJTAEs+FYlEL851g=="],
|
"@types/eslint": ["@types/eslint@8.56.12", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-03ruubjWyOHlmljCVoxSuNDdmfZDzsrrz0P2LeJsOXr+ZwFQ+0yQIwNCwt/GYhV7Z31fgtXJTAEs+FYlEL851g=="],
|
||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.6", "", {}, "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="],
|
"@types/estree": ["@types/estree@1.0.6", "", {}, "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="],
|
||||||
|
|
||||||
|
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
|
||||||
|
|
||||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||||
|
|
||||||
"@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
|
"@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
|
||||||
@@ -647,6 +744,8 @@
|
|||||||
|
|
||||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||||
|
|
||||||
|
"classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="],
|
||||||
|
|
||||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||||
|
|
||||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
@@ -675,6 +774,24 @@
|
|||||||
|
|
||||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||||
|
|
||||||
|
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
|
||||||
|
|
||||||
|
"d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
|
||||||
|
|
||||||
|
"d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="],
|
||||||
|
|
||||||
|
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
|
||||||
|
|
||||||
|
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
|
||||||
|
|
||||||
|
"d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="],
|
||||||
|
|
||||||
|
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
|
||||||
|
|
||||||
|
"d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="],
|
||||||
|
|
||||||
|
"d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="],
|
||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
|
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
|
||||||
@@ -1115,6 +1232,10 @@
|
|||||||
|
|
||||||
"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-zoom-pan-pinch": ["react-zoom-pan-pinch@3.7.0", "", { "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-UmReVZ0TxlKzxSbYiAj+LeGRW8s8LraAFTXRAxzMYnNRgGPsxCudwZKVkjvGmjtx7SW/hZamt69NUmGf4xrkXA=="],
|
||||||
|
|
||||||
|
"reactflow": ["reactflow@11.11.4", "", { "dependencies": { "@reactflow/background": "11.3.14", "@reactflow/controls": "11.2.14", "@reactflow/core": "11.11.4", "@reactflow/minimap": "11.7.14", "@reactflow/node-resizer": "2.2.14", "@reactflow/node-toolbar": "1.3.14" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og=="],
|
||||||
|
|
||||||
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
|
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
|
||||||
|
|
||||||
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||||
@@ -1309,6 +1430,8 @@
|
|||||||
|
|
||||||
"zod": ["zod@3.24.1", "", {}, "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="],
|
"zod": ["zod@3.24.1", "", {}, "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="],
|
||||||
|
|
||||||
|
"zustand": ["zustand@4.5.6", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-ibr/n1hBzLLj5Y+yUcU7dYw8p6WnIVzdJbnX+1YpaScvZVF2ziugqHs+LAmHw4lWO9c/zRj+K1ncgWDQuthEdQ=="],
|
||||||
|
|
||||||
"@auth/core/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
"@auth/core/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||||
|
|
||||||
"@auth/core/preact": ["preact@10.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="],
|
"@auth/core/preact": ["preact@10.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="],
|
||||||
|
|||||||
@@ -15,19 +15,19 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
pgadmin:
|
# pgadmin:
|
||||||
image: dpage/pgadmin4
|
# image: dpage/pgadmin4
|
||||||
environment:
|
# environment:
|
||||||
PGADMIN_DEFAULT_EMAIL: admin@admin.com
|
# PGADMIN_DEFAULT_EMAIL: admin@admin.com
|
||||||
PGADMIN_DEFAULT_PASSWORD: admin
|
# PGADMIN_DEFAULT_PASSWORD: admin
|
||||||
PGADMIN_CONFIG_SERVER_MODE: 'False'
|
# PGADMIN_CONFIG_SERVER_MODE: 'False'
|
||||||
ports:
|
# ports:
|
||||||
- "5050:80"
|
# - "5050:80"
|
||||||
volumes:
|
# volumes:
|
||||||
- pgadmin_data:/var/lib/pgadmin
|
# - pgadmin_data:/var/lib/pgadmin
|
||||||
depends_on:
|
# depends_on:
|
||||||
db:
|
# db:
|
||||||
condition: service_healthy
|
# condition: service_healthy
|
||||||
|
|
||||||
minio:
|
minio:
|
||||||
image: minio/minio
|
image: minio/minio
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ export default {
|
|||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: env.DATABASE_URL,
|
url: env.DATABASE_URL,
|
||||||
},
|
},
|
||||||
tablesFilter: ["hristudio_*"],
|
tablesFilter: ["hs_*"],
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|||||||
170
drizzle/0000_adorable_grandmaster.sql
Normal file
170
drizzle/0000_adorable_grandmaster.sql
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
CREATE TYPE "public"."activity_type" AS ENUM('study_created', 'study_updated', 'study_deleted', 'ownership_transferred', 'member_added', 'member_removed', 'member_role_changed', 'participant_added', 'participant_updated', 'participant_removed', 'experiment_created', 'experiment_updated', 'experiment_deleted', 'trial_started', 'trial_completed', 'trial_cancelled', 'invitation_sent', 'invitation_accepted', 'invitation_declined', 'invitation_expired', 'invitation_revoked', 'consent_form_added', 'consent_form_signed', 'metadata_updated', 'data_exported');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."experiment_status" AS ENUM('draft', 'active', 'archived');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."invitation_status" AS ENUM('pending', 'accepted', 'declined', 'expired', 'revoked');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."participant_status" AS ENUM('active', 'inactive', 'completed', 'withdrawn');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."study_activity_type" AS ENUM('member_added', 'member_role_changed', 'study_updated', 'participant_added', 'participant_updated', 'invitation_sent', 'invitation_accepted', 'invitation_declined', 'invitation_expired', 'invitation_revoked');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."study_role" AS ENUM('owner', 'admin', 'principal_investigator', 'wizard', 'researcher', 'observer');--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_account" (
|
||||||
|
"userId" varchar(255) NOT NULL,
|
||||||
|
"type" varchar(255) NOT NULL,
|
||||||
|
"provider" varchar(255) NOT NULL,
|
||||||
|
"providerAccountId" varchar(255) NOT NULL,
|
||||||
|
"refresh_token" text,
|
||||||
|
"access_token" text,
|
||||||
|
"expires_at" integer,
|
||||||
|
"token_type" varchar(255),
|
||||||
|
"scope" varchar(255),
|
||||||
|
"id_token" text,
|
||||||
|
"session_state" varchar(255)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_session" (
|
||||||
|
"sessionToken" varchar(255) PRIMARY KEY NOT NULL,
|
||||||
|
"userId" varchar(255) NOT NULL,
|
||||||
|
"expires" timestamp NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_user" (
|
||||||
|
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||||
|
"email" varchar(255) NOT NULL,
|
||||||
|
"first_name" varchar(255),
|
||||||
|
"last_name" varchar(255),
|
||||||
|
"password" varchar(255),
|
||||||
|
"emailVerified" timestamp,
|
||||||
|
"image" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_verificationToken" (
|
||||||
|
"identifier" varchar(255) NOT NULL,
|
||||||
|
"token" varchar(255) NOT NULL,
|
||||||
|
"expires" timestamp NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_experiment" (
|
||||||
|
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hs_experiment_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||||
|
"study_id" integer NOT NULL,
|
||||||
|
"title" varchar(256) NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"version" integer DEFAULT 1 NOT NULL,
|
||||||
|
"status" "experiment_status" DEFAULT 'draft' NOT NULL,
|
||||||
|
"steps" jsonb DEFAULT '[]'::jsonb,
|
||||||
|
"created_by" varchar(255) NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_participant" (
|
||||||
|
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hs_participant_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||||
|
"study_id" integer NOT NULL,
|
||||||
|
"identifier" varchar(256),
|
||||||
|
"email" varchar(256),
|
||||||
|
"first_name" varchar(256),
|
||||||
|
"last_name" varchar(256),
|
||||||
|
"notes" text,
|
||||||
|
"status" "participant_status" DEFAULT 'active' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_study" (
|
||||||
|
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hs_study_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||||
|
"title" varchar(256) NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"created_by" varchar(255) NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_study_activity" (
|
||||||
|
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hs_study_activity_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||||
|
"study_id" integer NOT NULL,
|
||||||
|
"user_id" varchar(255) NOT NULL,
|
||||||
|
"type" "activity_type" NOT NULL,
|
||||||
|
"description" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_study_invitation" (
|
||||||
|
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hs_study_invitation_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||||
|
"study_id" integer NOT NULL,
|
||||||
|
"email" varchar(255) NOT NULL,
|
||||||
|
"role" "study_role" NOT NULL,
|
||||||
|
"token" varchar(255) NOT NULL,
|
||||||
|
"status" "invitation_status" DEFAULT 'pending' NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone,
|
||||||
|
"created_by" varchar(255) NOT NULL,
|
||||||
|
CONSTRAINT "hs_study_invitation_token_unique" UNIQUE("token")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_study_member" (
|
||||||
|
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hs_study_member_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||||
|
"study_id" integer NOT NULL,
|
||||||
|
"user_id" varchar(255) NOT NULL,
|
||||||
|
"role" "study_role" NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_study_metadata" (
|
||||||
|
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hs_study_metadata_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||||
|
"study_id" integer NOT NULL,
|
||||||
|
"key" varchar(256) NOT NULL,
|
||||||
|
"value" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_permissions" (
|
||||||
|
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hs_permissions_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||||
|
"code" varchar(50) NOT NULL,
|
||||||
|
"name" varchar(100) NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "hs_permissions_code_unique" UNIQUE("code")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_role_permissions" (
|
||||||
|
"role_id" integer NOT NULL,
|
||||||
|
"permission_id" integer NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "hs_role_permissions_role_id_permission_id_pk" PRIMARY KEY("role_id","permission_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_roles" (
|
||||||
|
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hs_roles_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||||
|
"code" varchar(50) NOT NULL,
|
||||||
|
"name" varchar(100) NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "hs_roles_code_unique" UNIQUE("code")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_user_roles" (
|
||||||
|
"user_id" varchar(255) NOT NULL,
|
||||||
|
"role_id" integer NOT NULL,
|
||||||
|
"study_id" integer,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "hs_user_roles_user_id_role_id_study_id_pk" PRIMARY KEY("user_id","role_id","study_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_account" ADD CONSTRAINT "hs_account_userId_hs_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_session" ADD CONSTRAINT "hs_session_userId_hs_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_experiment" ADD CONSTRAINT "hs_experiment_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_experiment" ADD CONSTRAINT "hs_experiment_created_by_hs_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_participant" ADD CONSTRAINT "hs_participant_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_study" ADD CONSTRAINT "hs_study_created_by_hs_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_study_activity" ADD CONSTRAINT "hs_study_activity_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_study_activity" ADD CONSTRAINT "hs_study_activity_user_id_hs_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_study_invitation" ADD CONSTRAINT "hs_study_invitation_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_study_invitation" ADD CONSTRAINT "hs_study_invitation_created_by_hs_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_study_member" ADD CONSTRAINT "hs_study_member_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_study_member" ADD CONSTRAINT "hs_study_member_user_id_hs_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."hs_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_study_metadata" ADD CONSTRAINT "hs_study_metadata_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_role_permissions" ADD CONSTRAINT "hs_role_permissions_role_id_hs_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."hs_roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_role_permissions" ADD CONSTRAINT "hs_role_permissions_permission_id_hs_permissions_id_fk" FOREIGN KEY ("permission_id") REFERENCES "public"."hs_permissions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_user_roles" ADD CONSTRAINT "hs_user_roles_user_id_hs_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."hs_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_user_roles" ADD CONSTRAINT "hs_user_roles_role_id_hs_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."hs_roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_user_roles" ADD CONSTRAINT "hs_user_roles_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
1289
drizzle/meta/0000_snapshot.json
Normal file
1289
drizzle/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,13 @@
|
|||||||
{
|
{
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "postgresql",
|
||||||
"entries": []
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1739338336977,
|
||||||
|
"tag": "0000_adorable_grandmaster",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -27,6 +27,9 @@
|
|||||||
"@aws-sdk/client-s3": "^3.735.0",
|
"@aws-sdk/client-s3": "^3.735.0",
|
||||||
"@aws-sdk/lib-storage": "^3.735.0",
|
"@aws-sdk/lib-storage": "^3.735.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.735.0",
|
"@aws-sdk/s3-request-presigner": "^3.735.0",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||||
"@radix-ui/react-avatar": "^1.1.2",
|
"@radix-ui/react-avatar": "^1.1.2",
|
||||||
@@ -35,6 +38,7 @@
|
|||||||
"@radix-ui/react-dropdown-menu": "^2.1.5",
|
"@radix-ui/react-dropdown-menu": "^2.1.5",
|
||||||
"@radix-ui/react-label": "^2.1.1",
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
"@radix-ui/react-popover": "^1.1.5",
|
"@radix-ui/react-popover": "^1.1.5",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||||
"@radix-ui/react-select": "^2.1.5",
|
"@radix-ui/react-select": "^2.1.5",
|
||||||
"@radix-ui/react-separator": "^1.1.1",
|
"@radix-ui/react-separator": "^1.1.1",
|
||||||
"@radix-ui/react-slider": "^1.2.2",
|
"@radix-ui/react-slider": "^1.2.2",
|
||||||
@@ -65,6 +69,8 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-easy-crop": "^5.2.0",
|
"react-easy-crop": "^5.2.0",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
|
"react-zoom-pan-pinch": "^3.7.0",
|
||||||
|
"reactflow": "^11.11.4",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"sonner": "^1.7.2",
|
"sonner": "^1.7.2",
|
||||||
"superjson": "^2.2.1",
|
"superjson": "^2.2.1",
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { PageContent } from "~/components/layout/page-content";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { use } from "react";
|
||||||
|
import { ExperimentDesigner } from "~/components/experiments/experiment-designer";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
|
import { useToast } from "~/hooks/use-toast";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { type Step } from "~/lib/experiments/types";
|
||||||
|
|
||||||
|
export default function EditExperimentPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string; experimentId: string }>;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const resolvedParams = use(params);
|
||||||
|
const studyId = Number(resolvedParams.id);
|
||||||
|
const experimentId = Number(resolvedParams.experimentId);
|
||||||
|
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [steps, setSteps] = useState<Step[]>([]);
|
||||||
|
|
||||||
|
const { data: study } = api.study.getById.useQuery({ id: studyId });
|
||||||
|
const { data: experiment, isLoading } = api.experiment.getById.useQuery({ id: experimentId });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (experiment) {
|
||||||
|
setTitle(experiment.title);
|
||||||
|
setDescription(experiment.description ?? "");
|
||||||
|
setSteps(experiment.steps);
|
||||||
|
}
|
||||||
|
}, [experiment]);
|
||||||
|
|
||||||
|
const { mutate: updateExperiment, isPending: isUpdating } = api.experiment.update.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Experiment updated successfully",
|
||||||
|
});
|
||||||
|
router.push(`/dashboard/studies/${studyId}/experiments/${experimentId}`);
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const canEdit = study && ["OWNER", "ADMIN", "PRINCIPAL_INVESTIGATOR"]
|
||||||
|
.map(r => r.toLowerCase())
|
||||||
|
.includes(study.role.toLowerCase());
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Loading..."
|
||||||
|
description="Please wait while we load the experiment details"
|
||||||
|
/>
|
||||||
|
<PageContent>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className="animate-pulse">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="h-6 w-1/3 bg-muted rounded" />
|
||||||
|
<div className="h-4 w-1/2 bg-muted rounded mt-2" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-4 w-1/4 bg-muted rounded" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</PageContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!study || !experiment) {
|
||||||
|
return <div>Not found</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canEdit) {
|
||||||
|
return <div>You do not have permission to edit this experiment.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Edit Experiment"
|
||||||
|
description={`Update experiment details for ${study.title}`}
|
||||||
|
/>
|
||||||
|
<PageContent>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Experiment Details</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Update the basic information for your experiment.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="title" className="text-sm font-medium">
|
||||||
|
Title
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
placeholder="Enter experiment title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="description" className="text-sm font-medium">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
placeholder="Enter experiment description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Design Experiment</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Use the designer below to update your experiment flow.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<ExperimentDesigner
|
||||||
|
defaultSteps={steps}
|
||||||
|
onChange={setSteps}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push(`/dashboard/studies/${studyId}/experiments/${experimentId}`)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
updateExperiment({
|
||||||
|
id: experimentId,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
steps,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isUpdating || !title}
|
||||||
|
>
|
||||||
|
{isUpdating ? "Saving..." : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { PageContent } from "~/components/layout/page-content";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Pencil as PencilIcon, Play, Archive } from "lucide-react";
|
||||||
|
import { use } from "react";
|
||||||
|
import { ExperimentDesigner } from "~/components/experiments/experiment-designer";
|
||||||
|
|
||||||
|
export default function ExperimentDetailsPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string; experimentId: string }>;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const resolvedParams = use(params);
|
||||||
|
const studyId = Number(resolvedParams.id);
|
||||||
|
const experimentId = Number(resolvedParams.experimentId);
|
||||||
|
|
||||||
|
const { data: study } = api.study.getById.useQuery({ id: studyId });
|
||||||
|
const { data: experiment, isLoading } = api.experiment.getById.useQuery({ id: experimentId });
|
||||||
|
|
||||||
|
const { mutate: updateExperiment } = api.experiment.update.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const canEdit = study && ["OWNER", "ADMIN", "PRINCIPAL_INVESTIGATOR"]
|
||||||
|
.map(r => r.toLowerCase())
|
||||||
|
.includes(study.role.toLowerCase());
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Loading..."
|
||||||
|
description="Please wait while we load the experiment details"
|
||||||
|
/>
|
||||||
|
<PageContent>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className="animate-pulse">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="h-6 w-1/3 bg-muted rounded" />
|
||||||
|
<div className="h-4 w-1/2 bg-muted rounded mt-2" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-4 w-1/4 bg-muted rounded" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</PageContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!study || !experiment) {
|
||||||
|
return <div>Not found</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title={experiment.title}
|
||||||
|
description={experiment.description ?? "No description provided"}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={
|
||||||
|
experiment.status === "active" ? "default" :
|
||||||
|
experiment.status === "archived" ? "secondary" :
|
||||||
|
"outline"
|
||||||
|
}>
|
||||||
|
{experiment.status}
|
||||||
|
</Badge>
|
||||||
|
{canEdit && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => router.push(`/dashboard/studies/${studyId}/experiments/${experimentId}/edit`)}
|
||||||
|
>
|
||||||
|
<PencilIcon className="h-4 w-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
{experiment.status === "draft" ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => updateExperiment({
|
||||||
|
id: experimentId,
|
||||||
|
title: experiment.title,
|
||||||
|
description: experiment.description,
|
||||||
|
status: "active",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4 mr-2" />
|
||||||
|
Activate
|
||||||
|
</Button>
|
||||||
|
) : experiment.status === "active" ? (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => updateExperiment({
|
||||||
|
id: experimentId,
|
||||||
|
title: experiment.title,
|
||||||
|
description: experiment.description,
|
||||||
|
status: "archived",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Archive className="h-4 w-4 mr-2" />
|
||||||
|
Archive
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PageHeader>
|
||||||
|
<PageContent>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Experiment Flow</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
View the steps and actions in this experiment.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<ExperimentDesigner
|
||||||
|
defaultSteps={experiment.steps}
|
||||||
|
onChange={canEdit ? (steps) => {
|
||||||
|
updateExperiment({
|
||||||
|
id: experimentId,
|
||||||
|
title: experiment.title,
|
||||||
|
description: experiment.description,
|
||||||
|
steps,
|
||||||
|
});
|
||||||
|
} : undefined}
|
||||||
|
readOnly={!canEdit}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</PageContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
src/app/dashboard/studies/[id]/experiments/new/page.tsx
Normal file
139
src/app/dashboard/studies/[id]/experiments/new/page.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { PageContent } from "~/components/layout/page-content";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { use } from "react";
|
||||||
|
import { ExperimentDesigner } from "~/components/experiments/experiment-designer";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
|
import { useToast } from "~/hooks/use-toast";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { type Step } from "~/lib/experiments/types";
|
||||||
|
|
||||||
|
export default function NewExperimentPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const resolvedParams = use(params);
|
||||||
|
const studyId = Number(resolvedParams.id);
|
||||||
|
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [steps, setSteps] = useState<Step[]>([]);
|
||||||
|
|
||||||
|
const { data: study } = api.study.getById.useQuery({ id: studyId });
|
||||||
|
|
||||||
|
const { mutate: createExperiment, isPending: isCreating } = api.experiment.create.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Experiment created successfully",
|
||||||
|
});
|
||||||
|
router.push(`/dashboard/studies/${studyId}/experiments/${data.id}`);
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const canCreateExperiments = study && ["OWNER", "ADMIN", "PRINCIPAL_INVESTIGATOR"]
|
||||||
|
.map(r => r.toLowerCase())
|
||||||
|
.includes(study.role.toLowerCase());
|
||||||
|
|
||||||
|
if (!study) {
|
||||||
|
return <div>Study not found</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canCreateExperiments) {
|
||||||
|
return <div>You do not have permission to create experiments in this study.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Create Experiment"
|
||||||
|
description={`Design a new experiment for ${study.title}`}
|
||||||
|
/>
|
||||||
|
<PageContent>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Experiment Details</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter the basic information for your experiment.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="title" className="text-sm font-medium">
|
||||||
|
Title
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
placeholder="Enter experiment title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="description" className="text-sm font-medium">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
placeholder="Enter experiment description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Design Experiment</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Use the designer below to create your experiment flow.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<ExperimentDesigner
|
||||||
|
onChange={setSteps}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push(`/dashboard/studies/${studyId}/experiments`)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
createExperiment({
|
||||||
|
studyId,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
steps,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isCreating || !title}
|
||||||
|
>
|
||||||
|
{isCreating ? "Creating..." : "Create Experiment"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
src/app/dashboard/studies/[id]/experiments/page.tsx
Normal file
105
src/app/dashboard/studies/[id]/experiments/page.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { PageContent } from "~/components/layout/page-content";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { Plus as PlusIcon, FlaskConical } from "lucide-react";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { use } from "react";
|
||||||
|
|
||||||
|
export default function ExperimentsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const resolvedParams = use(params);
|
||||||
|
const studyId = Number(resolvedParams.id);
|
||||||
|
|
||||||
|
const { data: study } = api.study.getById.useQuery({ id: studyId });
|
||||||
|
const { data: experiments, isLoading } = api.experiment.getByStudyId.useQuery({ studyId });
|
||||||
|
|
||||||
|
const canCreateExperiments = study && ["OWNER", "ADMIN", "PRINCIPAL_INVESTIGATOR"]
|
||||||
|
.map(r => r.toLowerCase())
|
||||||
|
.includes(study.role.toLowerCase());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Experiments"
|
||||||
|
description={study ? `Manage experiments for ${study.title}` : "Loading..."}
|
||||||
|
>
|
||||||
|
{canCreateExperiments && (
|
||||||
|
<Button
|
||||||
|
onClick={() => router.push(`/dashboard/studies/${studyId}/experiments/new`)}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-4 w-4 mr-2" />
|
||||||
|
New Experiment
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</PageHeader>
|
||||||
|
<PageContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="grid gap-6">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<Card key={i} className="animate-pulse">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="h-6 w-1/3 bg-muted rounded" />
|
||||||
|
<div className="h-4 w-1/2 bg-muted rounded mt-2" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-4 w-1/4 bg-muted rounded" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : !experiments || experiments.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FlaskConical className="h-5 w-5" />
|
||||||
|
No Experiments
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{canCreateExperiments
|
||||||
|
? "Get started by creating your first experiment."
|
||||||
|
: "No experiments have been created for this study yet."}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-6">
|
||||||
|
{experiments.map((experiment) => (
|
||||||
|
<Card
|
||||||
|
key={experiment.id}
|
||||||
|
className="hover:bg-muted/50 cursor-pointer transition-colors"
|
||||||
|
onClick={() => router.push(`/dashboard/studies/${studyId}/experiments/${experiment.id}`)}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle>{experiment.title}</CardTitle>
|
||||||
|
<Badge variant={
|
||||||
|
experiment.status === "active" ? "default" :
|
||||||
|
experiment.status === "archived" ? "secondary" :
|
||||||
|
"outline"
|
||||||
|
}>
|
||||||
|
{experiment.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
{experiment.description || "No description provided"}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Version {experiment.version} • {experiment.steps.length} steps
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PageContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
src/app/page.tsx
174
src/app/page.tsx
@@ -1,16 +1,22 @@
|
|||||||
import { getServerAuthSession } from "~/server/auth";
|
import { getServerAuthSession } from "~/server/auth";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { BotIcon } from "lucide-react";
|
import { BotIcon, ArrowRight, Sparkles, Brain, Microscope } from "lucide-react";
|
||||||
import { Logo } from "~/components/logo";
|
import { Logo } from "~/components/logo";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const session = await getServerAuthSession();
|
const session = await getServerAuthSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background relative">
|
||||||
|
{/* Background Gradients */}
|
||||||
|
<div className="pointer-events-none fixed inset-0 flex items-center justify-center opacity-40">
|
||||||
|
<div className="h-[800px] w-[800px] rounded-full bg-gradient-to-r from-primary/20 via-secondary/20 to-background blur-3xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Navigation Bar */}
|
{/* Navigation Bar */}
|
||||||
<nav className="border-b bg-card/50 backdrop-blur supports-[backdrop-filter]:bg-card/50">
|
<nav className="sticky top-0 z-50 border-b bg-background/50 backdrop-blur supports-[backdrop-filter]:bg-background/50">
|
||||||
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
|
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Logo />
|
<Logo />
|
||||||
@@ -36,61 +42,129 @@ export default async function Home() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="container mx-auto px-4 py-24 grid lg:grid-cols-2 gap-12 items-center">
|
<section className="container mx-auto px-4 py-24">
|
||||||
<div>
|
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
||||||
<h1 className="text-4xl font-bold tracking-tight lg:text-6xl">
|
<div className="space-y-6">
|
||||||
Streamline Your HRI Research
|
<div className="inline-flex rounded-lg bg-gradient-to-br from-primary/20 via-secondary/20 to-background p-1 mb-8">
|
||||||
</h1>
|
<span className="rounded-md bg-background/95 px-3 py-1 text-sm backdrop-blur">
|
||||||
<p className="mt-6 text-xl text-muted-foreground">
|
Now with Visual Experiment Designer
|
||||||
A comprehensive platform for designing, executing, and analyzing Wizard-of-Oz experiments in human-robot interaction studies.
|
</span>
|
||||||
</p>
|
</div>
|
||||||
<div className="mt-8 flex flex-col sm:flex-row gap-4">
|
<h1 className="text-4xl font-bold tracking-tight lg:text-6xl bg-gradient-to-br from-foreground via-foreground/90 to-foreground/70 bg-clip-text text-transparent">
|
||||||
{!session ? (
|
Streamline Your HRI Research
|
||||||
<Button size="lg" className="w-full sm:w-auto" asChild>
|
</h1>
|
||||||
<Link href="/auth/signup">Get Started</Link>
|
<p className="text-xl text-muted-foreground">
|
||||||
|
A comprehensive platform for designing, executing, and analyzing Wizard-of-Oz experiments in human-robot interaction studies.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 pt-4">
|
||||||
|
{!session ? (
|
||||||
|
<Button size="lg" className="w-full sm:w-auto group bg-gradient-to-r from-primary to-primary hover:from-primary/90 hover:to-primary" asChild>
|
||||||
|
<Link href="/auth/signup">
|
||||||
|
Get Started
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="lg" className="w-full sm:w-auto group bg-gradient-to-r from-primary to-primary hover:from-primary/90 hover:to-primary" asChild>
|
||||||
|
<Link href="/dashboard">
|
||||||
|
Go to Dashboard
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button size="lg" variant="outline" className="w-full sm:w-auto" asChild>
|
||||||
|
<Link href="https://github.com/soconnor0919/hristudio" target="_blank">
|
||||||
|
View on GitHub
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
</div>
|
||||||
<Button size="lg" className="w-full sm:w-auto" asChild>
|
|
||||||
<Link href="/dashboard">Go to Dashboard</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button size="lg" variant="outline" className="w-full sm:w-auto" asChild>
|
|
||||||
<Link href="https://github.com/soconnor0919/hristudio" target="_blank">
|
|
||||||
View on GitHub
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="relative aspect-square lg:aspect-video">
|
||||||
<div className="relative aspect-video">
|
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-secondary/20 to-background rounded-lg border shadow-xl" />
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-secondary/20 rounded-lg" />
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<BotIcon className="h-32 w-32 text-primary/40" />
|
||||||
<BotIcon className="h-32 w-32 text-primary/40" />
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Features Section */}
|
{/* Features Section */}
|
||||||
<section className="container mx-auto px-4 py-24">
|
<section className="container mx-auto px-4 py-24 space-y-12">
|
||||||
<div className="grid md:grid-cols-3 gap-8">
|
<div className="text-center space-y-4">
|
||||||
<div className="space-y-4">
|
<h2 className="text-3xl font-bold tracking-tight bg-gradient-to-br from-foreground to-foreground/70 bg-clip-text text-transparent inline-block">
|
||||||
<h3 className="text-xl font-semibold">Visual Experiment Design</h3>
|
Powerful Features for HRI Research
|
||||||
<p className="text-muted-foreground">
|
</h2>
|
||||||
Create and configure experiments using an intuitive drag-and-drop interface without extensive coding.
|
<p className="text-muted-foreground max-w-[600px] mx-auto">
|
||||||
</p>
|
Everything you need to design, execute, and analyze your human-robot interaction experiments.
|
||||||
</div>
|
</p>
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-xl font-semibold">Real-time Control</h3>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Execute experiments with synchronized views for wizards and observers, enabling seamless collaboration.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-xl font-semibold">Comprehensive Analysis</h3>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Record, playback, and analyze experimental data with built-in annotation and export tools.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 gap-8">
|
||||||
|
<Card className="group relative overflow-hidden border bg-background/60 backdrop-blur supports-[backdrop-filter]:bg-background/60 hover:shadow-lg transition-all">
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/10 via-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
<CardHeader>
|
||||||
|
<div className="size-12 rounded-lg bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center mb-4">
|
||||||
|
<Sparkles className="size-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>Visual Experiment Design</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Create and configure experiments using an intuitive drag-and-drop interface without extensive coding.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="group relative overflow-hidden border bg-background/60 backdrop-blur supports-[backdrop-filter]:bg-background/60 hover:shadow-lg transition-all">
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/10 via-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
<CardHeader>
|
||||||
|
<div className="size-12 rounded-lg bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center mb-4">
|
||||||
|
<Brain className="size-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>Real-time Control</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Execute experiments with synchronized views for wizards and observers, enabling seamless collaboration.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="group relative overflow-hidden border bg-background/60 backdrop-blur supports-[backdrop-filter]:bg-background/60 hover:shadow-lg transition-all">
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/10 via-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
<CardHeader>
|
||||||
|
<div className="size-12 rounded-lg bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center mb-4">
|
||||||
|
<Microscope className="size-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>Comprehensive Analysis</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Record, playback, and analyze experimental data with built-in annotation and export tools.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* CTA Section */}
|
||||||
|
<section className="container mx-auto px-4 py-24">
|
||||||
|
<Card className="relative overflow-hidden">
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary via-primary to-secondary" />
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_120%,rgba(0,0,0,0)_30%,rgba(0,0,0,0.15)_100%)]" />
|
||||||
|
<CardContent className="relative p-12 flex flex-col items-center text-center space-y-6 text-primary-foreground">
|
||||||
|
<BotIcon className="size-12 mb-4" />
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">
|
||||||
|
Ready to Transform Your Research?
|
||||||
|
</h2>
|
||||||
|
<p className="text-primary-foreground/90 max-w-[600px]">
|
||||||
|
Join the growing community of researchers using HRIStudio to advance human-robot interaction studies.
|
||||||
|
</p>
|
||||||
|
{!session ? (
|
||||||
|
<Button size="lg" variant="secondary" asChild className="mt-4 bg-background/20 hover:bg-background/30">
|
||||||
|
<Link href="/auth/signup">Start Your Journey</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="lg" variant="secondary" asChild className="mt-4 bg-background/20 hover:bg-background/30">
|
||||||
|
<Link href="/dashboard">Go to Dashboard</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
401
src/components/experiments/action-config-dialog.tsx
Normal file
401
src/components/experiments/action-config-dialog.tsx
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "~/components/ui/form";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
|
import { Switch } from "~/components/ui/switch";
|
||||||
|
import { AVAILABLE_ACTIONS } from "~/lib/experiments/actions";
|
||||||
|
import { type ActionType } from "~/lib/experiments/types";
|
||||||
|
|
||||||
|
// Define parameter schemas for each action type
|
||||||
|
const parameterSchemas = {
|
||||||
|
move: z.object({
|
||||||
|
position: z.object({
|
||||||
|
x: z.number(),
|
||||||
|
y: z.number(),
|
||||||
|
z: z.number(),
|
||||||
|
}),
|
||||||
|
speed: z.number().min(0).max(1),
|
||||||
|
easing: z.enum(["linear", "easeIn", "easeOut", "easeInOut"]),
|
||||||
|
}),
|
||||||
|
speak: z.object({
|
||||||
|
text: z.string().min(1),
|
||||||
|
speed: z.number().min(0.5).max(2),
|
||||||
|
pitch: z.number().min(0.5).max(2),
|
||||||
|
volume: z.number().min(0).max(1),
|
||||||
|
}),
|
||||||
|
wait: z.object({
|
||||||
|
duration: z.number().min(0),
|
||||||
|
showCountdown: z.boolean(),
|
||||||
|
}),
|
||||||
|
input: z.object({
|
||||||
|
type: z.enum(["button", "text", "number", "choice"]),
|
||||||
|
prompt: z.string().optional(),
|
||||||
|
options: z.array(z.string()).optional(),
|
||||||
|
timeout: z.number().nullable(),
|
||||||
|
}),
|
||||||
|
gesture: z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
speed: z.number().min(0).max(1),
|
||||||
|
intensity: z.number().min(0).max(1),
|
||||||
|
}),
|
||||||
|
record: z.object({
|
||||||
|
type: z.enum(["start", "stop"]),
|
||||||
|
streams: z.array(z.enum(["video", "audio", "sensors"])),
|
||||||
|
}),
|
||||||
|
condition: z.object({
|
||||||
|
condition: z.string().min(1),
|
||||||
|
trueActions: z.array(z.any()),
|
||||||
|
falseActions: z.array(z.any()).optional(),
|
||||||
|
}),
|
||||||
|
loop: z.object({
|
||||||
|
count: z.number().min(1),
|
||||||
|
actions: z.array(z.any()),
|
||||||
|
}),
|
||||||
|
} satisfies Record<ActionType, z.ZodType<any>>;
|
||||||
|
|
||||||
|
interface ActionConfigDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
type: ActionType;
|
||||||
|
parameters: Record<string, any>;
|
||||||
|
onSubmit: (parameters: Record<string, any>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionConfigDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
type,
|
||||||
|
parameters,
|
||||||
|
onSubmit,
|
||||||
|
}: ActionConfigDialogProps) {
|
||||||
|
const actionConfig = AVAILABLE_ACTIONS.find((a) => a.type === type);
|
||||||
|
if (!actionConfig) return null;
|
||||||
|
|
||||||
|
const schema = parameterSchemas[type];
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: parameters,
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit(data: Record<string, any>) {
|
||||||
|
onSubmit(data);
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Configure {actionConfig.title}</DialogTitle>
|
||||||
|
<DialogDescription>{actionConfig.description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
|
||||||
|
{type === "move" && (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="position.x"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>X Position</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(parseFloat(e.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="position.y"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Y Position</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(parseFloat(e.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="position.z"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Z Position</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(parseFloat(e.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="speed"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Speed</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(parseFloat(e.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Movement speed (0-1)
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="easing"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Easing</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select easing type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="linear">Linear</SelectItem>
|
||||||
|
<SelectItem value="easeIn">Ease In</SelectItem>
|
||||||
|
<SelectItem value="easeOut">Ease Out</SelectItem>
|
||||||
|
<SelectItem value="easeInOut">Ease In Out</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
Movement easing function
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === "speak" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="text"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Text</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Enter text to speak"
|
||||||
|
className="resize-none"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="speed"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Speed</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="0.5"
|
||||||
|
max="2"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(parseFloat(e.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="pitch"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Pitch</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="0.5"
|
||||||
|
max="2"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(parseFloat(e.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="volume"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Volume</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(parseFloat(e.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === "wait" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="duration"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Duration (ms)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="100"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(parseFloat(e.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Wait duration in milliseconds
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="showCountdown"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel className="text-base">
|
||||||
|
Show Countdown
|
||||||
|
</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Display a countdown timer during the wait
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add more action type configurations here */}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">Save Changes</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/components/experiments/action-item.tsx
Normal file
45
src/components/experiments/action-item.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type ActionType } from "~/lib/experiments/types";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
interface ActionItemProps {
|
||||||
|
type: ActionType;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
draggable?: boolean;
|
||||||
|
onDragStart?: (event: React.DragEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionItem({
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
draggable,
|
||||||
|
onDragStart,
|
||||||
|
}: ActionItemProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
draggable={draggable}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-grab items-center gap-3 rounded-lg border bg-card p-3 text-left",
|
||||||
|
"hover:bg-accent hover:text-accent-foreground",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
"active:cursor-grabbing"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border bg-background">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<p className="text-sm font-medium leading-none">{title}</p>
|
||||||
|
{description && (
|
||||||
|
<p className="text-xs text-muted-foreground">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/components/experiments/edges/flow-edge.tsx
Normal file
77
src/components/experiments/edges/flow-edge.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { BaseEdge, EdgeProps, getBezierPath } from "reactflow";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
export function FlowEdge({
|
||||||
|
id,
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
sourcePosition,
|
||||||
|
targetPosition,
|
||||||
|
style = {},
|
||||||
|
markerEnd,
|
||||||
|
}: EdgeProps) {
|
||||||
|
const [edgePath] = getBezierPath({
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
sourcePosition,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
targetPosition,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<BaseEdge path={edgePath} markerEnd={markerEnd} style={style} />
|
||||||
|
<motion.path
|
||||||
|
id={id}
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
strokeWidth: 3,
|
||||||
|
fill: "none",
|
||||||
|
stroke: "hsl(var(--primary))",
|
||||||
|
strokeDasharray: "5,5",
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
d={edgePath}
|
||||||
|
className="react-flow__edge-path"
|
||||||
|
animate={{
|
||||||
|
strokeDashoffset: [0, -10],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 1,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "linear",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<motion.path
|
||||||
|
style={{
|
||||||
|
strokeWidth: 15,
|
||||||
|
fill: "none",
|
||||||
|
stroke: "hsl(var(--primary))",
|
||||||
|
opacity: 0,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
d={edgePath}
|
||||||
|
className="react-flow__edge-interaction"
|
||||||
|
onMouseEnter={(event) => {
|
||||||
|
const path = event.currentTarget.previousSibling as SVGPathElement;
|
||||||
|
if (path) {
|
||||||
|
path.style.opacity = "1";
|
||||||
|
path.style.strokeWidth = "4";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(event) => {
|
||||||
|
const path = event.currentTarget.previousSibling as SVGPathElement;
|
||||||
|
if (path) {
|
||||||
|
path.style.opacity = "0.5";
|
||||||
|
path.style.strokeWidth = "3";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
453
src/components/experiments/experiment-designer.tsx
Normal file
453
src/components/experiments/experiment-designer.tsx
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef } from "react";
|
||||||
|
import ReactFlow, {
|
||||||
|
Background,
|
||||||
|
Controls,
|
||||||
|
MiniMap,
|
||||||
|
type Node,
|
||||||
|
type Edge,
|
||||||
|
type Connection,
|
||||||
|
type NodeChange,
|
||||||
|
type EdgeChange,
|
||||||
|
applyNodeChanges,
|
||||||
|
applyEdgeChanges,
|
||||||
|
ReactFlowProvider,
|
||||||
|
Panel,
|
||||||
|
} from "reactflow";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { type Step } from "~/lib/experiments/types";
|
||||||
|
import { AVAILABLE_ACTIONS } from "~/lib/experiments/actions";
|
||||||
|
import { Card } from "~/components/ui/card";
|
||||||
|
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
||||||
|
import { ActionNode } from "./nodes/action-node";
|
||||||
|
import { FlowEdge } from "./edges/flow-edge";
|
||||||
|
import { ActionItem } from "./action-item";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { ChevronLeft, ChevronRight, Undo, Redo, ZoomIn, ZoomOut } from "lucide-react";
|
||||||
|
import "reactflow/dist/style.css";
|
||||||
|
|
||||||
|
const nodeTypes = {
|
||||||
|
action: ActionNode,
|
||||||
|
};
|
||||||
|
|
||||||
|
const edgeTypes = {
|
||||||
|
default: FlowEdge,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ExperimentDesignerProps {
|
||||||
|
className?: string;
|
||||||
|
defaultSteps?: Step[];
|
||||||
|
onChange?: (steps: Step[]) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExperimentDesigner({
|
||||||
|
className,
|
||||||
|
defaultSteps = [],
|
||||||
|
onChange,
|
||||||
|
readOnly = false,
|
||||||
|
}: ExperimentDesignerProps) {
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
|
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||||
|
const [reactFlowInstance, setReactFlowInstance] = useState<any>(null);
|
||||||
|
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
|
||||||
|
|
||||||
|
// History management for undo/redo
|
||||||
|
const [history, setHistory] = useState<Step[][]>([defaultSteps]);
|
||||||
|
const [historyIndex, setHistoryIndex] = useState(0);
|
||||||
|
|
||||||
|
const addToHistory = useCallback((newSteps: Step[]) => {
|
||||||
|
setHistory((h) => {
|
||||||
|
const newHistory = h.slice(0, historyIndex + 1);
|
||||||
|
return [...newHistory, newSteps];
|
||||||
|
});
|
||||||
|
setHistoryIndex((i) => i + 1);
|
||||||
|
}, [historyIndex]);
|
||||||
|
|
||||||
|
const undo = useCallback(() => {
|
||||||
|
if (historyIndex > 0) {
|
||||||
|
setHistoryIndex((i) => i - 1);
|
||||||
|
setSteps(history[historyIndex - 1]!);
|
||||||
|
onChange?.(history[historyIndex - 1]!);
|
||||||
|
}
|
||||||
|
}, [history, historyIndex, onChange]);
|
||||||
|
|
||||||
|
const redo = useCallback(() => {
|
||||||
|
if (historyIndex < history.length - 1) {
|
||||||
|
setHistoryIndex((i) => i + 1);
|
||||||
|
setSteps(history[historyIndex + 1]!);
|
||||||
|
onChange?.(history[historyIndex + 1]!);
|
||||||
|
}
|
||||||
|
}, [history, historyIndex, onChange]);
|
||||||
|
|
||||||
|
// Convert steps to nodes and edges
|
||||||
|
const initialNodes: Node[] = defaultSteps.flatMap((step, stepIndex) =>
|
||||||
|
step.actions.map((action, actionIndex) => ({
|
||||||
|
id: action.id,
|
||||||
|
type: "action",
|
||||||
|
position: { x: stepIndex * 250, y: actionIndex * 150 },
|
||||||
|
data: {
|
||||||
|
type: action.type,
|
||||||
|
parameters: action.parameters,
|
||||||
|
onChange: (parameters: Record<string, any>) => {
|
||||||
|
const newSteps = [...steps];
|
||||||
|
const stepIndex = newSteps.findIndex(s =>
|
||||||
|
s.actions.some(a => a.id === action.id)
|
||||||
|
);
|
||||||
|
const actionIndex = stepIndex !== -1
|
||||||
|
? newSteps[stepIndex]!.actions.findIndex(
|
||||||
|
a => a.id === action.id
|
||||||
|
)
|
||||||
|
: -1;
|
||||||
|
|
||||||
|
if (
|
||||||
|
stepIndex !== -1 &&
|
||||||
|
actionIndex !== -1 &&
|
||||||
|
newSteps[stepIndex]?.actions[actionIndex]
|
||||||
|
) {
|
||||||
|
const step = newSteps[stepIndex]!;
|
||||||
|
const updatedAction = { ...step.actions[actionIndex]!, parameters };
|
||||||
|
step.actions[actionIndex] = updatedAction;
|
||||||
|
setSteps(newSteps);
|
||||||
|
addToHistory(newSteps);
|
||||||
|
onChange?.(newSteps);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialEdges: Edge[] = defaultSteps.flatMap((step, stepIndex) =>
|
||||||
|
step.actions.slice(0, -1).map((action, actionIndex) => ({
|
||||||
|
id: `${action.id}-${step.actions[actionIndex + 1]?.id}`,
|
||||||
|
source: action.id,
|
||||||
|
target: step.actions[actionIndex + 1]?.id ?? "",
|
||||||
|
type: "default",
|
||||||
|
animated: true,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const [nodes, setNodes] = useState<Node[]>(initialNodes);
|
||||||
|
const [edges, setEdges] = useState<Edge[]>(initialEdges);
|
||||||
|
const [steps, setSteps] = useState<Step[]>(defaultSteps);
|
||||||
|
|
||||||
|
const onNodesChange = useCallback(
|
||||||
|
(changes: NodeChange[]) => {
|
||||||
|
setNodes((nds) => {
|
||||||
|
const newNodes = applyNodeChanges(changes, nds);
|
||||||
|
// Update selected node
|
||||||
|
const selectedChange = changes.find((c) => c.type === "select");
|
||||||
|
if (selectedChange) {
|
||||||
|
const selected = newNodes.find((n) => n.id === selectedChange.id);
|
||||||
|
setSelectedNode(selected ?? null);
|
||||||
|
}
|
||||||
|
return newNodes;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onEdgesChange = useCallback(
|
||||||
|
(changes: EdgeChange[]) => {
|
||||||
|
setEdges((eds) => applyEdgeChanges(changes, eds));
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onConnect = useCallback(
|
||||||
|
(connection: Connection) => {
|
||||||
|
const newEdge: Edge = {
|
||||||
|
id: `${connection.source}-${connection.target}`,
|
||||||
|
source: connection.source ?? "",
|
||||||
|
target: connection.target ?? "",
|
||||||
|
type: "default",
|
||||||
|
animated: true,
|
||||||
|
};
|
||||||
|
setEdges((eds) => [...eds, newEdge]);
|
||||||
|
|
||||||
|
const sourceNode = nodes.find((n) => n.id === connection.source);
|
||||||
|
const targetNode = nodes.find((n) => n.id === connection.target);
|
||||||
|
if (sourceNode && targetNode) {
|
||||||
|
const newSteps = [...steps];
|
||||||
|
const sourceStep = newSteps.find((s) =>
|
||||||
|
s.actions.some((a) => a.id === sourceNode.id)
|
||||||
|
);
|
||||||
|
const targetStep = newSteps.find((s) =>
|
||||||
|
s.actions.some((a) => a.id === targetNode.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sourceStep && targetStep) {
|
||||||
|
const sourceAction = sourceStep.actions.find(
|
||||||
|
(a) => a.id === sourceNode.id
|
||||||
|
);
|
||||||
|
const targetAction = targetStep.actions.find(
|
||||||
|
(a) => a.id === targetNode.id
|
||||||
|
);
|
||||||
|
if (sourceAction && targetAction) {
|
||||||
|
const targetStepIndex = newSteps.indexOf(targetStep);
|
||||||
|
newSteps[targetStepIndex]!.actions = targetStep.actions.filter(
|
||||||
|
(a) => a.id !== targetAction.id
|
||||||
|
);
|
||||||
|
const sourceStepIndex = newSteps.indexOf(sourceStep);
|
||||||
|
const sourceActionIndex = sourceStep.actions.indexOf(sourceAction);
|
||||||
|
newSteps[sourceStepIndex]!.actions.splice(
|
||||||
|
sourceActionIndex + 1,
|
||||||
|
0,
|
||||||
|
targetAction
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSteps(newSteps);
|
||||||
|
addToHistory(newSteps);
|
||||||
|
onChange?.(newSteps);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[nodes, steps, onChange, addToHistory]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDragOver = useCallback((event: React.DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = "move";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onDrop = useCallback(
|
||||||
|
(event: React.DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!reactFlowWrapper.current || !reactFlowInstance) return;
|
||||||
|
|
||||||
|
const type = event.dataTransfer.getData("application/reactflow");
|
||||||
|
if (!type) return;
|
||||||
|
|
||||||
|
const position = reactFlowInstance.screenToFlowPosition({
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionConfig = AVAILABLE_ACTIONS.find((a) => a.type === type);
|
||||||
|
if (!actionConfig) return;
|
||||||
|
|
||||||
|
const newAction = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
type: actionConfig.type,
|
||||||
|
parameters: { ...actionConfig.defaultParameters },
|
||||||
|
order: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const newNode: Node = {
|
||||||
|
id: newAction.id,
|
||||||
|
type: "action",
|
||||||
|
position,
|
||||||
|
data: {
|
||||||
|
type: actionConfig.type,
|
||||||
|
parameters: newAction.parameters,
|
||||||
|
onChange: (parameters: Record<string, any>) => {
|
||||||
|
const newSteps = [...steps];
|
||||||
|
const stepIndex = newSteps.findIndex((s) =>
|
||||||
|
s.actions.some((a) => a.id === newAction.id)
|
||||||
|
);
|
||||||
|
const actionIndex = stepIndex !== -1
|
||||||
|
? newSteps[stepIndex]!.actions.findIndex(
|
||||||
|
a => a.id === newAction.id
|
||||||
|
)
|
||||||
|
: -1;
|
||||||
|
|
||||||
|
if (
|
||||||
|
stepIndex !== -1 &&
|
||||||
|
actionIndex !== -1 &&
|
||||||
|
newSteps[stepIndex]?.actions[actionIndex]
|
||||||
|
) {
|
||||||
|
const step = newSteps[stepIndex]!;
|
||||||
|
const updatedAction = { ...step.actions[actionIndex]!, parameters };
|
||||||
|
step.actions[actionIndex] = updatedAction;
|
||||||
|
setSteps(newSteps);
|
||||||
|
addToHistory(newSteps);
|
||||||
|
onChange?.(newSteps);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
setNodes((nds) => [...nds, newNode]);
|
||||||
|
|
||||||
|
const newStep: Step = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
title: `Step ${steps.length + 1}`,
|
||||||
|
actions: [newAction],
|
||||||
|
order: steps.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
setSteps((s) => [...s, newStep]);
|
||||||
|
addToHistory([...steps, newStep]);
|
||||||
|
onChange?.([...steps, newStep]);
|
||||||
|
},
|
||||||
|
[steps, onChange, reactFlowInstance, addToHistory]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("relative flex h-[calc(100vh-16rem)]", className)}>
|
||||||
|
<AnimatePresence>
|
||||||
|
{sidebarOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ x: -320, opacity: 0 }}
|
||||||
|
animate={{ x: 0, opacity: 1 }}
|
||||||
|
exit={{ x: -320, opacity: 0 }}
|
||||||
|
transition={{ type: "spring", damping: 20, stiffness: 300 }}
|
||||||
|
className="absolute inset-y-0 left-0 z-30 w-80 overflow-hidden"
|
||||||
|
>
|
||||||
|
<Card className="flex h-full flex-col rounded-r-none border-r-0 shadow-2xl">
|
||||||
|
<Tabs defaultValue="actions" className="flex h-full flex-col">
|
||||||
|
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="actions">Actions</TabsTrigger>
|
||||||
|
<TabsTrigger value="properties">Properties</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<TabsContent value="actions" className="flex-1 p-0">
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="space-y-2 p-4">
|
||||||
|
{AVAILABLE_ACTIONS.map((action) => (
|
||||||
|
<ActionItem
|
||||||
|
key={action.type}
|
||||||
|
type={action.type}
|
||||||
|
title={action.title}
|
||||||
|
description={action.description}
|
||||||
|
icon={action.icon}
|
||||||
|
draggable
|
||||||
|
onDragStart={(event) => {
|
||||||
|
event.dataTransfer.setData(
|
||||||
|
"application/reactflow",
|
||||||
|
action.type
|
||||||
|
);
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="properties" className="flex-1 p-0">
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="p-4">
|
||||||
|
{selectedNode ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="font-medium">
|
||||||
|
{AVAILABLE_ACTIONS.find((a) => a.type === selectedNode.data.type)?.title}
|
||||||
|
</h3>
|
||||||
|
<pre className="rounded-lg bg-muted p-4 text-xs">
|
||||||
|
{JSON.stringify(selectedNode.data.parameters, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Select a node to view its properties
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{!sidebarOpen && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="absolute left-4 top-4 z-20"
|
||||||
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={reactFlowWrapper}
|
||||||
|
className={cn(
|
||||||
|
"relative h-full flex-1 transition-[margin] duration-200 ease-in-out",
|
||||||
|
sidebarOpen && "ml-80"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
onNodesChange={onNodesChange}
|
||||||
|
onEdgesChange={onEdgesChange}
|
||||||
|
onConnect={onConnect}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDrop={onDrop}
|
||||||
|
onInit={setReactFlowInstance}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
edgeTypes={edgeTypes}
|
||||||
|
fitView
|
||||||
|
className="react-flow-wrapper"
|
||||||
|
>
|
||||||
|
<Background />
|
||||||
|
<Controls />
|
||||||
|
<MiniMap
|
||||||
|
nodeColor={(node) => {
|
||||||
|
const action = AVAILABLE_ACTIONS.find(
|
||||||
|
(a) => a.type === node.data.type
|
||||||
|
);
|
||||||
|
return action ? "hsl(var(--primary) / 0.5)" : "hsl(var(--muted))"
|
||||||
|
}}
|
||||||
|
maskColor="hsl(var(--background))"
|
||||||
|
className="!bg-card/80 !border !border-border rounded-lg backdrop-blur"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "hsl(var(--card))",
|
||||||
|
borderRadius: "var(--radius)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Panel position="top-center" className="flex gap-2 rounded-lg bg-background/95 px-4 py-2 shadow-md backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={undo}
|
||||||
|
disabled={historyIndex === 0}
|
||||||
|
>
|
||||||
|
<Undo className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={redo}
|
||||||
|
disabled={historyIndex === history.length - 1}
|
||||||
|
>
|
||||||
|
<Redo className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="mx-2 w-px bg-border" />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => reactFlowInstance?.zoomIn()}
|
||||||
|
>
|
||||||
|
<ZoomIn className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => reactFlowInstance?.zoomOut()}
|
||||||
|
>
|
||||||
|
<ZoomOut className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Panel>
|
||||||
|
</ReactFlow>
|
||||||
|
</ReactFlowProvider>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
src/components/experiments/nodes/action-node.tsx
Normal file
127
src/components/experiments/nodes/action-node.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo, useState } from "react";
|
||||||
|
import { Handle, Position, type NodeProps } from "reactflow";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { AVAILABLE_ACTIONS } from "~/lib/experiments/actions";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Settings, ArrowDown, ArrowUp } from "lucide-react";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
import { ActionConfigDialog } from "../action-config-dialog";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
|
||||||
|
|
||||||
|
interface ActionNodeData {
|
||||||
|
type: string;
|
||||||
|
parameters: Record<string, any>;
|
||||||
|
onChange?: (parameters: Record<string, any>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ActionNode = memo(({ data, selected }: NodeProps<ActionNodeData>) => {
|
||||||
|
const [configOpen, setConfigOpen] = useState(false);
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const actionConfig = AVAILABLE_ACTIONS.find((a) => a.type === data.type);
|
||||||
|
if (!actionConfig) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.8, opacity: 0 }}
|
||||||
|
animate={{
|
||||||
|
scale: 1,
|
||||||
|
opacity: 1,
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
className={cn(
|
||||||
|
"relative",
|
||||||
|
"before:absolute before:inset-[-2px] before:rounded-xl before:bg-gradient-to-br before:from-border before:to-border/50 before:opacity-100",
|
||||||
|
"after:absolute after:inset-[-1px] after:rounded-xl after:bg-gradient-to-br after:from-background after:to-background",
|
||||||
|
selected && "before:from-primary/50 before:to-primary/20",
|
||||||
|
isHovered && "before:from-border/80 before:to-border/30",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Card className="relative z-10 w-[250px] bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-none">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-gradient-to-br from-primary/20 to-primary/10 text-primary">
|
||||||
|
{actionConfig.icon}
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-sm font-medium leading-none">
|
||||||
|
{actionConfig.title}
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
onClick={() => setConfigOpen(true)}
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 pt-0">
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
{actionConfig.description}
|
||||||
|
</CardDescription>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Top}
|
||||||
|
className={cn(
|
||||||
|
"!h-3 !w-3 !border-2 !bg-background",
|
||||||
|
"!border-border transition-colors duration-200",
|
||||||
|
"data-[connecting=true]:!border-primary data-[connecting=true]:!bg-primary",
|
||||||
|
"before:absolute before:inset-[-4px] before:rounded-full before:border-2 before:border-background",
|
||||||
|
"after:absolute after:inset-[-8px] after:rounded-full after:border-2 after:border-border/50"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="flex items-center gap-2">
|
||||||
|
<ArrowDown className="h-3 w-3" />
|
||||||
|
Input Connection
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
className={cn(
|
||||||
|
"!h-3 !w-3 !border-2 !bg-background",
|
||||||
|
"!border-border transition-colors duration-200",
|
||||||
|
"data-[connecting=true]:!border-primary data-[connecting=true]:!bg-primary",
|
||||||
|
"before:absolute before:inset-[-4px] before:rounded-full before:border-2 before:border-background",
|
||||||
|
"after:absolute after:inset-[-8px] after:rounded-full after:border-2 after:border-border/50"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" className="flex items-center gap-2">
|
||||||
|
<ArrowUp className="h-3 w-3" />
|
||||||
|
Output Connection
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<ActionConfigDialog
|
||||||
|
open={configOpen}
|
||||||
|
onOpenChange={setConfigOpen}
|
||||||
|
type={data.type as any}
|
||||||
|
parameters={data.parameters}
|
||||||
|
onSubmit={data.onChange ?? (() => {})}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
User,
|
User,
|
||||||
Microscope,
|
Microscope,
|
||||||
Users,
|
Users,
|
||||||
Plus
|
Plus,
|
||||||
|
FlaskConical
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { useSession } from "next-auth/react"
|
import { useSession } from "next-auth/react"
|
||||||
@@ -74,6 +75,22 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Experiments",
|
||||||
|
url: `/dashboard/studies/${activeStudy.id}/experiments`,
|
||||||
|
icon: FlaskConical,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "All Experiments",
|
||||||
|
url: `/dashboard/studies/${activeStudy.id}/experiments`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Create Experiment",
|
||||||
|
url: `/dashboard/studies/${activeStudy.id}/experiments/new`,
|
||||||
|
hidden: !["OWNER", "ADMIN", "PRINCIPAL_INVESTIGATOR"].map(r => r.toLowerCase()).includes(activeStudy.role.toLowerCase()),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
: []
|
: []
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function CreateStudyForm() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const createStudy = api.study.create.useMutation({
|
const { mutate, isPending } = api.study.create.useMutation({
|
||||||
onSuccess: (study) => {
|
onSuccess: (study) => {
|
||||||
toast({
|
toast({
|
||||||
title: "Study created",
|
title: "Study created",
|
||||||
@@ -66,7 +66,7 @@ export function CreateStudyForm() {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
createStudy.mutate(data);
|
mutate(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -120,9 +120,9 @@ export function CreateStudyForm() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={createStudy.isLoading || status !== "authenticated"}
|
disabled={isPending || status !== "authenticated"}
|
||||||
>
|
>
|
||||||
{createStudy.isLoading ? "Creating..." : "Create Study"}
|
{isPending ? "Creating..." : "Create Study"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
48
src/components/ui/scroll-area.tsx
Normal file
48
src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
const ScrollArea = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn("relative overflow-hidden", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
));
|
||||||
|
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const ScrollBar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
ref={ref}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none select-none transition-colors",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
));
|
||||||
|
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar };
|
||||||
110
src/lib/experiments/actions.tsx
Normal file
110
src/lib/experiments/actions.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Move,
|
||||||
|
MessageSquare,
|
||||||
|
Clock,
|
||||||
|
KeyboardIcon,
|
||||||
|
Pointer,
|
||||||
|
Video,
|
||||||
|
GitBranch,
|
||||||
|
Repeat
|
||||||
|
} from "lucide-react";
|
||||||
|
import { type ActionType } from "./types";
|
||||||
|
|
||||||
|
interface ActionConfig {
|
||||||
|
type: ActionType;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
defaultParameters: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AVAILABLE_ACTIONS: ActionConfig[] = [
|
||||||
|
{
|
||||||
|
type: "move",
|
||||||
|
title: "Move Robot",
|
||||||
|
description: "Move the robot to a specific position",
|
||||||
|
icon: <Move className="h-4 w-4" />,
|
||||||
|
defaultParameters: {
|
||||||
|
position: { x: 0, y: 0, z: 0 },
|
||||||
|
speed: 1,
|
||||||
|
easing: "linear",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "speak",
|
||||||
|
title: "Robot Speech",
|
||||||
|
description: "Make the robot say something",
|
||||||
|
icon: <MessageSquare className="h-4 w-4" />,
|
||||||
|
defaultParameters: {
|
||||||
|
text: "",
|
||||||
|
speed: 1,
|
||||||
|
pitch: 1,
|
||||||
|
volume: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "wait",
|
||||||
|
title: "Wait",
|
||||||
|
description: "Pause for a specified duration",
|
||||||
|
icon: <Clock className="h-4 w-4" />,
|
||||||
|
defaultParameters: {
|
||||||
|
duration: 1000,
|
||||||
|
showCountdown: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "input",
|
||||||
|
title: "User Input",
|
||||||
|
description: "Wait for participant response",
|
||||||
|
icon: <KeyboardIcon className="h-4 w-4" />,
|
||||||
|
defaultParameters: {
|
||||||
|
type: "button",
|
||||||
|
prompt: "Please respond",
|
||||||
|
timeout: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "gesture",
|
||||||
|
title: "Gesture",
|
||||||
|
description: "Perform a predefined gesture",
|
||||||
|
icon: <Pointer className="h-4 w-4" />,
|
||||||
|
defaultParameters: {
|
||||||
|
name: "",
|
||||||
|
speed: 1,
|
||||||
|
intensity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "record",
|
||||||
|
title: "Record",
|
||||||
|
description: "Start or stop recording",
|
||||||
|
icon: <Video className="h-4 w-4" />,
|
||||||
|
defaultParameters: {
|
||||||
|
type: "start",
|
||||||
|
streams: ["video"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "condition",
|
||||||
|
title: "Condition",
|
||||||
|
description: "Branch based on a condition",
|
||||||
|
icon: <GitBranch className="h-4 w-4" />,
|
||||||
|
defaultParameters: {
|
||||||
|
condition: "",
|
||||||
|
trueActions: [],
|
||||||
|
falseActions: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "loop",
|
||||||
|
title: "Loop",
|
||||||
|
description: "Repeat a sequence of actions",
|
||||||
|
icon: <Repeat className="h-4 w-4" />,
|
||||||
|
defaultParameters: {
|
||||||
|
count: 1,
|
||||||
|
actions: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
85
src/lib/experiments/types.ts
Normal file
85
src/lib/experiments/types.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
export type ActionType =
|
||||||
|
| "move" // Robot movement
|
||||||
|
| "speak" // Robot speech
|
||||||
|
| "wait" // Wait for a duration
|
||||||
|
| "input" // Wait for user input
|
||||||
|
| "gesture" // Robot gesture
|
||||||
|
| "record" // Start/stop recording
|
||||||
|
| "condition" // Conditional branching
|
||||||
|
| "loop"; // Repeat actions
|
||||||
|
|
||||||
|
export interface Action {
|
||||||
|
id: string;
|
||||||
|
type: ActionType;
|
||||||
|
parameters: Record<string, any>;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Step {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
actions: Action[];
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Experiment {
|
||||||
|
id: number;
|
||||||
|
studyId: number;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
version: number;
|
||||||
|
status: "draft" | "active" | "archived";
|
||||||
|
steps: Step[];
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action Parameters by Type
|
||||||
|
export interface MoveParameters {
|
||||||
|
position: { x: number; y: number; z: number };
|
||||||
|
speed?: number;
|
||||||
|
easing?: "linear" | "easeIn" | "easeOut" | "easeInOut";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeakParameters {
|
||||||
|
text: string;
|
||||||
|
voice?: string;
|
||||||
|
speed?: number;
|
||||||
|
pitch?: number;
|
||||||
|
volume?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WaitParameters {
|
||||||
|
duration: number; // in milliseconds
|
||||||
|
showCountdown?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InputParameters {
|
||||||
|
prompt?: string;
|
||||||
|
type: "button" | "text" | "number" | "choice";
|
||||||
|
options?: string[]; // for choice type
|
||||||
|
timeout?: number; // optional timeout in milliseconds
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GestureParameters {
|
||||||
|
name: string;
|
||||||
|
speed?: number;
|
||||||
|
intensity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordParameters {
|
||||||
|
type: "start" | "stop";
|
||||||
|
streams: ("video" | "audio" | "sensors")[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConditionParameters {
|
||||||
|
condition: string; // JavaScript expression
|
||||||
|
trueActions: Action[];
|
||||||
|
falseActions?: Action[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoopParameters {
|
||||||
|
count: number;
|
||||||
|
actions: Action[];
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createTRPCRouter } from "~/server/api/trpc";
|
import { createTRPCRouter } from "~/server/api/trpc";
|
||||||
import { studyRouter } from "~/server/api/routers/study";
|
import { studyRouter } from "./routers/study";
|
||||||
import { participantRouter } from "~/server/api/routers/participant";
|
import { participantRouter } from "./routers/participant";
|
||||||
import { userRouter } from "~/server/api/routers/user";
|
import { experimentRouter } from "./routers/experiment";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is the primary router for your server.
|
* This is the primary router for your server.
|
||||||
@@ -11,7 +11,7 @@ import { userRouter } from "~/server/api/routers/user";
|
|||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
study: studyRouter,
|
study: studyRouter,
|
||||||
participant: participantRouter,
|
participant: participantRouter,
|
||||||
user: userRouter,
|
experiment: experimentRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
@@ -1,178 +1,144 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||||
import { experiments, studyMembers } from "~/server/db/schema";
|
import { experiments, studyMembers } from "~/server/db/schema";
|
||||||
|
import { type Step } from "~/lib/experiments/types";
|
||||||
|
|
||||||
const createExperimentSchema = z.object({
|
const createExperimentSchema = z.object({
|
||||||
studyId: z.string().uuid(),
|
studyId: z.number(),
|
||||||
robotId: z.string().uuid(),
|
title: z.string().min(1, "Title is required"),
|
||||||
title: z.string().min(1).max(256),
|
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
estimatedDuration: z.number().int().min(0).optional(),
|
steps: z.array(z.object({
|
||||||
order: z.number().int().min(0),
|
id: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
actions: z.array(z.object({
|
||||||
|
id: z.string(),
|
||||||
|
type: z.string(),
|
||||||
|
parameters: z.record(z.any()),
|
||||||
|
order: z.number(),
|
||||||
|
})),
|
||||||
|
order: z.number(),
|
||||||
|
})).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateExperimentSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
title: z.string().min(1, "Title is required"),
|
||||||
|
description: z.string().optional(),
|
||||||
|
status: z.enum(["draft", "active", "archived"]).optional(),
|
||||||
|
steps: z.array(z.object({
|
||||||
|
id: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
actions: z.array(z.object({
|
||||||
|
id: z.string(),
|
||||||
|
type: z.string(),
|
||||||
|
parameters: z.record(z.any()),
|
||||||
|
order: z.number(),
|
||||||
|
})),
|
||||||
|
order: z.number(),
|
||||||
|
})).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const experimentRouter = createTRPCRouter({
|
export const experimentRouter = createTRPCRouter({
|
||||||
create: protectedProcedure
|
getByStudyId: protectedProcedure
|
||||||
.input(createExperimentSchema)
|
.input(z.object({ studyId: z.number() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
|
// Check if user is a member of the study
|
||||||
const membership = await ctx.db.query.studyMembers.findFirst({
|
const membership = await ctx.db.query.studyMembers.findFirst({
|
||||||
where: eq(studyMembers.studyId, input.studyId),
|
where: and(
|
||||||
|
eq(studyMembers.studyId, input.studyId),
|
||||||
|
eq(studyMembers.userId, ctx.session.user.id),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
throw new TRPCError({
|
|
||||||
code: "NOT_FOUND",
|
|
||||||
message: "Study not found",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (membership.role !== "admin" && membership.role !== "principal_investigator") {
|
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
message: "You don't have permission to create experiments",
|
message: "You do not have permission to view experiments in this study",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const [experiment] = await ctx.db
|
return ctx.db.query.experiments.findMany({
|
||||||
.insert(experiments)
|
where: eq(experiments.studyId, input.studyId),
|
||||||
.values(input)
|
orderBy: experiments.createdAt,
|
||||||
.returning();
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
getById: protectedProcedure
|
||||||
|
.input(z.object({ id: z.number() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const experiment = await ctx.db.query.experiments.findFirst({
|
||||||
|
where: eq(experiments.id, input.id),
|
||||||
|
});
|
||||||
|
|
||||||
if (!experiment) {
|
if (!experiment) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
code: "NOT_FOUND",
|
||||||
message: "Failed to create experiment",
|
message: "Experiment not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has access to the study
|
||||||
|
const membership = await ctx.db.query.studyMembers.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(studyMembers.studyId, experiment.studyId),
|
||||||
|
eq(studyMembers.userId, ctx.session.user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "You do not have permission to view this experiment",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return experiment;
|
return experiment;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
list: protectedProcedure
|
create: protectedProcedure
|
||||||
.input(z.object({ studyId: z.string().uuid() }))
|
.input(createExperimentSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
// Check if user has permission to create experiments
|
||||||
const membership = await ctx.db.query.studyMembers.findFirst({
|
const membership = await ctx.db.query.studyMembers.findFirst({
|
||||||
where: eq(studyMembers.studyId, input.studyId),
|
where: and(
|
||||||
|
eq(studyMembers.studyId, input.studyId),
|
||||||
|
eq(studyMembers.userId, ctx.session.user.id),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!membership) {
|
if (!membership || !["owner", "admin", "principal_investigator"]
|
||||||
|
.includes(membership.role.toLowerCase())) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "FORBIDDEN",
|
||||||
message: "Study not found",
|
message: "You do not have permission to create experiments in this study",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const experimentList = await ctx.db.query.experiments.findMany({
|
const [experiment] = await ctx.db
|
||||||
where: eq(experiments.studyId, input.studyId),
|
.insert(experiments)
|
||||||
orderBy: (experiments, { asc }) => [asc(experiments.order)],
|
.values({
|
||||||
with: {
|
studyId: input.studyId,
|
||||||
robot: true,
|
title: input.title,
|
||||||
},
|
description: input.description,
|
||||||
});
|
steps: input.steps as Step[],
|
||||||
|
version: 1,
|
||||||
|
status: "draft",
|
||||||
|
createdById: ctx.session.user.id,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
return experimentList;
|
return experiment;
|
||||||
}),
|
|
||||||
|
|
||||||
byId: protectedProcedure
|
|
||||||
.input(z.object({ id: z.string().uuid() }))
|
|
||||||
.query(async ({ ctx, input }) => {
|
|
||||||
const experiment = await ctx.db.query.experiments.findFirst({
|
|
||||||
where: eq(experiments.id, input.id),
|
|
||||||
with: {
|
|
||||||
robot: true,
|
|
||||||
study: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!experiment) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "NOT_FOUND",
|
|
||||||
message: "Experiment not found",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const membership = await ctx.db.query.studyMembers.findFirst({
|
|
||||||
where: eq(studyMembers.studyId, experiment.studyId),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "NOT_FOUND",
|
|
||||||
message: "Study not found",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...experiment,
|
|
||||||
role: membership.role,
|
|
||||||
};
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
update: protectedProcedure
|
update: protectedProcedure
|
||||||
.input(
|
.input(updateExperimentSchema)
|
||||||
z.object({
|
|
||||||
id: z.string().uuid(),
|
|
||||||
title: z.string().min(1).max(256).optional(),
|
|
||||||
description: z.string().optional(),
|
|
||||||
estimatedDuration: z.number().int().min(0).optional(),
|
|
||||||
order: z.number().int().min(0).optional(),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
const { id, ...data } = input;
|
|
||||||
|
|
||||||
const experiment = await ctx.db.query.experiments.findFirst({
|
|
||||||
where: eq(experiments.id, id),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!experiment) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "NOT_FOUND",
|
|
||||||
message: "Experiment not found",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const membership = await ctx.db.query.studyMembers.findFirst({
|
|
||||||
where: eq(studyMembers.studyId, experiment.studyId),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "NOT_FOUND",
|
|
||||||
message: "Study not found",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (membership.role !== "admin" && membership.role !== "principal_investigator") {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "FORBIDDEN",
|
|
||||||
message: "You don't have permission to update this experiment",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const [updated] = await ctx.db
|
|
||||||
.update(experiments)
|
|
||||||
.set(data)
|
|
||||||
.where(eq(experiments.id, id))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (!updated) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "NOT_FOUND",
|
|
||||||
message: "Experiment not found",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...updated,
|
|
||||||
role: membership.role,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
delete: protectedProcedure
|
|
||||||
.input(z.object({ id: z.string().uuid() }))
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const experiment = await ctx.db.query.experiments.findFirst({
|
const experiment = await ctx.db.query.experiments.findFirst({
|
||||||
where: eq(experiments.id, input.id),
|
where: eq(experiments.id, input.id),
|
||||||
@@ -185,36 +151,72 @@ export const experimentRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if user has permission to edit experiments
|
||||||
const membership = await ctx.db.query.studyMembers.findFirst({
|
const membership = await ctx.db.query.studyMembers.findFirst({
|
||||||
where: eq(studyMembers.studyId, experiment.studyId),
|
where: and(
|
||||||
|
eq(studyMembers.studyId, experiment.studyId),
|
||||||
|
eq(studyMembers.userId, ctx.session.user.id),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!membership) {
|
if (!membership || !["owner", "admin", "principal_investigator"]
|
||||||
throw new TRPCError({
|
.includes(membership.role.toLowerCase())) {
|
||||||
code: "NOT_FOUND",
|
|
||||||
message: "Study not found",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (membership.role !== "admin" && membership.role !== "principal_investigator") {
|
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
message: "You don't have permission to delete this experiment",
|
message: "You do not have permission to edit this experiment",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const [deleted] = await ctx.db
|
const [updatedExperiment] = await ctx.db
|
||||||
.delete(experiments)
|
.update(experiments)
|
||||||
|
.set({
|
||||||
|
title: input.title,
|
||||||
|
description: input.description,
|
||||||
|
status: input.status,
|
||||||
|
steps: input.steps as Step[] | undefined,
|
||||||
|
version: experiment.version + 1,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
.where(eq(experiments.id, input.id))
|
.where(eq(experiments.id, input.id))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (!deleted) {
|
return updatedExperiment;
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: protectedProcedure
|
||||||
|
.input(z.object({ id: z.number() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const experiment = await ctx.db.query.experiments.findFirst({
|
||||||
|
where: eq(experiments.id, input.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!experiment) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "Experiment not found",
|
message: "Experiment not found",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return deleted;
|
// Check if user has permission to delete experiments
|
||||||
|
const membership = await ctx.db.query.studyMembers.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(studyMembers.studyId, experiment.studyId),
|
||||||
|
eq(studyMembers.userId, ctx.session.user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership || !["owner", "admin", "principal_investigator"]
|
||||||
|
.includes(membership.role.toLowerCase())) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "You do not have permission to delete this experiment",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db
|
||||||
|
.delete(experiments)
|
||||||
|
.where(eq(experiments.id, input.id));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -184,7 +184,7 @@ export const studyRouter = createTRPCRouter({
|
|||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
title: z.string().min(1, "Title is required"),
|
title: z.string().min(1, "Title is required"),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
@@ -198,10 +198,10 @@ export const studyRouter = createTRPCRouter({
|
|||||||
const result = await db
|
const result = await db
|
||||||
.insert(studies)
|
.insert(studies)
|
||||||
.values({
|
.values({
|
||||||
title: input.title,
|
title: input.title,
|
||||||
description: input.description ?? "",
|
description: input.description ?? "",
|
||||||
createdById: ctx.session.user.id,
|
createdById: ctx.session.user.id,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
import { integer, pgEnum, text, timestamp, varchar, serial } from "drizzle-orm/pg-core";
|
import { integer, pgEnum, text, timestamp, varchar, serial, jsonb } from "drizzle-orm/pg-core";
|
||||||
import { ROLES } from "~/lib/permissions/constants";
|
import { ROLES } from "~/lib/permissions/constants";
|
||||||
import { createTable } from "../utils";
|
import { createTable } from "../utils";
|
||||||
import { users } from "./auth";
|
import { users } from "./auth";
|
||||||
|
import { type Step } from "~/lib/experiments/types";
|
||||||
|
|
||||||
// Create enum from role values
|
// Create enum from role values
|
||||||
export const studyRoleEnum = pgEnum("study_role", [
|
export const studyRoleEnum = pgEnum("study_role", [
|
||||||
@@ -73,6 +74,13 @@ export const studyActivityTypeEnum = pgEnum("study_activity_type", [
|
|||||||
"invitation_revoked",
|
"invitation_revoked",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Create enum for experiment status
|
||||||
|
export const experimentStatusEnum = pgEnum("experiment_status", [
|
||||||
|
"draft",
|
||||||
|
"active",
|
||||||
|
"archived",
|
||||||
|
]);
|
||||||
|
|
||||||
export const studies = createTable("study", {
|
export const studies = createTable("study", {
|
||||||
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
|
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
|
||||||
title: varchar("title", { length: 256 }).notNull(),
|
title: varchar("title", { length: 256 }).notNull(),
|
||||||
@@ -136,12 +144,26 @@ export const studyInvitations = createTable("study_invitation", {
|
|||||||
createdById: varchar("created_by", { length: 255 }).notNull().references(() => users.id),
|
createdById: varchar("created_by", { length: 255 }).notNull().references(() => users.id),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const experiments = createTable("experiment", {
|
||||||
|
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
|
||||||
|
studyId: integer("study_id").notNull().references(() => studies.id, { onDelete: "cascade" }),
|
||||||
|
title: varchar("title", { length: 256 }).notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
version: integer("version").notNull().default(1),
|
||||||
|
status: experimentStatusEnum("status").notNull().default("draft"),
|
||||||
|
steps: jsonb("steps").$type<Step[]>().default([]),
|
||||||
|
createdById: varchar("created_by", { length: 255 }).notNull().references(() => users.id),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||||
|
});
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
export const studiesRelations = relations(studies, ({ one, many }) => ({
|
export const studiesRelations = relations(studies, ({ one, many }) => ({
|
||||||
creator: one(users, { fields: [studies.createdById], references: [users.id] }),
|
creator: one(users, { fields: [studies.createdById], references: [users.id] }),
|
||||||
members: many(studyMembers),
|
members: many(studyMembers),
|
||||||
participants: many(participants),
|
participants: many(participants),
|
||||||
invitations: many(studyInvitations),
|
invitations: many(studyInvitations),
|
||||||
|
experiments: many(experiments),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const studyMembersRelations = relations(studyMembers, ({ one }) => ({
|
export const studyMembersRelations = relations(studyMembers, ({ one }) => ({
|
||||||
@@ -157,3 +179,8 @@ export const studyInvitationsRelations = relations(studyInvitations, ({ one }) =
|
|||||||
study: one(studies, { fields: [studyInvitations.studyId], references: [studies.id] }),
|
study: one(studies, { fields: [studyInvitations.studyId], references: [studies.id] }),
|
||||||
creator: one(users, { fields: [studyInvitations.createdById], references: [users.id] }),
|
creator: one(users, { fields: [studyInvitations.createdById], references: [users.id] }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const experimentsRelations = relations(experiments, ({ one }) => ({
|
||||||
|
study: one(studies, { fields: [experiments.studyId], references: [studies.id] }),
|
||||||
|
creator: one(users, { fields: [experiments.createdById], references: [users.id] }),
|
||||||
|
}));
|
||||||
17
structure.md
17
structure.md
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
A *study* is a general term for a research project.
|
A *study* is a general term for a research project.
|
||||||
|
|
||||||
An *experiment* is a specific set of steps and actions that will be conducted with a participant and robot.
|
An *experiment* is a specific set of steps and actions that will be conducted with a participant and robot. Experiments are designed and configured via a dedicated drag and drop experiment designer. This interactive designer features a dotted background—similar to Unreal Engine's IDE drag and drop area—that clearly indicates drop zones. Users can add, reorder, and connect individual steps and actions visually.
|
||||||
|
|
||||||
An *trial* is a specific instance of an experiment. It is a single run of the experiment with a specific participant and robot.
|
An *trial* is a specific instance of an experiment. It is a single run of the experiment with a specific participant and robot.
|
||||||
|
|
||||||
@@ -16,6 +16,21 @@ A *participant* is a person that has been added to a study. This person does not
|
|||||||
|
|
||||||
A *user* is a person that has an account, which is a person that has been added to a study. Anyone can sign up for an account, but they must be added to a study or create their own. A user can have different roles in different studies.
|
A *user* is a person that has an account, which is a person that has been added to a study. Anyone can sign up for an account, but they must be added to a study or create their own. A user can have different roles in different studies.
|
||||||
|
|
||||||
|
## Experiment Design and Implementation
|
||||||
|
|
||||||
|
Experiments are central to HRIStudio and are managed with full CRUD operations. The Experiment Design feature includes:
|
||||||
|
|
||||||
|
- **Drag and Drop Designer:** An interactive design area with a dotted background, reminiscent of Unreal Engine's IDE, which allows users to visually add, reposition, and connect steps and actions. The designer includes:
|
||||||
|
- A dotted grid background that provides visual cues for alignment and spacing
|
||||||
|
- Highlighted drop zones that activate when dragging components
|
||||||
|
- Visual feedback for valid/invalid drop targets
|
||||||
|
- Smooth animations for reordering and nesting
|
||||||
|
- Connection lines showing relationships between steps
|
||||||
|
- A side panel of available actions that can be dragged into steps
|
||||||
|
- **Experiment Templates:** The ability to save and reuse experiment configurations.
|
||||||
|
- **CRUD Operations:** Procedures to create, retrieve, update, and delete experiments associated with a study.
|
||||||
|
- **Dynamic Interaction:** Support for adding and reordering steps, and nesting actions within steps.
|
||||||
|
|
||||||
## Roles and Permissions
|
## Roles and Permissions
|
||||||
|
|
||||||
### Core Roles
|
### Core Roles
|
||||||
|
|||||||
Reference in New Issue
Block a user