mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-02-04 23:46:32 -05:00
Break work
This commit is contained in:
@@ -17,3 +17,10 @@ AUTH_SECRET=""
|
|||||||
|
|
||||||
# Drizzle
|
# Drizzle
|
||||||
DATABASE_URL="postgresql://postgres:password@localhost:5433/hristudio"
|
DATABASE_URL="postgresql://postgres:password@localhost:5433/hristudio"
|
||||||
|
|
||||||
|
# MinIO/S3 Configuration
|
||||||
|
MINIO_ENDPOINT="http://localhost:9000"
|
||||||
|
MINIO_REGION="us-east-1"
|
||||||
|
MINIO_ACCESS_KEY="minioadmin"
|
||||||
|
MINIO_SECRET_KEY="minioadmin"
|
||||||
|
MINIO_BUCKET_NAME="hristudio-data"
|
||||||
|
|||||||
11
bun.lock
11
bun.lock
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 0,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hristudio",
|
"name": "hristudio",
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
"@hookform/resolvers": "^5.1.1",
|
"@hookform/resolvers": "^5.1.1",
|
||||||
"@radix-ui/react-accordion": "^1.2.11",
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||||
|
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
"@radix-ui/react-collapsible": "^1.1.11",
|
"@radix-ui/react-collapsible": "^1.1.11",
|
||||||
@@ -51,6 +53,7 @@
|
|||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-hook-form": "^7.60.0",
|
"react-hook-form": "^7.60.0",
|
||||||
"react-resizable-panels": "^3.0.4",
|
"react-resizable-panels": "^3.0.4",
|
||||||
|
"react-webcam": "^7.2.0",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"superjson": "^2.2.1",
|
"superjson": "^2.2.1",
|
||||||
@@ -382,6 +385,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
|
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "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-5nZrJTF7gH+e0nZS7/QxFz6tJV4VimhQb1avEgtsJxvvIp5JilL+c58HICsKzPxghdwaDt48hEfPM1au4zGy+w=="],
|
||||||
|
|
||||||
"@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="],
|
"@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="],
|
||||||
|
|
||||||
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "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-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA=="],
|
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "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-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA=="],
|
||||||
@@ -1270,6 +1275,8 @@
|
|||||||
|
|
||||||
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||||
|
|
||||||
|
"react-webcam": ["react-webcam@7.2.0", "", { "peerDependencies": { "react": ">=16.2.0", "react-dom": ">=16.2.0" } }, "sha512-xkrzYPqa1ag2DP+2Q/kLKBmCIfEx49bVdgCCCcZf88oF+0NPEbkwYk3/s/C7Zy0mhM8k+hpdNkBLzxg8H0aWcg=="],
|
||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
||||||
@@ -1492,6 +1499,8 @@
|
|||||||
|
|
||||||
"@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.4.5", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg=="],
|
"@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.4.5", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-aspect-ratio/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "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-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||||
|
|
||||||
"@radix-ui/react-menu/@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.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-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q=="],
|
"@radix-ui/react-menu/@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.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-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q=="],
|
||||||
|
|
||||||
"@radix-ui/react-roving-focus/@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
"@radix-ui/react-roving-focus/@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||||
@@ -1606,6 +1615,8 @@
|
|||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-aspect-ratio/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="],
|
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="],
|
||||||
|
|
||||||
"@typescript-eslint/typescript-estree/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
"@typescript-eslint/typescript-estree/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"@hookform/resolvers": "^5.1.1",
|
"@hookform/resolvers": "^5.1.1",
|
||||||
"@radix-ui/react-accordion": "^1.2.11",
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||||
|
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
"@radix-ui/react-collapsible": "^1.1.11",
|
"@radix-ui/react-collapsible": "^1.1.11",
|
||||||
@@ -70,6 +71,7 @@
|
|||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-hook-form": "^7.60.0",
|
"react-hook-form": "^7.60.0",
|
||||||
"react-resizable-panels": "^3.0.4",
|
"react-resizable-panels": "^3.0.4",
|
||||||
|
"react-webcam": "^7.2.0",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"superjson": "^2.2.1",
|
"superjson": "^2.2.1",
|
||||||
|
|||||||
1170
scripts/seed-dev.ts
1170
scripts/seed-dev.ts
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,25 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { Suspense, useEffect } from "react";
|
import { Suspense, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Activity,
|
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Calendar,
|
Search,
|
||||||
Download,
|
|
||||||
Filter,
|
Filter,
|
||||||
TrendingDown,
|
PlayCircle,
|
||||||
TrendingUp,
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
ChevronRight,
|
||||||
|
User,
|
||||||
|
LayoutGrid
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "~/components/ui/button";
|
import { PageHeader } from "~/components/ui/page-header";
|
||||||
import {
|
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||||
Card,
|
import { useStudyContext } from "~/lib/study-context";
|
||||||
CardContent,
|
import { useSelectedStudyDetails } from "~/hooks/useSelectedStudyDetails";
|
||||||
CardDescription,
|
import { api } from "~/trpc/react";
|
||||||
CardHeader,
|
import { TrialAnalysisView } from "~/components/trials/views/TrialAnalysisView";
|
||||||
CardTitle,
|
|
||||||
} from "~/components/ui/card";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -27,283 +27,180 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "~/components/ui/select";
|
} from "~/components/ui/select";
|
||||||
import { PageHeader } from "~/components/ui/page-header";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
import { Button } from "~/components/ui/button";
|
||||||
import { useStudyContext } from "~/lib/study-context";
|
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||||
import { useSelectedStudyDetails } from "~/hooks/useSelectedStudyDetails";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
|
||||||
// Mock chart component - replace with actual charting library
|
// -- Sub-Components --
|
||||||
function MockChart({ title, data }: { title: string; data: number[] }) {
|
|
||||||
const maxValue = Math.max(...data);
|
|
||||||
|
|
||||||
return (
|
function AnalyticsContent({
|
||||||
<div className="space-y-2">
|
selectedTrialId,
|
||||||
<h4 className="text-sm font-medium">{title}</h4>
|
setSelectedTrialId,
|
||||||
<div className="flex h-32 items-end space-x-1">
|
trialsList,
|
||||||
{data.map((value, index) => (
|
isLoadingList
|
||||||
<div
|
}: {
|
||||||
key={index}
|
selectedTrialId: string | null;
|
||||||
className="bg-primary min-h-[4px] flex-1 rounded-t"
|
setSelectedTrialId: (id: string | null) => void;
|
||||||
style={{ height: `${(value / maxValue) * 100}%` }}
|
trialsList: any[];
|
||||||
/>
|
isLoadingList: boolean;
|
||||||
))}
|
}) {
|
||||||
</div>
|
|
||||||
</div>
|
// Fetch full details of selected trial
|
||||||
|
const {
|
||||||
|
data: selectedTrial,
|
||||||
|
isLoading: isLoadingTrial,
|
||||||
|
error: trialError
|
||||||
|
} = api.trials.get.useQuery(
|
||||||
|
{ id: selectedTrialId! },
|
||||||
|
{ enabled: !!selectedTrialId }
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function AnalyticsOverview() {
|
// Transform trial data
|
||||||
const metrics = [
|
const trialData = selectedTrial ? {
|
||||||
{
|
...selectedTrial,
|
||||||
title: "Total Trials This Month",
|
startedAt: selectedTrial.startedAt ? new Date(selectedTrial.startedAt) : null,
|
||||||
value: "142",
|
completedAt: selectedTrial.completedAt ? new Date(selectedTrial.completedAt) : null,
|
||||||
change: "+12%",
|
eventCount: (selectedTrial as any).eventCount,
|
||||||
trend: "up",
|
mediaCount: (selectedTrial as any).mediaCount,
|
||||||
description: "vs last month",
|
} : null;
|
||||||
icon: Activity,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Avg Trial Duration",
|
|
||||||
value: "24.5m",
|
|
||||||
change: "-3%",
|
|
||||||
trend: "down",
|
|
||||||
description: "vs last month",
|
|
||||||
icon: Calendar,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Completion Rate",
|
|
||||||
value: "94.2%",
|
|
||||||
change: "+2.1%",
|
|
||||||
trend: "up",
|
|
||||||
description: "vs last month",
|
|
||||||
icon: TrendingUp,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Participant Retention",
|
|
||||||
value: "87.3%",
|
|
||||||
change: "+5.4%",
|
|
||||||
trend: "up",
|
|
||||||
description: "vs last month",
|
|
||||||
icon: BarChart3,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div className="h-[calc(100vh-140px)] flex flex-col">
|
||||||
{metrics.map((metric) => (
|
{selectedTrialId ? (
|
||||||
<Card key={metric.title}>
|
isLoadingTrial ? (
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<div className="flex-1 flex items-center justify-center bg-background/50 rounded-lg border border-dashed">
|
||||||
<CardTitle className="text-sm font-medium">
|
<div className="flex flex-col items-center gap-2 animate-pulse">
|
||||||
{metric.title}
|
<div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
||||||
</CardTitle>
|
<span className="text-muted-foreground text-sm">Loading trial data...</span>
|
||||||
<metric.icon className="text-muted-foreground h-4 w-4" />
|
</div>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
) : trialError ? (
|
||||||
<div className="text-2xl font-bold">{metric.value}</div>
|
<div className="flex-1 flex items-center justify-center p-8 bg-background/50 rounded-lg border border-dashed text-destructive">
|
||||||
<div className="text-muted-foreground flex items-center space-x-2 text-xs">
|
<div className="max-w-md text-center">
|
||||||
<span
|
<h3 className="font-semibold mb-2">Error Loading Trial</h3>
|
||||||
className={`flex items-center ${
|
<p className="text-sm opacity-80">{trialError.message}</p>
|
||||||
metric.trend === "up" ? "text-green-600" : "text-red-600"
|
<Button variant="outline" className="mt-4" onClick={() => setSelectedTrialId(null)}>
|
||||||
}`}
|
Return to Overview
|
||||||
>
|
</Button>
|
||||||
{metric.trend === "up" ? (
|
</div>
|
||||||
<TrendingUp className="mr-1 h-3 w-3" />
|
</div>
|
||||||
|
) : trialData ? (
|
||||||
|
<TrialAnalysisView trial={trialData} />
|
||||||
|
) : null
|
||||||
) : (
|
) : (
|
||||||
<TrendingDown className="mr-1 h-3 w-3" />
|
<div className="flex-1 bg-background/50 rounded-lg border shadow-sm overflow-hidden">
|
||||||
|
<StudyOverviewPlaceholder
|
||||||
|
trials={trialsList ?? []}
|
||||||
|
onSelect={(id) => setSelectedTrialId(id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{metric.change}
|
|
||||||
</span>
|
|
||||||
<span>{metric.description}</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChartsSection() {
|
function StudyOverviewPlaceholder({ trials, onSelect }: { trials: any[], onSelect: (id: string) => void }) {
|
||||||
const trialData = [12, 19, 15, 27, 32, 28, 35, 42, 38, 41, 37, 44];
|
const recentTrials = [...trials].sort((a, b) =>
|
||||||
const participantData = [8, 12, 10, 15, 18, 16, 20, 24, 22, 26, 23, 28];
|
new Date(b.startedAt || b.createdAt).getTime() - new Date(a.startedAt || a.createdAt).getTime()
|
||||||
const completionData = [85, 88, 92, 89, 94, 91, 95, 92, 96, 94, 97, 94];
|
).slice(0, 5);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 lg:grid-cols-3">
|
<div className="h-full p-8 grid place-items-center bg-muted/5">
|
||||||
<Card>
|
<div className="max-w-3xl w-full grid gap-8 md:grid-cols-2">
|
||||||
<CardHeader>
|
{/* Left: Illustration / Prompt */}
|
||||||
<CardTitle>Trial Volume</CardTitle>
|
<div className="flex flex-col justify-center space-y-4">
|
||||||
<CardDescription>Monthly trial execution trends</CardDescription>
|
<div className="bg-primary/10 w-16 h-16 rounded-2xl flex items-center justify-center mb-2">
|
||||||
</CardHeader>
|
<BarChart3 className="h-8 w-8 text-primary" />
|
||||||
<CardContent>
|
|
||||||
<MockChart title="Trials per Month" data={trialData} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Participant Enrollment</CardTitle>
|
|
||||||
<CardDescription>New participants over time</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<MockChart title="New Participants" data={participantData} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Completion Rates</CardTitle>
|
|
||||||
<CardDescription>Trial completion percentage</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<MockChart title="Completion %" data={completionData} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<div>
|
||||||
}
|
<h2 className="text-2xl font-semibold tracking-tight">Analytics & Playback</h2>
|
||||||
|
<CardDescription className="text-base mt-2">
|
||||||
function RecentInsights() {
|
Select a session from the top right to review video recordings, event logs, and metrics.
|
||||||
const insights = [
|
|
||||||
{
|
|
||||||
title: "Peak Performance Hours",
|
|
||||||
description:
|
|
||||||
"Participants show 23% better performance during 10-11 AM trials",
|
|
||||||
type: "trend",
|
|
||||||
severity: "info",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Attention Span Decline",
|
|
||||||
description:
|
|
||||||
"Average attention span has decreased by 8% over the last month",
|
|
||||||
type: "alert",
|
|
||||||
severity: "warning",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "High Completion Rate",
|
|
||||||
description: "Memory retention study achieved 98% completion rate",
|
|
||||||
type: "success",
|
|
||||||
severity: "success",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Equipment Utilization",
|
|
||||||
description: "Robot interaction trials are at 85% capacity utilization",
|
|
||||||
type: "info",
|
|
||||||
severity: "info",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const getSeverityColor = (severity: string) => {
|
|
||||||
switch (severity) {
|
|
||||||
case "success":
|
|
||||||
return "bg-green-50 text-green-700 border-green-200";
|
|
||||||
case "warning":
|
|
||||||
return "bg-yellow-50 text-yellow-700 border-yellow-200";
|
|
||||||
case "info":
|
|
||||||
return "bg-blue-50 text-blue-700 border-blue-200";
|
|
||||||
default:
|
|
||||||
return "bg-gray-50 text-gray-700 border-gray-200";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Recent Insights</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
AI-generated insights from your research data
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{insights.map((insight, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={`rounded-lg border p-4 ${getSeverityColor(insight.severity)}`}
|
|
||||||
>
|
|
||||||
<h4 className="mb-1 font-medium">{insight.title}</h4>
|
|
||||||
<p className="text-sm">{insight.description}</p>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="flex gap-4 pt-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<PlayCircle className="h-4 w-4" />
|
||||||
|
Feature-rich playback
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
Synchronized timeline
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AnalyticsContent({ studyId: _studyId }: { studyId: string }) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header with time range controls */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Select defaultValue="30d">
|
|
||||||
<SelectTrigger className="w-[120px]">
|
|
||||||
<SelectValue placeholder="Time range" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="7d">Last 7 days</SelectItem>
|
|
||||||
<SelectItem value="30d">Last 30 days</SelectItem>
|
|
||||||
<SelectItem value="90d">Last 90 days</SelectItem>
|
|
||||||
<SelectItem value="1y">Last year</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<Filter className="mr-2 h-4 w-4" />
|
|
||||||
Filter
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
Export
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Overview Metrics */}
|
{/* Right: Recent Sessions */}
|
||||||
<AnalyticsOverview />
|
|
||||||
|
|
||||||
{/* Charts */}
|
|
||||||
<ChartsSection />
|
|
||||||
|
|
||||||
{/* Insights */}
|
|
||||||
<div className="grid gap-4 lg:grid-cols-3">
|
|
||||||
<div className="lg:col-span-2">
|
|
||||||
<RecentInsights />
|
|
||||||
</div>
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="pb-3">
|
||||||
<CardTitle>Quick Actions</CardTitle>
|
<CardTitle className="text-base">Recent Sessions</CardTitle>
|
||||||
<CardDescription>Generate custom reports</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="p-0">
|
||||||
<Button variant="outline" className="w-full justify-start">
|
<ScrollArea className="h-[240px]">
|
||||||
<BarChart3 className="mr-2 h-4 w-4" />
|
<div className="px-4 pb-4 space-y-1">
|
||||||
Trial Performance Report
|
{recentTrials.map(trial => (
|
||||||
</Button>
|
<button
|
||||||
<Button variant="outline" className="w-full justify-start">
|
key={trial.id}
|
||||||
<Activity className="mr-2 h-4 w-4" />
|
onClick={() => onSelect(trial.id)}
|
||||||
Participant Engagement
|
className="w-full flex items-center gap-3 p-3 rounded-md hover:bg-accent transition-colors text-left group"
|
||||||
</Button>
|
>
|
||||||
<Button variant="outline" className="w-full justify-start">
|
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-mono font-medium text-primary">
|
||||||
<TrendingUp className="mr-2 h-4 w-4" />
|
{trial.sessionNumber}
|
||||||
Trend Analysis
|
</div>
|
||||||
</Button>
|
<div className="flex-1 min-w-0">
|
||||||
<Button variant="outline" className="w-full justify-start">
|
<div className="flex items-center gap-2">
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<span className="font-medium text-sm truncate">
|
||||||
Custom Export
|
{trial.participant?.participantCode ?? "Unknown"}
|
||||||
</Button>
|
</span>
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-full border capitalize ${trial.status === 'completed' ? 'bg-green-500/10 text-green-500 border-green-500/20' :
|
||||||
|
trial.status === 'in_progress' ? 'bg-blue-500/10 text-blue-500 border-blue-500/20' :
|
||||||
|
'bg-slate-500/10 text-slate-500 border-slate-500/20'
|
||||||
|
}`}>
|
||||||
|
{trial.status.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground flex items-center gap-2 mt-0.5">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
{new Date(trial.createdAt).toLocaleDateString()}
|
||||||
|
<span className="text-muted-foreground top-[1px] relative text-[10px]">•</span>
|
||||||
|
{formatDistanceToNow(new Date(trial.createdAt), { addSuffix: true })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground/30 group-hover:text-primary transition-colors" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{recentTrials.length === 0 && (
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
No sessions found.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Main Page --
|
||||||
|
|
||||||
export default function StudyAnalyticsPage() {
|
export default function StudyAnalyticsPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const studyId: string = typeof params.id === "string" ? params.id : "";
|
const studyId: string = typeof params.id === "string" ? params.id : "";
|
||||||
const { setSelectedStudyId, selectedStudyId } = useStudyContext();
|
const { setSelectedStudyId, selectedStudyId } = useStudyContext();
|
||||||
const { study } = useSelectedStudyDetails();
|
const { study } = useSelectedStudyDetails();
|
||||||
|
|
||||||
|
// State lifted up
|
||||||
|
const [selectedTrialId, setSelectedTrialId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Fetch list of trials for the selector
|
||||||
|
const { data: trialsList, isLoading: isLoadingList } = api.trials.list.useQuery(
|
||||||
|
{ studyId, limit: 100 },
|
||||||
|
{ enabled: !!studyId }
|
||||||
|
);
|
||||||
|
|
||||||
// Set breadcrumbs
|
// Set breadcrumbs
|
||||||
useBreadcrumbsEffect([
|
useBreadcrumbsEffect([
|
||||||
{ label: "Dashboard", href: "/dashboard" },
|
{ label: "Dashboard", href: "/dashboard" },
|
||||||
@@ -320,16 +217,53 @@ export default function StudyAnalyticsPage() {
|
|||||||
}, [studyId, selectedStudyId, setSelectedStudyId]);
|
}, [studyId, selectedStudyId, setSelectedStudyId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="h-[calc(100vh-64px)] flex flex-col p-6 gap-6">
|
||||||
|
<div className="flex-none">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Analytics"
|
title="Analytics"
|
||||||
description="Insights and data analysis for this study"
|
description="Analyze trial data and replay sessions"
|
||||||
icon={BarChart3}
|
icon={BarChart3}
|
||||||
|
actions={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Session Selector in Header */}
|
||||||
|
<div className="w-[300px]">
|
||||||
|
<Select
|
||||||
|
value={selectedTrialId ?? "overview"}
|
||||||
|
onValueChange={(val) => setSelectedTrialId(val === "overview" ? null : val)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full h-9 text-xs">
|
||||||
|
<LayoutGrid className="h-3.5 w-3.5 mr-2 text-muted-foreground" />
|
||||||
|
<SelectValue placeholder="Select Session" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-[400px]" align="end">
|
||||||
|
<SelectItem value="overview" className="border-b mb-1 pb-1 font-medium text-xs">
|
||||||
|
Show Study Overview
|
||||||
|
</SelectItem>
|
||||||
|
{trialsList?.map((trial) => (
|
||||||
|
<SelectItem key={trial.id} value={trial.id} className="text-xs">
|
||||||
|
<span className="font-mono mr-2 text-muted-foreground">#{trial.sessionNumber}</span>
|
||||||
|
{trial.participant?.participantCode ?? "Unknown"} <span className="text-muted-foreground ml-1">({new Date(trial.createdAt).toLocaleDateString()})</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 bg-transparent">
|
||||||
<Suspense fallback={<div>Loading analytics...</div>}>
|
<Suspense fallback={<div>Loading analytics...</div>}>
|
||||||
<AnalyticsContent studyId={studyId} />
|
<AnalyticsContent
|
||||||
|
selectedTrialId={selectedTrialId}
|
||||||
|
setSelectedTrialId={setSelectedTrialId}
|
||||||
|
trialsList={trialsList ?? []}
|
||||||
|
isLoadingList={isLoadingList}
|
||||||
|
studyId={studyId}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { Suspense, useEffect } from "react";
|
import { Suspense, useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Play, Zap, ArrowLeft, User, FlaskConical } from "lucide-react";
|
import { Play, Zap, ArrowLeft, User, FlaskConical, LineChart } from "lucide-react";
|
||||||
import { PageHeader } from "~/components/ui/page-header";
|
import { PageHeader } from "~/components/ui/page-header";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
@@ -157,6 +157,14 @@ function TrialDetailContent() {
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{trial.status === "completed" && (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={`/studies/${studyId}/trials/${trialId}/analysis`}>
|
||||||
|
<LineChart className="mr-2 h-4 w-4" />
|
||||||
|
View Analysis
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link href={`/studies/${studyId}/trials`}>
|
<Link href={`/studies/${studyId}/trials`}>
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import "~/styles/globals.css";
|
import "~/styles/globals.css";
|
||||||
|
|
||||||
import { type Metadata } from "next";
|
import { type Metadata } from "next";
|
||||||
import { Geist } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
|
|
||||||
import { SessionProvider } from "next-auth/react";
|
import { SessionProvider } from "next-auth/react";
|
||||||
import { TRPCReactProvider } from "~/trpc/react";
|
import { TRPCReactProvider } from "~/trpc/react";
|
||||||
@@ -13,16 +13,16 @@ export const metadata: Metadata = {
|
|||||||
icons: [{ rel: "icon", url: "/favicon.ico" }],
|
icons: [{ rel: "icon", url: "/favicon.ico" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
const geist = Geist({
|
const inter = Inter({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-sans",
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{ children: React.ReactNode }>) {
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={`${geist.variable}`}>
|
<html lang="en" className={`${inter.variable}`}>
|
||||||
<body>
|
<body>
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<TRPCReactProvider>{children}</TRPCReactProvider>
|
<TRPCReactProvider>{children}</TRPCReactProvider>
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export class ActionRegistry {
|
|||||||
parameters?: CoreBlockParam[];
|
parameters?: CoreBlockParam[];
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
retryable?: boolean;
|
retryable?: boolean;
|
||||||
|
nestable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -139,6 +140,7 @@ export class ActionRegistry {
|
|||||||
parameterSchemaRaw: {
|
parameterSchemaRaw: {
|
||||||
parameters: block.parameters ?? [],
|
parameters: block.parameters ?? [],
|
||||||
},
|
},
|
||||||
|
nestable: block.nestable,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.actions.set(actionDef.id, actionDef);
|
this.actions.set(actionDef.id, actionDef);
|
||||||
@@ -180,31 +182,33 @@ export class ActionRegistry {
|
|||||||
private loadFallbackActions(): void {
|
private loadFallbackActions(): void {
|
||||||
const fallbackActions: ActionDefinition[] = [
|
const fallbackActions: ActionDefinition[] = [
|
||||||
{
|
{
|
||||||
id: "wizard_speak",
|
id: "wizard_say",
|
||||||
type: "wizard_speak",
|
type: "wizard_say",
|
||||||
name: "Wizard Says",
|
name: "Wizard Says",
|
||||||
description: "Wizard speaks to participant",
|
description: "Wizard speaks to participant",
|
||||||
category: "wizard",
|
category: "wizard",
|
||||||
icon: "MessageSquare",
|
icon: "MessageSquare",
|
||||||
color: "#3b82f6",
|
color: "#a855f7",
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
{
|
||||||
id: "text",
|
id: "message",
|
||||||
name: "Text to say",
|
name: "Message",
|
||||||
type: "text",
|
type: "text",
|
||||||
placeholder: "Hello, participant!",
|
placeholder: "Hello, participant!",
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "tone",
|
||||||
|
name: "Tone",
|
||||||
|
type: "select",
|
||||||
|
options: ["neutral", "friendly", "encouraging"],
|
||||||
|
value: "neutral",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
source: { kind: "core", baseActionId: "wizard_speak" },
|
source: { kind: "core", baseActionId: "wizard_say" },
|
||||||
execution: { transport: "internal", timeoutMs: 30000 },
|
execution: { transport: "internal", timeoutMs: 30000 },
|
||||||
parameterSchemaRaw: {
|
parameterSchemaRaw: {},
|
||||||
type: "object",
|
nestable: false,
|
||||||
properties: {
|
|
||||||
text: { type: "string" },
|
|
||||||
},
|
|
||||||
required: ["text"],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "wait",
|
id: "wait",
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ import {
|
|||||||
MouseSensor,
|
MouseSensor,
|
||||||
TouchSensor,
|
TouchSensor,
|
||||||
KeyboardSensor,
|
KeyboardSensor,
|
||||||
|
closestCorners,
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
type DragStartEvent,
|
type DragStartEvent,
|
||||||
|
type DragOverEvent,
|
||||||
} from "@dnd-kit/core";
|
} from "@dnd-kit/core";
|
||||||
import { BottomStatusBar } from "./layout/BottomStatusBar";
|
import { BottomStatusBar } from "./layout/BottomStatusBar";
|
||||||
import { ActionLibraryPanel } from "./panels/ActionLibraryPanel";
|
import { ActionLibraryPanel } from "./panels/ActionLibraryPanel";
|
||||||
@@ -599,11 +601,8 @@ export function DesignerRoot({
|
|||||||
// Serialize steps for stable comparison
|
// Serialize steps for stable comparison
|
||||||
const stepsHash = useMemo(() => JSON.stringify(steps), [steps]);
|
const stepsHash = useMemo(() => JSON.stringify(steps), [steps]);
|
||||||
|
|
||||||
useEffect(() => {
|
// Intentionally removed redundant recomputeHash useEffect that was causing excessive refreshes
|
||||||
if (!initialized) return;
|
// The debounced useEffect (lines 352-372) handles this correctly.
|
||||||
void recomputeHash();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [stepsHash, initialized]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedStepId || selectedActionId) {
|
if (selectedStepId || selectedActionId) {
|
||||||
@@ -628,18 +627,10 @@ export function DesignerRoot({
|
|||||||
) {
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void persist();
|
void persist();
|
||||||
} else if (e.key === "v" && !e.metaKey && !e.ctrlKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
void validateDesign();
|
|
||||||
} else if (e.key === "e" && !e.metaKey && !e.ctrlKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
void handleExport();
|
|
||||||
} else if (e.key === "n" && e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
createNewStep();
|
|
||||||
}
|
}
|
||||||
|
// 'v' (validate), 'e' (export), 'Shift+N' (new step) shortcuts removed to prevent accidents
|
||||||
},
|
},
|
||||||
[hasUnsavedChanges, persist, validateDesign, handleExport, createNewStep],
|
[hasUnsavedChanges, persist],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -687,43 +678,163 @@ export function DesignerRoot({
|
|||||||
[toggleLibraryScrollLock],
|
[toggleLibraryScrollLock],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
const handleDragOver = useCallback((event: DragOverEvent) => {
|
||||||
async (event: DragEndEvent) => {
|
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
console.debug("[DesignerRoot] dragEnd", {
|
const store = useDesignerStore.getState();
|
||||||
active: active?.id,
|
|
||||||
over: over?.id ?? null,
|
// Only handle Library -> Flow projection
|
||||||
});
|
if (!active.id.toString().startsWith("action-")) {
|
||||||
// Clear overlay immediately
|
if (store.insertionProjection) {
|
||||||
toggleLibraryScrollLock(false);
|
store.setInsertionProjection(null);
|
||||||
setDragOverlayAction(null);
|
}
|
||||||
if (!over) {
|
|
||||||
console.debug("[DesignerRoot] dragEnd: no drop target (ignored)");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expect dragged action (library) onto a step droppable
|
if (!over) {
|
||||||
const activeId = active.id.toString();
|
if (store.insertionProjection) {
|
||||||
const overId = over.id.toString();
|
store.setInsertionProjection(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overId = over.id.toString();
|
||||||
|
const activeDef = active.data.current?.action;
|
||||||
|
|
||||||
|
if (!activeDef) return;
|
||||||
|
|
||||||
if (activeId.startsWith("action-") && active.data.current?.action) {
|
|
||||||
// Resolve stepId from possible over ids: step-<id>, s-step-<id>, or s-act-<actionId>
|
|
||||||
let stepId: string | null = null;
|
let stepId: string | null = null;
|
||||||
|
let parentId: string | null = null;
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
// Detect target based on over id
|
||||||
|
if (overId.startsWith("s-act-")) {
|
||||||
|
const data = over.data.current;
|
||||||
|
if (data && data.stepId) {
|
||||||
|
stepId = data.stepId;
|
||||||
|
parentId = data.parentId ?? null; // Use parentId from the action we are hovering over
|
||||||
|
// Use sortable index (insertion point provided by dnd-kit sortable strategy)
|
||||||
|
index = data.sortable?.index ?? 0;
|
||||||
|
}
|
||||||
|
} else if (overId.startsWith("container-")) {
|
||||||
|
// Dropping into a container (e.g. Loop)
|
||||||
|
const data = over.data.current;
|
||||||
|
if (data && data.stepId) {
|
||||||
|
stepId = data.stepId;
|
||||||
|
parentId = data.parentId ?? overId.slice("container-".length);
|
||||||
|
// If dropping into container, appending is a safe default if specific index logic is missing
|
||||||
|
// But actually we can find length if we want. For now, 0 or append logic?
|
||||||
|
// If container is empty, index 0 is correct.
|
||||||
|
// If not empty, we are hitting the container *background*, so append?
|
||||||
|
// The projection logic will insert at 'index'. If index is past length, it appends.
|
||||||
|
// Let's set a large index to ensure append, or look up length.
|
||||||
|
// Lookup requires finding the action in store. Expensive?
|
||||||
|
// Let's assume index 0 for now (prepend) or implement lookup.
|
||||||
|
// Better: lookup action -> children length.
|
||||||
|
const actionId = parentId;
|
||||||
|
const step = store.steps.find(s => s.id === stepId);
|
||||||
|
// Find action recursive? Store has `findActionById` helper but it is not exported/accessible easily here?
|
||||||
|
// Actually, `store.steps` is available.
|
||||||
|
// We can implement a quick BFS/DFS or just assume 0.
|
||||||
|
// If dragging over the container *background* (empty space), append is usually expected.
|
||||||
|
// Let's try 9999?
|
||||||
|
index = 9999;
|
||||||
|
}
|
||||||
|
} else if (overId.startsWith("s-step-") || overId.startsWith("step-")) {
|
||||||
|
// Container drop (Step)
|
||||||
|
stepId = overId.startsWith("s-step-")
|
||||||
|
? overId.slice("s-step-".length)
|
||||||
|
: overId.slice("step-".length);
|
||||||
|
const step = store.steps.find((s) => s.id === stepId);
|
||||||
|
index = step ? step.actions.length : 0;
|
||||||
|
|
||||||
|
} else if (overId === "projection-placeholder") {
|
||||||
|
// Hovering over our own projection placeholder -> keep current state
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stepId) {
|
||||||
|
const current = store.insertionProjection;
|
||||||
|
// Optimization: avoid redundant updates if projection matches
|
||||||
|
if (
|
||||||
|
current &&
|
||||||
|
current.stepId === stepId &&
|
||||||
|
current.parentId === parentId &&
|
||||||
|
current.index === index
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.setInsertionProjection({
|
||||||
|
stepId,
|
||||||
|
parentId,
|
||||||
|
index,
|
||||||
|
action: {
|
||||||
|
id: "projection-placeholder",
|
||||||
|
type: activeDef.type,
|
||||||
|
name: activeDef.name,
|
||||||
|
category: activeDef.category,
|
||||||
|
description: "Drop here",
|
||||||
|
source: activeDef.source || { kind: "library" },
|
||||||
|
parameters: {},
|
||||||
|
execution: activeDef.execution,
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (store.insertionProjection) store.setInsertionProjection(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
async (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
// Clear overlay immediately
|
||||||
|
toggleLibraryScrollLock(false);
|
||||||
|
setDragOverlayAction(null);
|
||||||
|
|
||||||
|
// Capture and clear projection
|
||||||
|
const store = useDesignerStore.getState();
|
||||||
|
const projection = store.insertionProjection;
|
||||||
|
store.setInsertionProjection(null);
|
||||||
|
|
||||||
|
if (!over) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Determine Target (Step, Parent, Index)
|
||||||
|
let stepId: string | null = null;
|
||||||
|
let parentId: string | null = null;
|
||||||
|
let index: number | undefined = undefined;
|
||||||
|
|
||||||
|
if (projection) {
|
||||||
|
stepId = projection.stepId;
|
||||||
|
parentId = projection.parentId;
|
||||||
|
index = projection.index;
|
||||||
|
} else {
|
||||||
|
// Fallback: resolution from overId (if projection failed or raced)
|
||||||
|
const overId = over.id.toString();
|
||||||
if (overId.startsWith("step-")) {
|
if (overId.startsWith("step-")) {
|
||||||
stepId = overId.slice("step-".length);
|
stepId = overId.slice("step-".length);
|
||||||
} else if (overId.startsWith("s-step-")) {
|
} else if (overId.startsWith("s-step-")) {
|
||||||
stepId = overId.slice("s-step-".length);
|
stepId = overId.slice("s-step-".length);
|
||||||
} else if (overId.startsWith("s-act-")) {
|
} else if (overId.startsWith("s-act-")) {
|
||||||
|
// This might fail if s-act-projection, but that should have covered by projection check above
|
||||||
const actionId = overId.slice("s-act-".length);
|
const actionId = overId.slice("s-act-".length);
|
||||||
const parent = steps.find((s) =>
|
const parent = steps.find((s) =>
|
||||||
s.actions.some((a) => a.id === actionId),
|
s.actions.some((a) => a.id === actionId),
|
||||||
);
|
);
|
||||||
stepId = parent?.id ?? null;
|
stepId = parent?.id ?? null;
|
||||||
}
|
}
|
||||||
if (!stepId) return;
|
}
|
||||||
|
|
||||||
|
if (!stepId) return;
|
||||||
|
const targetStep = steps.find((s) => s.id === stepId);
|
||||||
|
if (!targetStep) return;
|
||||||
|
|
||||||
|
// 2. Instantiate Action
|
||||||
|
if (active.id.toString().startsWith("action-") && active.data.current?.action) {
|
||||||
const actionDef = active.data.current.action as {
|
const actionDef = active.data.current.action as {
|
||||||
id: string;
|
id: string; // type
|
||||||
type: string;
|
type: string;
|
||||||
name: string;
|
name: string;
|
||||||
category: string;
|
category: string;
|
||||||
@@ -733,14 +844,13 @@ export function DesignerRoot({
|
|||||||
parameters: Array<{ id: string; name: string }>;
|
parameters: Array<{ id: string; name: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const targetStep = steps.find((s) => s.id === stepId);
|
|
||||||
if (!targetStep) return;
|
|
||||||
|
|
||||||
const fullDef = actionRegistry.getAction(actionDef.type);
|
const fullDef = actionRegistry.getAction(actionDef.type);
|
||||||
const defaultParams: Record<string, unknown> = {};
|
const defaultParams: Record<string, unknown> = {};
|
||||||
if (fullDef?.parameters) {
|
if (fullDef?.parameters) {
|
||||||
for (const param of fullDef.parameters) {
|
for (const param of fullDef.parameters) {
|
||||||
|
// @ts-expect-error - 'default' property access
|
||||||
if (param.default !== undefined) {
|
if (param.default !== undefined) {
|
||||||
|
// @ts-expect-error - 'default' property access
|
||||||
defaultParams[param.id] = param.default;
|
defaultParams[param.id] = param.default;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -755,39 +865,61 @@ export function DesignerRoot({
|
|||||||
transport: actionDef.execution.transport,
|
transport: actionDef.execution.transport,
|
||||||
retryable: actionDef.execution.retryable ?? false,
|
retryable: actionDef.execution.retryable ?? false,
|
||||||
}
|
}
|
||||||
: {
|
: undefined;
|
||||||
transport: "internal",
|
|
||||||
retryable: false,
|
|
||||||
};
|
|
||||||
const newAction: ExperimentAction = {
|
const newAction: ExperimentAction = {
|
||||||
id: `action-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
id: crypto.randomUUID(),
|
||||||
type: actionDef.type,
|
type: actionDef.type, // this is the 'type' key
|
||||||
name: actionDef.name,
|
name: actionDef.name,
|
||||||
category: actionDef.category as ExperimentAction["category"],
|
category: actionDef.category as any,
|
||||||
|
description: "",
|
||||||
parameters: defaultParams,
|
parameters: defaultParams,
|
||||||
source: actionDef.source as ExperimentAction["source"],
|
source: actionDef.source ? {
|
||||||
|
kind: actionDef.source.kind as any,
|
||||||
|
pluginId: actionDef.source.pluginId,
|
||||||
|
pluginVersion: actionDef.source.pluginVersion,
|
||||||
|
baseActionId: actionDef.id
|
||||||
|
} : { kind: "core" },
|
||||||
execution,
|
execution,
|
||||||
|
children: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
upsertAction(stepId, newAction);
|
// 3. Commit
|
||||||
// Select the newly added action and open properties
|
upsertAction(stepId, newAction, parentId, index);
|
||||||
selectStep(stepId);
|
|
||||||
|
// Auto-select
|
||||||
selectAction(stepId, newAction.id);
|
selectAction(stepId, newAction.id);
|
||||||
setInspectorTab("properties");
|
|
||||||
await recomputeHash();
|
void recomputeHash();
|
||||||
toast.success(`Added ${actionDef.name} to ${targetStep.name}`);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[steps, upsertAction, selectAction, recomputeHash, toggleLibraryScrollLock],
|
||||||
steps,
|
|
||||||
upsertAction,
|
|
||||||
recomputeHash,
|
|
||||||
selectStep,
|
|
||||||
selectAction,
|
|
||||||
toggleLibraryScrollLock,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
// validation status badges removed (unused)
|
// validation status badges removed (unused)
|
||||||
|
/* ------------------------------- Panels ---------------------------------- */
|
||||||
|
const leftPanel = useMemo(
|
||||||
|
() => (
|
||||||
|
<div ref={libraryRootRef} data-library-root className="h-full">
|
||||||
|
<ActionLibraryPanel />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const centerPanel = useMemo(() => <FlowWorkspace />, []);
|
||||||
|
|
||||||
|
const rightPanel = useMemo(
|
||||||
|
() => (
|
||||||
|
<div className="h-full">
|
||||||
|
<InspectorPanel
|
||||||
|
activeTab={inspectorTab}
|
||||||
|
onTabChange={setInspectorTab}
|
||||||
|
studyPlugins={studyPlugins}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
[inspectorTab, studyPlugins],
|
||||||
|
);
|
||||||
|
|
||||||
/* ------------------------------- Render ---------------------------------- */
|
/* ------------------------------- Render ---------------------------------- */
|
||||||
if (loadingExperiment && !initialized) {
|
if (loadingExperiment && !initialized) {
|
||||||
@@ -852,33 +984,33 @@ export function DesignerRoot({
|
|||||||
<div className="flex h-[calc(100vh-12rem)] w-full max-w-full flex-col overflow-hidden rounded-md border">
|
<div className="flex h-[calc(100vh-12rem)] w-full max-w-full flex-col overflow-hidden rounded-md border">
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={pointerWithin}
|
collisionDetection={closestCorners}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
onDragCancel={() => toggleLibraryScrollLock(false)}
|
onDragCancel={() => toggleLibraryScrollLock(false)}
|
||||||
>
|
>
|
||||||
<PanelsContainer
|
<PanelsContainer
|
||||||
showDividers
|
showDividers
|
||||||
className="min-h-0 flex-1"
|
className="min-h-0 flex-1"
|
||||||
left={
|
left={leftPanel}
|
||||||
<div ref={libraryRootRef} data-library-root className="h-full">
|
center={centerPanel}
|
||||||
<ActionLibraryPanel />
|
right={rightPanel}
|
||||||
</div>
|
|
||||||
}
|
|
||||||
center={<FlowWorkspace />}
|
|
||||||
right={
|
|
||||||
<div className="h-full">
|
|
||||||
<InspectorPanel
|
|
||||||
activeTab={inspectorTab}
|
|
||||||
onTabChange={setInspectorTab}
|
|
||||||
studyPlugins={studyPlugins}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<DragOverlay>
|
<DragOverlay>
|
||||||
{dragOverlayAction ? (
|
{dragOverlayAction ? (
|
||||||
<div className="bg-background pointer-events-none rounded border px-2 py-1 text-xs shadow-lg select-none">
|
<div className="bg-background flex items-center gap-2 rounded border px-3 py-2 text-xs font-medium shadow-lg select-none">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"h-2.5 w-2.5 rounded-full",
|
||||||
|
{
|
||||||
|
wizard: "bg-blue-500",
|
||||||
|
robot: "bg-emerald-600",
|
||||||
|
control: "bg-amber-500",
|
||||||
|
observation: "bg-purple-600",
|
||||||
|
}[dragOverlayAction.category] || "bg-slate-400",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
{dragOverlayAction.name}
|
{dragOverlayAction.name}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -282,205 +282,22 @@ export function PropertiesPanelBase({
|
|||||||
Parameters
|
Parameters
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{def.parameters.map((param) => {
|
{def.parameters.map((param) => (
|
||||||
const rawValue = selectedAction.parameters[param.id];
|
<ParameterEditor
|
||||||
const commonLabel = (
|
key={param.id}
|
||||||
<Label className="flex items-center gap-2 text-xs">
|
param={param}
|
||||||
{param.name}
|
value={selectedAction.parameters[param.id]}
|
||||||
<span className="text-muted-foreground font-normal">
|
onUpdate={(val) => {
|
||||||
{param.type === "number" &&
|
|
||||||
(param.min !== undefined || param.max !== undefined) &&
|
|
||||||
typeof rawValue === "number" &&
|
|
||||||
`( ${rawValue} )`}
|
|
||||||
</span>
|
|
||||||
</Label>
|
|
||||||
);
|
|
||||||
|
|
||||||
/* ---- Handlers ---- */
|
|
||||||
const updateParamValue = (value: unknown) => {
|
|
||||||
setLocalParams((prev) => ({ ...prev, [param.id]: value }));
|
|
||||||
debouncedParamUpdate(
|
|
||||||
containingStep.id,
|
|
||||||
selectedAction.id,
|
|
||||||
param.id,
|
|
||||||
value,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateParamValueImmediate = (value: unknown) => {
|
|
||||||
setLocalParams((prev) => ({ ...prev, [param.id]: value }));
|
|
||||||
onActionUpdate(containingStep.id, selectedAction.id, {
|
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||||
parameters: {
|
parameters: {
|
||||||
...selectedAction.parameters,
|
...selectedAction.parameters,
|
||||||
[param.id]: value,
|
[param.id]: val,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const updateParamLocal = (value: unknown) => {
|
|
||||||
setLocalParams((prev) => ({ ...prev, [param.id]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const commitParamValue = () => {
|
|
||||||
if (localParams[param.id] !== rawValue) {
|
|
||||||
onActionUpdate(containingStep.id, selectedAction.id, {
|
|
||||||
parameters: {
|
|
||||||
...selectedAction.parameters,
|
|
||||||
[param.id]: localParams[param.id],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ---- Control Rendering ---- */
|
|
||||||
let control: React.ReactNode = null;
|
|
||||||
|
|
||||||
if (param.type === "text") {
|
|
||||||
const localValue = localParams[param.id] ?? rawValue ?? "";
|
|
||||||
control = (
|
|
||||||
<Input
|
|
||||||
value={localValue as string}
|
|
||||||
placeholder={param.placeholder}
|
|
||||||
onChange={(e) => updateParamValue(e.target.value)}
|
|
||||||
onBlur={() => {
|
|
||||||
if (localParams[param.id] !== rawValue) {
|
|
||||||
onActionUpdate(containingStep.id, selectedAction.id, {
|
|
||||||
parameters: {
|
|
||||||
...selectedAction.parameters,
|
|
||||||
[param.id]: localParams[param.id],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
className="mt-1 h-7 w-full text-xs"
|
onCommit={() => { }}
|
||||||
/>
|
/>
|
||||||
);
|
|
||||||
} else if (param.type === "select") {
|
|
||||||
const localValue = localParams[param.id] ?? rawValue ?? "";
|
|
||||||
control = (
|
|
||||||
<Select
|
|
||||||
value={localValue as string}
|
|
||||||
onValueChange={(val) => updateParamValueImmediate(val)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="mt-1 h-7 w-full text-xs">
|
|
||||||
<SelectValue placeholder="Select…" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{param.options?.map((opt) => (
|
|
||||||
<SelectItem key={opt} value={opt}>
|
|
||||||
{opt}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
);
|
|
||||||
} else if (param.type === "boolean") {
|
|
||||||
const localValue = localParams[param.id] ?? rawValue ?? false;
|
|
||||||
control = (
|
|
||||||
<div className="mt-1 flex h-7 items-center">
|
|
||||||
<Switch
|
|
||||||
checked={Boolean(localValue)}
|
|
||||||
onCheckedChange={(val) =>
|
|
||||||
updateParamValueImmediate(val)
|
|
||||||
}
|
|
||||||
aria-label={param.name}
|
|
||||||
/>
|
|
||||||
<span className="text-muted-foreground ml-2 text-[11px]">
|
|
||||||
{Boolean(localValue) ? "Enabled" : "Disabled"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (param.type === "number") {
|
|
||||||
const localValue = localParams[param.id] ?? rawValue;
|
|
||||||
const numericVal =
|
|
||||||
typeof localValue === "number"
|
|
||||||
? localValue
|
|
||||||
: typeof param.value === "number"
|
|
||||||
? param.value
|
|
||||||
: (param.min ?? 0);
|
|
||||||
|
|
||||||
if (param.min !== undefined || param.max !== undefined) {
|
|
||||||
const min = param.min ?? 0;
|
|
||||||
const max =
|
|
||||||
param.max ??
|
|
||||||
Math.max(
|
|
||||||
min + 1,
|
|
||||||
Number.isFinite(numericVal) ? numericVal : min + 1,
|
|
||||||
);
|
|
||||||
// Step heuristic
|
|
||||||
const range = max - min;
|
|
||||||
const step =
|
|
||||||
param.step ??
|
|
||||||
(range <= 5
|
|
||||||
? 0.1
|
|
||||||
: range <= 50
|
|
||||||
? 0.5
|
|
||||||
: Math.max(1, Math.round(range / 100)));
|
|
||||||
control = (
|
|
||||||
<div className="mt-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Slider
|
|
||||||
min={min}
|
|
||||||
max={max}
|
|
||||||
step={step}
|
|
||||||
value={[Number(numericVal)]}
|
|
||||||
onValueChange={(vals: number[]) =>
|
|
||||||
updateParamLocal(vals[0])
|
|
||||||
}
|
|
||||||
onPointerUp={commitParamValue}
|
|
||||||
/>
|
|
||||||
<span className="text-muted-foreground min-w-[2.5rem] text-right text-[10px] tabular-nums">
|
|
||||||
{step < 1
|
|
||||||
? Number(numericVal).toFixed(2)
|
|
||||||
: Number(numericVal).toString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground mt-1 flex justify-between text-[10px]">
|
|
||||||
<span>{min}</span>
|
|
||||||
<span>{max}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
control = (
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={numericVal}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateParamValue(parseFloat(e.target.value) || 0)
|
|
||||||
}
|
|
||||||
onBlur={() => {
|
|
||||||
if (localParams[param.id] !== rawValue) {
|
|
||||||
onActionUpdate(
|
|
||||||
containingStep.id,
|
|
||||||
selectedAction.id,
|
|
||||||
{
|
|
||||||
parameters: {
|
|
||||||
...selectedAction.parameters,
|
|
||||||
[param.id]: localParams[param.id],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="mt-1 h-7 w-full text-xs"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={param.id} className="space-y-1">
|
|
||||||
{commonLabel}
|
|
||||||
{param.description && (
|
|
||||||
<div className="text-muted-foreground text-[10px]">
|
|
||||||
{param.description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{control}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -635,3 +452,156 @@ export function PropertiesPanelBase({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const PropertiesPanel = React.memo(PropertiesPanelBase);
|
export const PropertiesPanel = React.memo(PropertiesPanelBase);
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Isolated Parameter Editor (Optimized) */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
interface ParameterEditorProps {
|
||||||
|
param: any;
|
||||||
|
value: unknown;
|
||||||
|
onUpdate: (value: unknown) => void;
|
||||||
|
onCommit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ParameterEditor = React.memo(function ParameterEditor({
|
||||||
|
param,
|
||||||
|
value: rawValue,
|
||||||
|
onUpdate,
|
||||||
|
onCommit
|
||||||
|
}: ParameterEditorProps) {
|
||||||
|
// Local state for immediate feedback
|
||||||
|
const [localValue, setLocalValue] = useState<unknown>(rawValue);
|
||||||
|
const debounceRef = useRef<NodeJS.Timeout | undefined>();
|
||||||
|
|
||||||
|
// Sync from prop if it changes externally
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalValue(rawValue);
|
||||||
|
}, [rawValue]);
|
||||||
|
|
||||||
|
const handleUpdate = useCallback((newVal: unknown, immediate = false) => {
|
||||||
|
setLocalValue(newVal);
|
||||||
|
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
|
||||||
|
if (immediate) {
|
||||||
|
onUpdate(newVal);
|
||||||
|
} else {
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
onUpdate(newVal);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}, [onUpdate]);
|
||||||
|
|
||||||
|
const handleCommit = useCallback(() => {
|
||||||
|
if (localValue !== rawValue) {
|
||||||
|
onUpdate(localValue);
|
||||||
|
}
|
||||||
|
}, [localValue, rawValue, onUpdate]);
|
||||||
|
|
||||||
|
let control: React.ReactNode = null;
|
||||||
|
|
||||||
|
if (param.type === "text") {
|
||||||
|
control = (
|
||||||
|
<Input
|
||||||
|
value={(localValue as string) ?? ""}
|
||||||
|
placeholder={param.placeholder}
|
||||||
|
onChange={(e) => handleUpdate(e.target.value)}
|
||||||
|
onBlur={handleCommit}
|
||||||
|
className="mt-1 h-7 w-full text-xs"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (param.type === "select") {
|
||||||
|
control = (
|
||||||
|
<Select
|
||||||
|
value={(localValue as string) ?? ""}
|
||||||
|
onValueChange={(val) => handleUpdate(val, true)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1 h-7 w-full text-xs">
|
||||||
|
<SelectValue placeholder="Select…" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{param.options?.map((opt: string) => (
|
||||||
|
<SelectItem key={opt} value={opt}>
|
||||||
|
{opt}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
} else if (param.type === "boolean") {
|
||||||
|
control = (
|
||||||
|
<div className="mt-1 flex h-7 items-center">
|
||||||
|
<Switch
|
||||||
|
checked={Boolean(localValue)}
|
||||||
|
onCheckedChange={(val) => handleUpdate(val, true)}
|
||||||
|
aria-label={param.name}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground ml-2 text-[11px]">
|
||||||
|
{Boolean(localValue) ? "Enabled" : "Disabled"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (param.type === "number") {
|
||||||
|
const numericVal = typeof localValue === "number" ? localValue : (param.min ?? 0);
|
||||||
|
|
||||||
|
if (param.min !== undefined || param.max !== undefined) {
|
||||||
|
const min = param.min ?? 0;
|
||||||
|
const max = param.max ?? Math.max(min + 1, Number.isFinite(numericVal) ? numericVal : min + 1);
|
||||||
|
const range = max - min;
|
||||||
|
const step = param.step ?? (range <= 5 ? 0.1 : range <= 50 ? 0.5 : Math.max(1, Math.round(range / 100)));
|
||||||
|
|
||||||
|
control = (
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Slider
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={[Number(numericVal)]}
|
||||||
|
onValueChange={(vals) => setLocalValue(vals[0])} // Update only local visual
|
||||||
|
onPointerUp={() => handleUpdate(localValue)} // Commit on release
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground min-w-[2.5rem] text-right text-[10px] tabular-nums">
|
||||||
|
{step < 1 ? Number(numericVal).toFixed(2) : Number(numericVal).toString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground mt-1 flex justify-between text-[10px]">
|
||||||
|
<span>{min}</span>
|
||||||
|
<span>{max}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
control = (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={numericVal}
|
||||||
|
onChange={(e) => handleUpdate(parseFloat(e.target.value) || 0)}
|
||||||
|
onBlur={handleCommit}
|
||||||
|
className="mt-1 h-7 w-full text-xs"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="flex items-center gap-2 text-xs">
|
||||||
|
{param.name}
|
||||||
|
<span className="text-muted-foreground font-normal">
|
||||||
|
{param.type === "number" &&
|
||||||
|
(param.min !== undefined || param.max !== undefined) &&
|
||||||
|
typeof rawValue === "number" &&
|
||||||
|
`( ${rawValue} )`}
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
{param.description && (
|
||||||
|
<div className="text-muted-foreground text-[10px]">
|
||||||
|
{param.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{control}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
useDndMonitor,
|
useDndMonitor,
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
type DragStartEvent,
|
type DragStartEvent,
|
||||||
|
type DragOverEvent,
|
||||||
} from "@dnd-kit/core";
|
} from "@dnd-kit/core";
|
||||||
import {
|
import {
|
||||||
useSortable,
|
useSortable,
|
||||||
@@ -68,7 +69,7 @@ interface FlowWorkspaceProps {
|
|||||||
onActionCreate?: (stepId: string, action: ExperimentAction) => void;
|
onActionCreate?: (stepId: string, action: ExperimentAction) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VirtualItem {
|
export interface VirtualItem {
|
||||||
index: number;
|
index: number;
|
||||||
top: number;
|
top: number;
|
||||||
height: number;
|
height: number;
|
||||||
@@ -77,6 +78,232 @@ interface VirtualItem {
|
|||||||
visible: boolean;
|
visible: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StepRowProps {
|
||||||
|
item: VirtualItem;
|
||||||
|
selectedStepId: string | null | undefined;
|
||||||
|
selectedActionId: string | null | undefined;
|
||||||
|
renamingStepId: string | null;
|
||||||
|
onSelectStep: (id: string | undefined) => void;
|
||||||
|
onSelectAction: (stepId: string, actionId: string | undefined) => void;
|
||||||
|
onToggleExpanded: (step: ExperimentStep) => void;
|
||||||
|
onRenameStep: (step: ExperimentStep, name: string) => void;
|
||||||
|
onDeleteStep: (step: ExperimentStep) => void;
|
||||||
|
onDeleteAction: (stepId: string, actionId: string) => void;
|
||||||
|
setRenamingStepId: (id: string | null) => void;
|
||||||
|
registerMeasureRef: (stepId: string, el: HTMLDivElement | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepRow = React.memo(function StepRow({
|
||||||
|
item,
|
||||||
|
selectedStepId,
|
||||||
|
selectedActionId,
|
||||||
|
renamingStepId,
|
||||||
|
onSelectStep,
|
||||||
|
onSelectAction,
|
||||||
|
onToggleExpanded,
|
||||||
|
onRenameStep,
|
||||||
|
onDeleteStep,
|
||||||
|
onDeleteAction,
|
||||||
|
setRenamingStepId,
|
||||||
|
registerMeasureRef,
|
||||||
|
}: StepRowProps) {
|
||||||
|
const step = item.step;
|
||||||
|
const insertionProjection = useDesignerStore((s) => s.insertionProjection);
|
||||||
|
|
||||||
|
const displayActions = useMemo(() => {
|
||||||
|
if (
|
||||||
|
insertionProjection?.stepId === step.id &&
|
||||||
|
insertionProjection.parentId === null
|
||||||
|
) {
|
||||||
|
const copy = [...step.actions];
|
||||||
|
// Insert placeholder action
|
||||||
|
// Ensure specific ID doesn't crash keys if collision (collision unlikely for library items)
|
||||||
|
// Actually, standard array key is action.id.
|
||||||
|
copy.splice(insertionProjection.index, 0, insertionProjection.action);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
return step.actions;
|
||||||
|
}, [step.actions, step.id, insertionProjection]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({
|
||||||
|
id: sortableStepId(step.id),
|
||||||
|
data: {
|
||||||
|
type: "step",
|
||||||
|
step: step,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
position: "absolute",
|
||||||
|
top: item.top,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
width: "100%",
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
zIndex: isDragging ? 25 : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} style={style} data-step-id={step.id}>
|
||||||
|
<div
|
||||||
|
ref={(el) => registerMeasureRef(step.id, el)}
|
||||||
|
className="relative px-3 py-4"
|
||||||
|
data-step-id={step.id}
|
||||||
|
>
|
||||||
|
<StepDroppableArea stepId={step.id} />
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mb-2 rounded border shadow-sm transition-colors",
|
||||||
|
selectedStepId === step.id
|
||||||
|
? "border-border bg-accent/30"
|
||||||
|
: "hover:bg-accent/30",
|
||||||
|
isDragging && "opacity-80 ring-1 ring-blue-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between gap-2 border-b px-2 py-1.5"
|
||||||
|
onClick={(e) => {
|
||||||
|
const tag = (e.target as HTMLElement).tagName.toLowerCase();
|
||||||
|
if (tag === "input" || tag === "textarea" || tag === "button")
|
||||||
|
return;
|
||||||
|
onSelectStep(step.id);
|
||||||
|
onSelectAction(step.id, undefined);
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleExpanded(step);
|
||||||
|
}}
|
||||||
|
className="text-muted-foreground hover:bg-accent/60 hover:text-foreground rounded p-1"
|
||||||
|
aria-label={step.expanded ? "Collapse step" : "Expand step"}
|
||||||
|
>
|
||||||
|
{step.expanded ? (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="h-5 px-1.5 text-[10px] font-normal"
|
||||||
|
>
|
||||||
|
{step.order + 1}
|
||||||
|
</Badge>
|
||||||
|
{renamingStepId === step.id ? (
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
defaultValue={step.name}
|
||||||
|
className="h-7 w-40 text-xs"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
onRenameStep(
|
||||||
|
step,
|
||||||
|
(e.target as HTMLInputElement).value.trim() ||
|
||||||
|
step.name,
|
||||||
|
);
|
||||||
|
setRenamingStepId(null);
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
setRenamingStepId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
onRenameStep(step, e.target.value.trim() || step.name);
|
||||||
|
setRenamingStepId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-sm font-medium">{step.name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-muted-foreground hover:text-foreground p-1 opacity-0 group-hover:opacity-100"
|
||||||
|
aria-label="Rename step"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setRenamingStepId(step.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit3 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="text-muted-foreground hidden text-[11px] md:inline">
|
||||||
|
{step.actions.length} actions
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 text-[11px] text-red-500 hover:text-red-600"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteStep(step);
|
||||||
|
}}
|
||||||
|
aria-label="Delete step"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<div
|
||||||
|
className="text-muted-foreground cursor-grab p-1"
|
||||||
|
aria-label="Drag step"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action List (Collapsible/Virtual content) */}
|
||||||
|
{step.expanded && (
|
||||||
|
<div className="bg-background/40 min-h-[3rem] space-y-2 p-2 pb-8">
|
||||||
|
<SortableContext
|
||||||
|
items={displayActions.map((a) => sortableActionId(a.id))}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="flex w-full flex-col gap-2">
|
||||||
|
{displayActions.length === 0 ? (
|
||||||
|
<div className="flex h-12 items-center justify-center rounded border border-dashed text-xs text-muted-foreground">
|
||||||
|
Drop actions here
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
displayActions.map((action) => (
|
||||||
|
<SortableActionChip
|
||||||
|
key={action.id}
|
||||||
|
stepId={step.id}
|
||||||
|
action={action}
|
||||||
|
parentId={null}
|
||||||
|
selectedActionId={selectedActionId}
|
||||||
|
onSelectAction={onSelectAction}
|
||||||
|
onDeleteAction={onDeleteAction}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
/* Utility */
|
/* Utility */
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
@@ -122,37 +349,125 @@ function StepDroppableArea({ stepId }: { stepId: string }) {
|
|||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
interface ActionChipProps {
|
interface ActionChipProps {
|
||||||
|
stepId: string;
|
||||||
action: ExperimentAction;
|
action: ExperimentAction;
|
||||||
isSelected: boolean;
|
parentId: string | null;
|
||||||
onSelect: () => void;
|
selectedActionId: string | null | undefined;
|
||||||
onDelete: () => void;
|
onSelectAction: (stepId: string, actionId: string | undefined) => void;
|
||||||
|
onDeleteAction: (stepId: string, actionId: string) => void;
|
||||||
dragHandle?: boolean;
|
dragHandle?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortableActionChip({
|
function SortableActionChip({
|
||||||
|
stepId,
|
||||||
action,
|
action,
|
||||||
isSelected,
|
parentId,
|
||||||
onSelect,
|
selectedActionId,
|
||||||
onDelete,
|
onSelectAction,
|
||||||
|
onDeleteAction,
|
||||||
|
dragHandle,
|
||||||
}: ActionChipProps) {
|
}: ActionChipProps) {
|
||||||
const def = actionRegistry.getAction(action.type);
|
const def = actionRegistry.getAction(action.type);
|
||||||
|
const isSelected = selectedActionId === action.id;
|
||||||
|
|
||||||
|
const insertionProjection = useDesignerStore((s) => s.insertionProjection);
|
||||||
|
const displayChildren = useMemo(() => {
|
||||||
|
if (
|
||||||
|
insertionProjection?.stepId === stepId &&
|
||||||
|
insertionProjection.parentId === action.id
|
||||||
|
) {
|
||||||
|
const copy = [...(action.children || [])];
|
||||||
|
copy.splice(insertionProjection.index, 0, insertionProjection.action);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
return action.children;
|
||||||
|
}, [action.children, action.id, stepId, insertionProjection]);
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------------ */
|
||||||
|
/* Main Sortable Logic */
|
||||||
|
/* ------------------------------------------------------------------------ */
|
||||||
|
const isPlaceholder = action.id === "projection-placeholder";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
listeners,
|
listeners,
|
||||||
setNodeRef,
|
setNodeRef,
|
||||||
transform,
|
transform,
|
||||||
transition,
|
transition,
|
||||||
isDragging,
|
isDragging: isSortableDragging,
|
||||||
} = useSortable({
|
} = useSortable({
|
||||||
id: sortableActionId(action.id),
|
id: sortableActionId(action.id),
|
||||||
|
disabled: isPlaceholder, // Disable sortable for placeholder
|
||||||
|
data: {
|
||||||
|
type: "action",
|
||||||
|
stepId,
|
||||||
|
parentId,
|
||||||
|
id: action.id,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const style: React.CSSProperties = {
|
// Use local dragging state or passed prop
|
||||||
transform: CSS.Transform.toString(transform),
|
const isDragging = isSortableDragging || dragHandle;
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Translate.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
zIndex: isDragging ? 30 : undefined,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------------ */
|
||||||
|
/* Nested Droppable (for control flow containers) */
|
||||||
|
/* ------------------------------------------------------------------------ */
|
||||||
|
const nestedDroppableId = `container-${action.id}`;
|
||||||
|
const {
|
||||||
|
isOver: isOverNested,
|
||||||
|
setNodeRef: setNestedNodeRef
|
||||||
|
} = useDroppable({
|
||||||
|
id: nestedDroppableId,
|
||||||
|
disabled: !def?.nestable || isPlaceholder, // Disable droppable for placeholder
|
||||||
|
data: {
|
||||||
|
type: "container",
|
||||||
|
stepId,
|
||||||
|
parentId: action.id,
|
||||||
|
action // Pass full action for projection logic
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const shouldRenderChildren = def?.nestable;
|
||||||
|
|
||||||
|
if (isPlaceholder) {
|
||||||
|
const { setNodeRef: setPlaceholderRef } = useDroppable({
|
||||||
|
id: "projection-placeholder",
|
||||||
|
data: { type: "placeholder" }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render simplified placeholder without hooks refs
|
||||||
|
// We still render the content matching the action type for visual fidelity
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setPlaceholderRef}
|
||||||
|
className="group relative flex w-full flex-col items-start gap-1 rounded border-2 border-dashed border-blue-300 bg-blue-50/50 px-3 py-2 text-[11px] opacity-70"
|
||||||
|
>
|
||||||
|
<div className="flex w-full items-center gap-2">
|
||||||
|
<span className={cn(
|
||||||
|
"h-2.5 w-2.5 rounded-full",
|
||||||
|
def ? {
|
||||||
|
wizard: "bg-blue-500",
|
||||||
|
robot: "bg-emerald-500",
|
||||||
|
control: "bg-amber-500",
|
||||||
|
observation: "bg-purple-500",
|
||||||
|
}[def.category] : "bg-gray-400"
|
||||||
|
)} />
|
||||||
|
<span className="font-medium text-foreground">{def?.name ?? action.name}</span>
|
||||||
|
</div>
|
||||||
|
{def?.description && (
|
||||||
|
<div className="text-muted-foreground line-clamp-3 w-full text-[10px] leading-snug">
|
||||||
|
{def.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
@@ -162,8 +477,13 @@ function SortableActionChip({
|
|||||||
"bg-muted/40 hover:bg-accent/40 cursor-pointer",
|
"bg-muted/40 hover:bg-accent/40 cursor-pointer",
|
||||||
isSelected && "border-border bg-accent/30",
|
isSelected && "border-border bg-accent/30",
|
||||||
isDragging && "opacity-70 shadow-lg",
|
isDragging && "opacity-70 shadow-lg",
|
||||||
|
// Visual feedback for nested drop
|
||||||
|
isOverNested && !isDragging && "ring-2 ring-blue-400 ring-offset-1 bg-blue-50/50"
|
||||||
)}
|
)}
|
||||||
onClick={onSelect}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelectAction(stepId, action.id);
|
||||||
|
}}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
role="button"
|
role="button"
|
||||||
aria-pressed={isSelected}
|
aria-pressed={isSelected}
|
||||||
@@ -197,7 +517,7 @@ function SortableActionChip({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDelete();
|
onDeleteAction(stepId, action.id);
|
||||||
}}
|
}}
|
||||||
className="text-muted-foreground hover:text-foreground rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
|
className="text-muted-foreground hover:text-foreground rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
aria-label="Delete action"
|
aria-label="Delete action"
|
||||||
@@ -221,12 +541,45 @@ function SortableActionChip({
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{def.parameters.length > 4 && (
|
{def.parameters.length > 4 && (
|
||||||
<span className="text-muted-foreground text-[9px]">
|
<span className="text-[9px] text-muted-foreground">+{def.parameters.length - 4}</span>
|
||||||
+{def.parameters.length - 4} more
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Nested Actions Container */}
|
||||||
|
{shouldRenderChildren && (
|
||||||
|
<div
|
||||||
|
ref={setNestedNodeRef}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 w-full flex flex-col gap-2 pl-4 border-l-2 border-border/40 transition-all min-h-[0.5rem] pb-4",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={(displayChildren ?? action.children ?? [])
|
||||||
|
.filter(c => c.id !== "projection-placeholder")
|
||||||
|
.map(c => sortableActionId(c.id))}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
{(displayChildren || action.children || []).map((child) => (
|
||||||
|
<SortableActionChip
|
||||||
|
key={child.id}
|
||||||
|
stepId={stepId}
|
||||||
|
action={child}
|
||||||
|
parentId={action.id}
|
||||||
|
selectedActionId={selectedActionId}
|
||||||
|
onSelectAction={onSelectAction}
|
||||||
|
onDeleteAction={onDeleteAction}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{(!displayChildren?.length && !action.children?.length) && (
|
||||||
|
<div className="text-[10px] text-muted-foreground/60 italic py-1">
|
||||||
|
Drag actions here
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SortableContext>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -254,7 +607,7 @@ export function FlowWorkspace({
|
|||||||
|
|
||||||
const removeAction = useDesignerStore((s) => s.removeAction);
|
const removeAction = useDesignerStore((s) => s.removeAction);
|
||||||
const reorderStep = useDesignerStore((s) => s.reorderStep);
|
const reorderStep = useDesignerStore((s) => s.reorderStep);
|
||||||
const reorderAction = useDesignerStore((s) => s.reorderAction);
|
const moveAction = useDesignerStore((s) => s.moveAction);
|
||||||
const recomputeHash = useDesignerStore((s) => s.recomputeHash);
|
const recomputeHash = useDesignerStore((s) => s.recomputeHash);
|
||||||
|
|
||||||
/* Local state */
|
/* Local state */
|
||||||
@@ -382,7 +735,10 @@ export function FlowWorkspace({
|
|||||||
description: "",
|
description: "",
|
||||||
type: "sequential",
|
type: "sequential",
|
||||||
order: steps.length,
|
order: steps.length,
|
||||||
trigger: { type: "trial_start", conditions: {} },
|
trigger:
|
||||||
|
steps.length === 0
|
||||||
|
? { type: "trial_start", conditions: {} }
|
||||||
|
: { type: "previous_step", conditions: {} },
|
||||||
actions: [],
|
actions: [],
|
||||||
expanded: true,
|
expanded: true,
|
||||||
};
|
};
|
||||||
@@ -472,34 +828,77 @@ export function FlowWorkspace({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Action reorder (within same parent only)
|
// Action reorder (supports nesting)
|
||||||
if (activeId.startsWith("s-act-") && overId.startsWith("s-act-")) {
|
if (activeId.startsWith("s-act-") && overId.startsWith("s-act-")) {
|
||||||
const fromActionId = parseSortableAction(activeId);
|
const activeData = active.data.current;
|
||||||
const toActionId = parseSortableAction(overId);
|
const overData = over.data.current;
|
||||||
if (fromActionId && toActionId && fromActionId !== toActionId) {
|
|
||||||
const fromParent = actionParentMap.get(fromActionId);
|
if (
|
||||||
const toParent = actionParentMap.get(toActionId);
|
activeData && overData &&
|
||||||
if (fromParent && toParent && fromParent === toParent) {
|
activeData.stepId === overData.stepId &&
|
||||||
const step = steps.find((s) => s.id === fromParent);
|
activeData.type === 'action' && overData.type === 'action'
|
||||||
if (step) {
|
) {
|
||||||
const fromIdx = step.actions.findIndex(
|
const stepId = activeData.stepId as string;
|
||||||
(a) => a.id === fromActionId,
|
const activeActionId = activeData.action.id;
|
||||||
);
|
const overActionId = overData.action.id;
|
||||||
const toIdx = step.actions.findIndex((a) => a.id === toActionId);
|
|
||||||
if (fromIdx >= 0 && toIdx >= 0) {
|
if (activeActionId !== overActionId) {
|
||||||
reorderAction(step.id, fromIdx, toIdx);
|
const newParentId = overData.parentId as string | null;
|
||||||
|
const newIndex = overData.sortable.index; // index within that parent's list
|
||||||
|
|
||||||
|
moveAction(stepId, activeActionId, newParentId, newIndex);
|
||||||
void recomputeHash();
|
void recomputeHash();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[steps, reorderStep, moveAction, recomputeHash],
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------------ */
|
||||||
|
/* Drag Over (Live Sorting) */
|
||||||
|
/* ------------------------------------------------------------------------ */
|
||||||
|
const handleLocalDragOver = useCallback(
|
||||||
|
(event: DragOverEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over) return;
|
||||||
|
|
||||||
|
const activeId = active.id.toString();
|
||||||
|
const overId = over.id.toString();
|
||||||
|
|
||||||
|
// Only handle action reordering
|
||||||
|
if (activeId.startsWith("s-act-") && overId.startsWith("s-act-")) {
|
||||||
|
const activeData = active.data.current;
|
||||||
|
const overData = over.data.current;
|
||||||
|
|
||||||
|
if (
|
||||||
|
activeData &&
|
||||||
|
overData &&
|
||||||
|
activeData.type === 'action' &&
|
||||||
|
overData.type === 'action'
|
||||||
|
) {
|
||||||
|
const activeActionId = activeData.action.id;
|
||||||
|
const overActionId = overData.action.id;
|
||||||
|
const activeStepId = activeData.stepId;
|
||||||
|
const overStepId = overData.stepId;
|
||||||
|
const activeParentId = activeData.parentId;
|
||||||
|
const overParentId = overData.parentId;
|
||||||
|
|
||||||
|
// If moving between different lists (parents/steps), move immediately to visualize snap
|
||||||
|
if (activeParentId !== overParentId || activeStepId !== overStepId) {
|
||||||
|
// Determine new index
|
||||||
|
// verification of safe move handled by store
|
||||||
|
moveAction(overStepId, activeActionId, overParentId, overData.sortable.index);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[steps, reorderStep, reorderAction, actionParentMap, recomputeHash],
|
[moveAction]
|
||||||
);
|
);
|
||||||
|
|
||||||
useDndMonitor({
|
useDndMonitor({
|
||||||
onDragStart: handleLocalDragStart,
|
onDragStart: handleLocalDragStart,
|
||||||
|
onDragOver: handleLocalDragOver,
|
||||||
onDragEnd: handleLocalDragEnd,
|
onDragEnd: handleLocalDragEnd,
|
||||||
onDragCancel: () => {
|
onDragCancel: () => {
|
||||||
// no-op
|
// no-op
|
||||||
@@ -509,204 +908,22 @@ export function FlowWorkspace({
|
|||||||
/* ------------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------------ */
|
||||||
/* Step Row (Sortable + Virtualized) */
|
/* Step Row (Sortable + Virtualized) */
|
||||||
/* ------------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------------ */
|
||||||
function StepRow({ item }: { item: VirtualItem }) {
|
// StepRow moved outside of component to prevent re-mounting on every render (flashing fix)
|
||||||
const step = item.step;
|
|
||||||
const {
|
|
||||||
setNodeRef,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
isDragging,
|
|
||||||
} = useSortable({
|
|
||||||
id: sortableStepId(step.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
const style: React.CSSProperties = {
|
const registerMeasureRef = useCallback(
|
||||||
position: "absolute",
|
(stepId: string, el: HTMLDivElement | null) => {
|
||||||
top: item.top,
|
const prev = measureRefs.current.get(stepId) ?? null;
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
width: "100%",
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition,
|
|
||||||
zIndex: isDragging ? 25 : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const setMeasureRef = (el: HTMLDivElement | null) => {
|
|
||||||
const prev = measureRefs.current.get(step.id) ?? null;
|
|
||||||
if (prev && prev !== el) {
|
if (prev && prev !== el) {
|
||||||
roRef.current?.unobserve(prev);
|
roRef.current?.unobserve(prev);
|
||||||
measureRefs.current.delete(step.id);
|
measureRefs.current.delete(stepId);
|
||||||
}
|
}
|
||||||
if (el) {
|
if (el) {
|
||||||
measureRefs.current.set(step.id, el);
|
measureRefs.current.set(stepId, el);
|
||||||
roRef.current?.observe(el);
|
roRef.current?.observe(el);
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[],
|
||||||
return (
|
|
||||||
<div ref={setNodeRef} style={style} data-step-id={step.id}>
|
|
||||||
<div
|
|
||||||
ref={setMeasureRef}
|
|
||||||
className="relative px-3 py-4"
|
|
||||||
data-step-id={step.id}
|
|
||||||
>
|
|
||||||
<StepDroppableArea stepId={step.id} />
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"mb-2 rounded border shadow-sm transition-colors",
|
|
||||||
selectedStepId === step.id
|
|
||||||
? "border-border bg-accent/30"
|
|
||||||
: "hover:bg-accent/30",
|
|
||||||
isDragging && "opacity-80 ring-1 ring-blue-300",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-between gap-2 border-b px-2 py-1.5"
|
|
||||||
onClick={(e) => {
|
|
||||||
// Avoid selecting step when interacting with controls or inputs
|
|
||||||
const tag = (e.target as HTMLElement).tagName.toLowerCase();
|
|
||||||
if (tag === "input" || tag === "textarea" || tag === "button")
|
|
||||||
return;
|
|
||||||
selectStep(step.id);
|
|
||||||
selectAction(step.id, undefined);
|
|
||||||
}}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
toggleExpanded(step);
|
|
||||||
}}
|
|
||||||
className="text-muted-foreground hover:bg-accent/60 hover:text-foreground rounded p-1"
|
|
||||||
aria-label={step.expanded ? "Collapse step" : "Expand step"}
|
|
||||||
>
|
|
||||||
{step.expanded ? (
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="h-5 px-1.5 text-[10px] font-normal"
|
|
||||||
>
|
|
||||||
{step.order + 1}
|
|
||||||
</Badge>
|
|
||||||
{renamingStepId === step.id ? (
|
|
||||||
<Input
|
|
||||||
autoFocus
|
|
||||||
defaultValue={step.name}
|
|
||||||
className="h-7 w-40 text-xs"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
renameStep(
|
|
||||||
step,
|
|
||||||
(e.target as HTMLInputElement).value.trim() ||
|
|
||||||
step.name,
|
|
||||||
);
|
);
|
||||||
setRenamingStepId(null);
|
|
||||||
void recomputeHash();
|
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
setRenamingStepId(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
renameStep(step, e.target.value.trim() || step.name);
|
|
||||||
setRenamingStepId(null);
|
|
||||||
void recomputeHash();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className="text-sm font-medium">{step.name}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-muted-foreground hover:text-foreground p-1 opacity-0 group-hover:opacity-100"
|
|
||||||
aria-label="Rename step"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setRenamingStepId(step.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Edit3 className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span className="text-muted-foreground hidden text-[11px] md:inline">
|
|
||||||
{step.actions.length} actions
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 w-7 p-0 text-[11px] text-red-500 hover:text-red-600"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
deleteStep(step);
|
|
||||||
}}
|
|
||||||
aria-label="Delete step"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
<div
|
|
||||||
className="text-muted-foreground cursor-grab p-1"
|
|
||||||
aria-label="Drag step"
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
>
|
|
||||||
<GripVertical className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{step.expanded && (
|
|
||||||
<div className="space-y-2 px-3 py-3">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{step.actions.length > 0 && (
|
|
||||||
<SortableContext
|
|
||||||
items={step.actions.map((a) => sortableActionId(a.id))}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
<div className="flex w-full flex-col gap-2">
|
|
||||||
{step.actions.map((action) => (
|
|
||||||
<SortableActionChip
|
|
||||||
key={action.id}
|
|
||||||
action={action}
|
|
||||||
isSelected={
|
|
||||||
selectedStepId === step.id &&
|
|
||||||
selectedActionId === action.id
|
|
||||||
}
|
|
||||||
onSelect={() => {
|
|
||||||
selectStep(step.id);
|
|
||||||
selectAction(step.id, action.id);
|
|
||||||
}}
|
|
||||||
onDelete={() => deleteAction(step.id, action.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* Persistent centered bottom drop hint */}
|
|
||||||
<div className="mt-3 flex w-full items-center justify-center">
|
|
||||||
<div className="text-muted-foreground border-muted-foreground/30 rounded border border-dashed px-2 py-1 text-[11px]">
|
|
||||||
Drop actions here
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------------ */
|
||||||
/* Render */
|
/* Render */
|
||||||
@@ -767,7 +984,27 @@ export function FlowWorkspace({
|
|||||||
>
|
>
|
||||||
<div style={{ height: totalHeight, position: "relative" }}>
|
<div style={{ height: totalHeight, position: "relative" }}>
|
||||||
{virtualItems.map(
|
{virtualItems.map(
|
||||||
(vi) => vi.visible && <StepRow key={vi.key} item={vi} />,
|
(vi) =>
|
||||||
|
vi.visible && (
|
||||||
|
<StepRow
|
||||||
|
key={vi.key}
|
||||||
|
item={vi}
|
||||||
|
selectedStepId={selectedStepId}
|
||||||
|
selectedActionId={selectedActionId}
|
||||||
|
renamingStepId={renamingStepId}
|
||||||
|
onSelectStep={selectStep}
|
||||||
|
onSelectAction={selectAction}
|
||||||
|
onToggleExpanded={toggleExpanded}
|
||||||
|
onRenameStep={(step, name) => {
|
||||||
|
renameStep(step, name);
|
||||||
|
void recomputeHash();
|
||||||
|
}}
|
||||||
|
onDeleteStep={deleteStep}
|
||||||
|
onDeleteAction={deleteAction}
|
||||||
|
setRenamingStepId={setRenamingStepId}
|
||||||
|
registerMeasureRef={registerMeasureRef}
|
||||||
|
/>
|
||||||
|
),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
|
|||||||
@@ -53,6 +53,30 @@ export interface PanelsContainerProps {
|
|||||||
* - Resize handles are absolutely positioned over the grid at the left and right boundaries.
|
* - Resize handles are absolutely positioned over the grid at the left and right boundaries.
|
||||||
* - Fractions are clamped with configurable min/max so panels remain usable at all sizes.
|
* - Fractions are clamped with configurable min/max so panels remain usable at all sizes.
|
||||||
*/
|
*/
|
||||||
|
const Panel: React.FC<React.PropsWithChildren<{
|
||||||
|
className?: string;
|
||||||
|
panelClassName?: string;
|
||||||
|
contentClassName?: string;
|
||||||
|
}>> = ({
|
||||||
|
className: panelCls,
|
||||||
|
panelClassName,
|
||||||
|
contentClassName,
|
||||||
|
children,
|
||||||
|
}) => (
|
||||||
|
<section
|
||||||
|
className={cn("min-w-0 overflow-hidden", panelCls, panelClassName)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full min-h-0 w-full overflow-x-hidden overflow-y-auto",
|
||||||
|
contentClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
export function PanelsContainer({
|
export function PanelsContainer({
|
||||||
left,
|
left,
|
||||||
center,
|
center,
|
||||||
@@ -234,23 +258,7 @@ export function PanelsContainer({
|
|||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const Panel: React.FC<React.PropsWithChildren<{ className?: string }>> = ({
|
|
||||||
className: panelCls,
|
|
||||||
children,
|
|
||||||
}) => (
|
|
||||||
<section
|
|
||||||
className={cn("min-w-0 overflow-hidden", panelCls, panelClassName)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"h-full min-h-0 w-full overflow-x-hidden overflow-y-auto",
|
|
||||||
contentClassName,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -263,11 +271,33 @@ export function PanelsContainer({
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{hasLeft && <Panel>{left}</Panel>}
|
{hasLeft && (
|
||||||
|
<Panel
|
||||||
|
panelClassName={panelClassName}
|
||||||
|
contentClassName={contentClassName}
|
||||||
|
>
|
||||||
|
{left}
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
{hasCenter && <Panel className={centerDividers}>{center}</Panel>}
|
{hasCenter && (
|
||||||
|
<Panel
|
||||||
|
className={centerDividers}
|
||||||
|
panelClassName={panelClassName}
|
||||||
|
contentClassName={contentClassName}
|
||||||
|
>
|
||||||
|
{center}
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
{hasRight && <Panel>{right}</Panel>}
|
{hasRight && (
|
||||||
|
<Panel
|
||||||
|
panelClassName={panelClassName}
|
||||||
|
contentClassName={contentClassName}
|
||||||
|
>
|
||||||
|
{right}
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Resize handles (only render where applicable) */}
|
{/* Resize handles (only render where applicable) */}
|
||||||
{hasCenter && hasLeft && (
|
{hasCenter && hasLeft && (
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ export function ActionLibraryPanel() {
|
|||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [selectedCategories, setSelectedCategories] = useState<
|
const [selectedCategories, setSelectedCategories] = useState<
|
||||||
Set<ActionCategory>
|
Set<ActionCategory>
|
||||||
>(new Set<ActionCategory>(["wizard"]));
|
>(new Set<ActionCategory>(["wizard", "robot", "control", "observation"]));
|
||||||
const [favorites, setFavorites] = useState<FavoritesState>({
|
const [favorites, setFavorites] = useState<FavoritesState>({
|
||||||
favorites: new Set<string>(),
|
favorites: new Set<string>(),
|
||||||
});
|
});
|
||||||
@@ -293,9 +293,7 @@ export function ActionLibraryPanel() {
|
|||||||
setShowOnlyFavorites(false);
|
setShowOnlyFavorites(false);
|
||||||
}, [categories]);
|
}, [categories]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSelectedCategories(new Set(categories.map((c) => c.key)));
|
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const activeCats = selectedCategories;
|
const activeCats = selectedCategories;
|
||||||
|
|||||||
@@ -155,8 +155,9 @@ function projectActionForDesign(
|
|||||||
pluginVersion: action.source.pluginVersion,
|
pluginVersion: action.source.pluginVersion,
|
||||||
baseActionId: action.source.baseActionId,
|
baseActionId: action.source.baseActionId,
|
||||||
},
|
},
|
||||||
execution: projectExecutionDescriptor(action.execution),
|
execution: action.execution ? projectExecutionDescriptor(action.execution) : null,
|
||||||
parameterKeysOrValues: parameterProjection,
|
parameterKeysOrValues: parameterProjection,
|
||||||
|
children: action.children?.map(c => projectActionForDesign(c, options)) ?? [],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (options.includeActionNames) {
|
if (options.includeActionNames) {
|
||||||
|
|||||||
@@ -79,6 +79,23 @@ export interface DesignerState {
|
|||||||
busyHashing: boolean;
|
busyHashing: boolean;
|
||||||
busyValidating: boolean;
|
busyValidating: boolean;
|
||||||
|
|
||||||
|
/* ---------------------- DnD Projection (Transient) ----------------------- */
|
||||||
|
insertionProjection: {
|
||||||
|
stepId: string;
|
||||||
|
parentId: string | null;
|
||||||
|
index: number;
|
||||||
|
action: ExperimentAction;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
setInsertionProjection: (
|
||||||
|
projection: {
|
||||||
|
stepId: string;
|
||||||
|
parentId: string | null;
|
||||||
|
index: number;
|
||||||
|
action: ExperimentAction;
|
||||||
|
} | null
|
||||||
|
) => void;
|
||||||
|
|
||||||
/* ------------------------------ Mutators --------------------------------- */
|
/* ------------------------------ Mutators --------------------------------- */
|
||||||
|
|
||||||
// Selection
|
// Selection
|
||||||
@@ -92,9 +109,10 @@ export interface DesignerState {
|
|||||||
reorderStep: (from: number, to: number) => void;
|
reorderStep: (from: number, to: number) => void;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
upsertAction: (stepId: string, action: ExperimentAction) => void;
|
upsertAction: (stepId: string, action: ExperimentAction, parentId?: string | null, index?: number) => void;
|
||||||
removeAction: (stepId: string, actionId: string) => void;
|
removeAction: (stepId: string, actionId: string) => void;
|
||||||
reorderAction: (stepId: string, from: number, to: number) => void;
|
reorderAction: (stepId: string, from: number, to: number) => void;
|
||||||
|
moveAction: (stepId: string, actionId: string, newParentId: string | null, newIndex: number) => void;
|
||||||
|
|
||||||
// Dirty
|
// Dirty
|
||||||
markDirty: (id: string) => void;
|
markDirty: (id: string) => void;
|
||||||
@@ -159,17 +177,73 @@ function reindexActions(actions: ExperimentAction[]): ExperimentAction[] {
|
|||||||
return actions.map((a) => ({ ...a }));
|
return actions.map((a) => ({ ...a }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateActionList(
|
function findActionById(
|
||||||
existing: ExperimentAction[],
|
list: ExperimentAction[],
|
||||||
|
id: string,
|
||||||
|
): ExperimentAction | null {
|
||||||
|
for (const action of list) {
|
||||||
|
if (action.id === id) return action;
|
||||||
|
if (action.children) {
|
||||||
|
const found = findActionById(action.children, id);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateActionInTree(
|
||||||
|
list: ExperimentAction[],
|
||||||
action: ExperimentAction,
|
action: ExperimentAction,
|
||||||
): ExperimentAction[] {
|
): ExperimentAction[] {
|
||||||
const idx = existing.findIndex((a) => a.id === action.id);
|
return list.map((a) => {
|
||||||
if (idx >= 0) {
|
if (a.id === action.id) return { ...action };
|
||||||
const copy = [...existing];
|
if (a.children) {
|
||||||
copy[idx] = { ...action };
|
return { ...a, children: updateActionInTree(a.children, action) };
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Immutable removal
|
||||||
|
function removeActionFromTree(
|
||||||
|
list: ExperimentAction[],
|
||||||
|
id: string,
|
||||||
|
): ExperimentAction[] {
|
||||||
|
return list
|
||||||
|
.filter((a) => a.id !== id)
|
||||||
|
.map((a) => ({
|
||||||
|
...a,
|
||||||
|
children: a.children ? removeActionFromTree(a.children, id) : undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Immutable insertion
|
||||||
|
function insertActionIntoTree(
|
||||||
|
list: ExperimentAction[],
|
||||||
|
action: ExperimentAction,
|
||||||
|
parentId: string | null,
|
||||||
|
index: number,
|
||||||
|
): ExperimentAction[] {
|
||||||
|
if (!parentId) {
|
||||||
|
// Insert at root level
|
||||||
|
const copy = [...list];
|
||||||
|
copy.splice(index, 0, action);
|
||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
return [...existing, { ...action }];
|
return list.map((a) => {
|
||||||
|
if (a.id === parentId) {
|
||||||
|
const children = a.children ? [...a.children] : [];
|
||||||
|
children.splice(index, 0, action);
|
||||||
|
return { ...a, children };
|
||||||
|
}
|
||||||
|
if (a.children) {
|
||||||
|
return {
|
||||||
|
...a,
|
||||||
|
children: insertActionIntoTree(a.children, action, parentId, index),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
@@ -187,6 +261,7 @@ export const useDesignerStore = create<DesignerState>((set, get) => ({
|
|||||||
autoSaveEnabled: true,
|
autoSaveEnabled: true,
|
||||||
busyHashing: false,
|
busyHashing: false,
|
||||||
busyValidating: false,
|
busyValidating: false,
|
||||||
|
insertionProjection: null,
|
||||||
|
|
||||||
/* ------------------------------ Selection -------------------------------- */
|
/* ------------------------------ Selection -------------------------------- */
|
||||||
selectStep: (id) =>
|
selectStep: (id) =>
|
||||||
@@ -263,16 +338,31 @@ export const useDesignerStore = create<DesignerState>((set, get) => ({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
/* ------------------------------- Actions --------------------------------- */
|
/* ------------------------------- Actions --------------------------------- */
|
||||||
upsertAction: (stepId: string, action: ExperimentAction) =>
|
upsertAction: (stepId: string, action: ExperimentAction, parentId: string | null = null, index?: number) =>
|
||||||
set((state: DesignerState) => {
|
set((state: DesignerState) => {
|
||||||
const stepsDraft: ExperimentStep[] = state.steps.map((s) =>
|
const stepsDraft: ExperimentStep[] = state.steps.map((s) => {
|
||||||
s.id === stepId
|
if (s.id !== stepId) return s;
|
||||||
? {
|
|
||||||
|
// Check if exists (update)
|
||||||
|
const exists = findActionById(s.actions, action.id);
|
||||||
|
if (exists) {
|
||||||
|
// If updating, we don't (currently) support moving via upsert.
|
||||||
|
// Use moveAction for moving.
|
||||||
|
return {
|
||||||
...s,
|
...s,
|
||||||
actions: reindexActions(updateActionList(s.actions, action)),
|
actions: updateActionInTree(s.actions, action)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
: s,
|
|
||||||
);
|
// Add new
|
||||||
|
// If index is provided, use it. Otherwise append.
|
||||||
|
const insertIndex = index ?? s.actions.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
actions: insertActionIntoTree(s.actions, action, parentId, insertIndex)
|
||||||
|
};
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
steps: stepsDraft,
|
steps: stepsDraft,
|
||||||
dirtyEntities: new Set<string>([
|
dirtyEntities: new Set<string>([
|
||||||
@@ -289,9 +379,7 @@ export const useDesignerStore = create<DesignerState>((set, get) => ({
|
|||||||
s.id === stepId
|
s.id === stepId
|
||||||
? {
|
? {
|
||||||
...s,
|
...s,
|
||||||
actions: reindexActions(
|
actions: removeActionFromTree(s.actions, actionId),
|
||||||
s.actions.filter((a) => a.id !== actionId),
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
: s,
|
: s,
|
||||||
);
|
);
|
||||||
@@ -308,31 +396,29 @@ export const useDesignerStore = create<DesignerState>((set, get) => ({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
reorderAction: (stepId: string, from: number, to: number) =>
|
moveAction: (stepId: string, actionId: string, newParentId: string | null, newIndex: number) =>
|
||||||
set((state: DesignerState) => {
|
set((state: DesignerState) => {
|
||||||
const stepsDraft: ExperimentStep[] = state.steps.map((s) => {
|
const stepsDraft = state.steps.map((s) => {
|
||||||
if (s.id !== stepId) return s;
|
if (s.id !== stepId) return s;
|
||||||
if (
|
|
||||||
from < 0 ||
|
const actionToMove = findActionById(s.actions, actionId);
|
||||||
to < 0 ||
|
if (!actionToMove) return s;
|
||||||
from >= s.actions.length ||
|
|
||||||
to >= s.actions.length ||
|
const pruned = removeActionFromTree(s.actions, actionId);
|
||||||
from === to
|
const inserted = insertActionIntoTree(pruned, actionToMove, newParentId, newIndex);
|
||||||
) {
|
return { ...s, actions: inserted };
|
||||||
return s;
|
|
||||||
}
|
|
||||||
const actionsDraft = [...s.actions];
|
|
||||||
const [moved] = actionsDraft.splice(from, 1);
|
|
||||||
if (!moved) return s;
|
|
||||||
actionsDraft.splice(to, 0, moved);
|
|
||||||
return { ...s, actions: reindexActions(actionsDraft) };
|
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
steps: stepsDraft,
|
steps: stepsDraft,
|
||||||
dirtyEntities: new Set<string>([...state.dirtyEntities, stepId]),
|
dirtyEntities: new Set<string>([...state.dirtyEntities, stepId, actionId]),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
reorderAction: (stepId: string, from: number, to: number) =>
|
||||||
|
get().moveAction(stepId, get().steps.find(s => s.id === stepId)?.actions[from]?.id!, null, to), // Legacy compat support (only works for root level reorder)
|
||||||
|
|
||||||
|
setInsertionProjection: (projection) => set({ insertionProjection: projection }),
|
||||||
|
|
||||||
/* -------------------------------- Dirty ---------------------------------- */
|
/* -------------------------------- Dirty ---------------------------------- */
|
||||||
markDirty: (id: string) =>
|
markDirty: (id: string) =>
|
||||||
set((state: DesignerState) => ({
|
set((state: DesignerState) => ({
|
||||||
|
|||||||
@@ -643,13 +643,13 @@ export function validateExecution(
|
|||||||
if (trialStartSteps.length > 1) {
|
if (trialStartSteps.length > 1) {
|
||||||
trialStartSteps.slice(1).forEach((step) => {
|
trialStartSteps.slice(1).forEach((step) => {
|
||||||
issues.push({
|
issues.push({
|
||||||
severity: "warning",
|
severity: "info",
|
||||||
message:
|
message:
|
||||||
"Multiple steps will start simultaneously. Ensure parallel execution is intended.",
|
"This step will start immediately at trial start. For sequential flow, use 'Previous Step' trigger.",
|
||||||
category: "execution",
|
category: "execution",
|
||||||
field: "trigger.type",
|
field: "trigger.type",
|
||||||
stepId: step.id,
|
stepId: step.id,
|
||||||
suggestion: "Consider using sequential triggers for subsequent steps",
|
suggestion: "Change trigger to 'Previous Step' if this step should follow the previous one",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -367,10 +367,8 @@ export const columns: ColumnDef<Trial>[] = [
|
|||||||
|
|
||||||
function ActionsCell({ row }: { row: { original: Trial } }) {
|
function ActionsCell({ row }: { row: { original: Trial } }) {
|
||||||
const trial = row.original;
|
const trial = row.original;
|
||||||
const router = React.useMemo(() => require("next/navigation").useRouter(), []); // Dynamic import to avoid hook rules in static context? No, this component is rendered in Table.
|
// ActionsCell is a component rendered by the table.
|
||||||
// Actually, hooks must be at top level. This ActionsCell will be a regular component.
|
|
||||||
// But useRouter might fail if columns is not in component tree?
|
|
||||||
// Table cells are rendered by flexRender in React, so they are components.
|
|
||||||
// importing useRouter is fine.
|
// importing useRouter is fine.
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|||||||
205
src/components/trials/playback/EventTimeline.tsx
Normal file
205
src/components/trials/playback/EventTimeline.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo, useRef, useState } from "react";
|
||||||
|
import { usePlayback } from "./PlaybackContext";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
Flag,
|
||||||
|
MessageSquare,
|
||||||
|
Zap,
|
||||||
|
Circle,
|
||||||
|
Bot,
|
||||||
|
User,
|
||||||
|
Activity
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger
|
||||||
|
} from "~/components/ui/tooltip";
|
||||||
|
|
||||||
|
function formatTime(seconds: number) {
|
||||||
|
const min = Math.floor(seconds / 60);
|
||||||
|
const sec = Math.floor(seconds % 60);
|
||||||
|
return `${min}:${sec.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventTimeline() {
|
||||||
|
const {
|
||||||
|
duration,
|
||||||
|
currentTime,
|
||||||
|
events,
|
||||||
|
seekTo,
|
||||||
|
startTime: contextStartTime
|
||||||
|
} = usePlayback();
|
||||||
|
|
||||||
|
// Determine effective time range
|
||||||
|
const sortedEvents = useMemo(() => {
|
||||||
|
return [...events].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
|
const startTime = useMemo(() => {
|
||||||
|
if (contextStartTime) return new Date(contextStartTime).getTime();
|
||||||
|
if (sortedEvents.length > 0) return new Date(sortedEvents[0]!.timestamp).getTime();
|
||||||
|
return 0;
|
||||||
|
}, [contextStartTime, sortedEvents]);
|
||||||
|
|
||||||
|
const effectiveDuration = useMemo(() => {
|
||||||
|
if (duration > 0) return duration * 1000;
|
||||||
|
if (sortedEvents.length === 0) return 60000; // 1 min default
|
||||||
|
const end = new Date(sortedEvents[sortedEvents.length - 1]!.timestamp).getTime();
|
||||||
|
return Math.max(end - startTime, 1000);
|
||||||
|
}, [duration, sortedEvents, startTime]);
|
||||||
|
|
||||||
|
// Dimensions
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
const getPercentage = (timestampMs: number) => {
|
||||||
|
const offset = timestampMs - startTime;
|
||||||
|
return Math.max(0, Math.min(100, (offset / effectiveDuration) * 100));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const pct = Math.max(0, Math.min(1, x / rect.width));
|
||||||
|
seekTo(pct * (effectiveDuration / 1000));
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentProgress = (currentTime * 1000 / effectiveDuration) * 100;
|
||||||
|
|
||||||
|
// Generate ticks for "number line" look
|
||||||
|
// We want a major tick every ~10% or meaningful time interval
|
||||||
|
const ticks = useMemo(() => {
|
||||||
|
const count = 10;
|
||||||
|
return Array.from({ length: count + 1 }).map((_, i) => ({
|
||||||
|
pct: (i / count) * 100,
|
||||||
|
label: formatTime((effectiveDuration / 1000) * (i / count))
|
||||||
|
}));
|
||||||
|
}, [effectiveDuration]);
|
||||||
|
|
||||||
|
const getEventIcon = (type: string) => {
|
||||||
|
if (type.includes("intervention") || type.includes("wizard")) return <User className="h-3 w-3" />;
|
||||||
|
if (type.includes("robot") || type.includes("action")) return <Bot className="h-3 w-3" />;
|
||||||
|
if (type.includes("completed")) return <CheckCircle className="h-3 w-3" />;
|
||||||
|
if (type.includes("start")) return <Flag className="h-3 w-3" />;
|
||||||
|
if (type.includes("note")) return <MessageSquare className="h-3 w-3" />;
|
||||||
|
if (type.includes("error")) return <AlertTriangle className="h-3 w-3" />;
|
||||||
|
return <Activity className="h-3 w-3" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEventColor = (type: string) => {
|
||||||
|
if (type.includes("intervention") || type.includes("wizard")) return "text-orange-500 border-orange-200 bg-orange-50";
|
||||||
|
if (type.includes("robot") || type.includes("action")) return "text-purple-500 border-purple-200 bg-purple-50";
|
||||||
|
if (type.includes("completed")) return "text-green-500 border-green-200 bg-green-50";
|
||||||
|
if (type.includes("start")) return "text-blue-500 border-blue-200 bg-blue-50";
|
||||||
|
if (type.includes("error")) return "text-red-500 border-red-200 bg-red-50";
|
||||||
|
return "text-slate-500 border-slate-200 bg-slate-50";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex flex-col select-none py-2">
|
||||||
|
<TooltipProvider>
|
||||||
|
{/* Timeline Track Container */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="relative w-full flex-1 min-h-[80px] group cursor-crosshair border-b border-border/50"
|
||||||
|
onClick={handleSeek}
|
||||||
|
>
|
||||||
|
{/* Background Grid/Ticks */}
|
||||||
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
|
{/* Major Ticks */}
|
||||||
|
{ticks.map((tick, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="absolute top-0 bottom-0 border-l border-border/30 flex flex-col justify-end"
|
||||||
|
style={{ left: `${tick.pct}%` }}
|
||||||
|
>
|
||||||
|
<span className="text-[10px] font-mono text-muted-foreground -ml-3 mb-1 bg-background/80 px-1 rounded">
|
||||||
|
{tick.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Central Axis Line */}
|
||||||
|
<div className="absolute top-1/2 left-0 right-0 h-px bg-border z-0" />
|
||||||
|
|
||||||
|
{/* Progress Fill (Subtle) */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-0 left-0 bg-primary/5 z-0 pointer-events-none"
|
||||||
|
style={{ width: `${currentProgress}%` }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Playhead */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-0 w-px bg-red-500 z-30 pointer-events-none transition-all duration-75"
|
||||||
|
style={{ left: `${currentProgress}%` }}
|
||||||
|
>
|
||||||
|
<div className="absolute -top-1 -ml-1.5 p-0.5 bg-red-500 rounded text-[8px] font-bold text-white w-3 h-3 flex items-center justify-center">
|
||||||
|
▼
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Events "Lollipops" */}
|
||||||
|
{sortedEvents.map((event, i) => {
|
||||||
|
const pct = getPercentage(new Date(event.timestamp).getTime());
|
||||||
|
const isTop = i % 2 === 0; // Stagger events top/bottom
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip key={i}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div
|
||||||
|
className="absolute z-20 flex flex-col items-center group/event"
|
||||||
|
style={{
|
||||||
|
left: `${pct}%`,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
height: '100%'
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
seekTo((new Date(event.timestamp).getTime() - startTime) / 1000);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* The Stem */}
|
||||||
|
<div className={cn(
|
||||||
|
"w-px transition-all duration-200 bg-border group-hover/event:bg-primary group-hover/event:h-full",
|
||||||
|
isTop ? "h-8 mb-auto" : "h-8 mt-auto"
|
||||||
|
)} />
|
||||||
|
|
||||||
|
{/* The Node */}
|
||||||
|
<div className={cn(
|
||||||
|
"absolute w-6 h-6 rounded-full border shadow-sm flex items-center justify-center transition-transform hover:scale-110 cursor-pointer bg-background z-10",
|
||||||
|
getEventColor(event.eventType),
|
||||||
|
isTop ? "-top-2" : "-bottom-2"
|
||||||
|
)}>
|
||||||
|
{getEventIcon(event.eventType)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side={isTop ? "top" : "bottom"}>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wider mb-0.5">{event.eventType.replace(/_/g, " ")}</div>
|
||||||
|
<div className="text-[10px] font-mono opacity-70 mb-1">
|
||||||
|
{new Date(event.timestamp).toLocaleTimeString()}
|
||||||
|
</div>
|
||||||
|
{event.data && (
|
||||||
|
<div className="bg-muted/50 p-1 rounded font-mono text-[9px] max-w-[200px] break-all">
|
||||||
|
{JSON.stringify(event.data as object).slice(0, 100)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
115
src/components/trials/playback/PlaybackContext.tsx
Normal file
115
src/components/trials/playback/PlaybackContext.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
interface TrialEvent {
|
||||||
|
eventType: string;
|
||||||
|
timestamp: Date;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlaybackContextType {
|
||||||
|
// State
|
||||||
|
currentTime: number;
|
||||||
|
duration: number;
|
||||||
|
isPlaying: boolean;
|
||||||
|
playbackRate: number;
|
||||||
|
startTime?: Date;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
play: () => void;
|
||||||
|
pause: () => void;
|
||||||
|
togglePlay: () => void;
|
||||||
|
seekTo: (time: number) => void;
|
||||||
|
setPlaybackRate: (rate: number) => void;
|
||||||
|
setDuration: (duration: number) => void;
|
||||||
|
setCurrentTime: (time: number) => void; // Used by VideoPlayer to update state
|
||||||
|
|
||||||
|
// Data
|
||||||
|
events: TrialEvent[];
|
||||||
|
currentEventIndex: number; // Index of the last event that happened before currentTime
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlaybackContext = createContext<PlaybackContextType | null>(null);
|
||||||
|
|
||||||
|
export function usePlayback() {
|
||||||
|
const context = useContext(PlaybackContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("usePlayback must be used within a PlaybackProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlaybackProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
events?: TrialEvent[];
|
||||||
|
startTime?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaybackProvider({ children, events = [], startTime }: PlaybackProviderProps) {
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [playbackRate, setPlaybackRate] = useState(1);
|
||||||
|
|
||||||
|
// Derived state: find the latest event index based on currentTime
|
||||||
|
const currentEventIndex = React.useMemo(() => {
|
||||||
|
if (!startTime || events.length === 0) return -1;
|
||||||
|
|
||||||
|
// Find the last event that occurred before or at currentTime
|
||||||
|
// Events are assumed to be sorted by timestamp
|
||||||
|
// Using basic iteration for now, optimization possible for large lists
|
||||||
|
let lastIndex = -1;
|
||||||
|
|
||||||
|
for (let i = 0; i < events.length; i++) {
|
||||||
|
const eventTime = new Date(events[i]!.timestamp).getTime();
|
||||||
|
const startStr = new Date(startTime).getTime();
|
||||||
|
const relativeSeconds = (eventTime - startStr) / 1000;
|
||||||
|
|
||||||
|
if (relativeSeconds <= currentTime) {
|
||||||
|
lastIndex = i;
|
||||||
|
} else {
|
||||||
|
break; // Events are sorted, so we can stop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lastIndex;
|
||||||
|
}, [currentTime, events, startTime]);
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const play = () => setIsPlaying(true);
|
||||||
|
const pause = () => setIsPlaying(false);
|
||||||
|
const togglePlay = () => setIsPlaying(p => !p);
|
||||||
|
|
||||||
|
const seekTo = (time: number) => {
|
||||||
|
setCurrentTime(time);
|
||||||
|
// Dispatch seek event to video player via some mechanism if needed,
|
||||||
|
// usually VideoPlayer observes this context or we use a Ref to control it.
|
||||||
|
// Actually, simple way: Context holds state, VideoPlayer listens to state?
|
||||||
|
// No, VideoPlayer usually drives time.
|
||||||
|
// Let's assume VideoPlayer updates `setCurrentTime` as it plays.
|
||||||
|
// But if *we* seek (e.g. from timeline), we need to tell VideoPlayer to jump.
|
||||||
|
// We might need a `seekRequest` timestamp or similar signal.
|
||||||
|
};
|
||||||
|
|
||||||
|
const value: PlaybackContextType = {
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
isPlaying,
|
||||||
|
playbackRate,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
togglePlay,
|
||||||
|
seekTo,
|
||||||
|
setPlaybackRate,
|
||||||
|
setDuration,
|
||||||
|
setCurrentTime,
|
||||||
|
events,
|
||||||
|
currentEventIndex,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlaybackContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</PlaybackContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
src/components/trials/playback/PlaybackPlayer.tsx
Normal file
154
src/components/trials/playback/PlaybackPlayer.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useRef, useEffect } from "react";
|
||||||
|
import { usePlayback } from "./PlaybackContext";
|
||||||
|
import { AspectRatio } from "~/components/ui/aspect-ratio";
|
||||||
|
import { Loader2, Play, Pause, Volume2, VolumeX, Maximize } from "lucide-react";
|
||||||
|
import { Slider } from "~/components/ui/slider";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
|
||||||
|
interface PlaybackPlayerProps {
|
||||||
|
src: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaybackPlayer({ src }: PlaybackPlayerProps) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const {
|
||||||
|
currentTime,
|
||||||
|
isPlaying,
|
||||||
|
playbackRate,
|
||||||
|
setCurrentTime,
|
||||||
|
setDuration,
|
||||||
|
togglePlay,
|
||||||
|
play,
|
||||||
|
pause
|
||||||
|
} = usePlayback();
|
||||||
|
|
||||||
|
const [isBuffering, setIsBuffering] = React.useState(true);
|
||||||
|
const [volume, setVolume] = React.useState(1);
|
||||||
|
const [muted, setMuted] = React.useState(false);
|
||||||
|
|
||||||
|
// Sync Play/Pause state
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
if (isPlaying && video.paused) {
|
||||||
|
video.play().catch(console.error);
|
||||||
|
} else if (!isPlaying && !video.paused) {
|
||||||
|
video.pause();
|
||||||
|
}
|
||||||
|
}, [isPlaying]);
|
||||||
|
|
||||||
|
// Sync Playback Rate
|
||||||
|
useEffect(() => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.playbackRate = playbackRate;
|
||||||
|
}
|
||||||
|
}, [playbackRate]);
|
||||||
|
|
||||||
|
// Sync Seek (External seek request)
|
||||||
|
// Note: This is tricky because normal playback also updates currentTime.
|
||||||
|
// We need to differentiate between "time updated by video" and "time updated by user seek".
|
||||||
|
// For now, we'll let the video drive the context time, and rely on the Parent/Context
|
||||||
|
// to call a imperative sync if needed, or we implement a "seekRequest" signal in context.
|
||||||
|
// simpler: If context time differs significantly from video time, we seek.
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
if (Math.abs(video.currentTime - currentTime) > 0.5) {
|
||||||
|
video.currentTime = currentTime;
|
||||||
|
}
|
||||||
|
}, [currentTime]);
|
||||||
|
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
setCurrentTime(videoRef.current.currentTime);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
setDuration(videoRef.current.duration);
|
||||||
|
setIsBuffering(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWaiting = () => setIsBuffering(true);
|
||||||
|
const handlePlaying = () => setIsBuffering(false);
|
||||||
|
const handleEnded = () => pause();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group relative rounded-lg overflow-hidden border bg-black shadow-sm">
|
||||||
|
<AspectRatio ratio={16 / 9}>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={src}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
onLoadedMetadata={handleLoadedMetadata}
|
||||||
|
onWaiting={handleWaiting}
|
||||||
|
onPlaying={handlePlaying}
|
||||||
|
onEnded={handleEnded}
|
||||||
|
onClick={togglePlay}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Overlay Controls (Visible on Hover/Pause) */}
|
||||||
|
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-4 opacity-0 transition-opacity group-hover:opacity-100 data-[paused=true]:opacity-100" data-paused={!isPlaying}>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-white hover:bg-white/20"
|
||||||
|
onClick={togglePlay}
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause className="h-6 w-6" /> : <Play className="h-6 w-6 fill-current" />}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<Slider
|
||||||
|
value={[currentTime]}
|
||||||
|
min={0}
|
||||||
|
max={videoRef.current?.duration || 100}
|
||||||
|
step={0.1}
|
||||||
|
onValueChange={([val]) => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.currentTime = val;
|
||||||
|
setCurrentTime(val);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs font-mono text-white/90">
|
||||||
|
{formatTime(currentTime)} / {formatTime(videoRef.current?.duration || 0)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-white hover:bg-white/20"
|
||||||
|
onClick={() => setMuted(!muted)}
|
||||||
|
>
|
||||||
|
{muted || volume === 0 ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isBuffering && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/20 pointer-events-none">
|
||||||
|
<Loader2 className="h-10 w-10 animate-spin text-white/80" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AspectRatio>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(seconds: number) {
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = Math.floor(seconds % 60);
|
||||||
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
import { LineChart, BarChart, Clock, Database, FileText, AlertTriangle, CheckCircle, VideoOff, Info } from "lucide-react";
|
||||||
import { LineChart, BarChart, Clock, Database, FileText } from "lucide-react";
|
import { PlaybackProvider } from "../playback/PlaybackContext";
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { PlaybackPlayer } from "../playback/PlaybackPlayer";
|
||||||
|
import { EventTimeline } from "../playback/EventTimeline";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
ResizableHandle,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from "~/components/ui/resizable";
|
||||||
|
|
||||||
interface TrialAnalysisViewProps {
|
interface TrialAnalysisViewProps {
|
||||||
trial: {
|
trial: {
|
||||||
@@ -17,108 +25,165 @@ interface TrialAnalysisViewProps {
|
|||||||
participant: { participantCode: string };
|
participant: { participantCode: string };
|
||||||
eventCount?: number;
|
eventCount?: number;
|
||||||
mediaCount?: number;
|
mediaCount?: number;
|
||||||
|
media?: { url: string; contentType: string }[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TrialAnalysisView({ trial }: TrialAnalysisViewProps) {
|
export function TrialAnalysisView({ trial }: TrialAnalysisViewProps) {
|
||||||
|
// Fetch events for timeline
|
||||||
|
const { data: events = [] } = api.trials.getEvents.useQuery({
|
||||||
|
trialId: trial.id,
|
||||||
|
limit: 1000
|
||||||
|
});
|
||||||
|
|
||||||
|
const videoMedia = trial.media?.find(m => m.contentType.startsWith("video/"));
|
||||||
|
const videoUrl = videoMedia?.url;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
<PlaybackProvider events={events} startTime={trial.startedAt ?? undefined}>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="h-[calc(100vh-8rem)] flex flex-col bg-background rounded-lg border shadow-sm overflow-hidden">
|
||||||
<Card>
|
{/* Header Context */}
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<div className="flex items-center justify-between p-3 border-b bg-muted/20 flex-none h-14">
|
||||||
<CardTitle className="text-sm font-medium">Status</CardTitle>
|
<div className="flex items-center gap-4">
|
||||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
<div className="flex flex-col">
|
||||||
</CardHeader>
|
<h1 className="text-base font-semibold leading-none">
|
||||||
<CardContent>
|
{trial.experiment.name}
|
||||||
<div className="text-2xl font-bold capitalize">{trial.status.replace("_", " ")}</div>
|
</h1>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
{trial.completedAt
|
{trial.participant.participantCode} • Session {trial.id.slice(0, 4)}...
|
||||||
? `Completed ${formatDistanceToNow(new Date(trial.completedAt), { addSuffix: true })}`
|
|
||||||
: "Not completed"}
|
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Duration</CardTitle>
|
|
||||||
<BarChart className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{trial.duration ? `${Math.floor(trial.duration / 60)}m ${trial.duration % 60}s` : "N/A"}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<div className="h-8 w-px bg-border" />
|
||||||
Total execution time
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
</p>
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Clock className="h-3.5 w-3.5" />
|
||||||
|
<span>{trial.startedAt?.toLocaleDateString()} {trial.startedAt?.toLocaleTimeString()}</span>
|
||||||
|
</div>
|
||||||
|
{trial.duration && (
|
||||||
|
<Badge variant="secondary" className="text-[10px] font-mono">
|
||||||
|
{Math.floor(trial.duration / 60)}m {trial.duration % 60}s
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Resizable Workspace */}
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
<ResizablePanelGroup direction="horizontal">
|
||||||
|
|
||||||
|
{/* LEFT: Video & Timeline */}
|
||||||
|
<ResizablePanel defaultSize={65} minSize={30} className="flex flex-col min-h-0">
|
||||||
|
<ResizablePanelGroup direction="vertical">
|
||||||
|
{/* Top: Video Player */}
|
||||||
|
<ResizablePanel defaultSize={75} minSize={20} className="bg-black relative">
|
||||||
|
{videoUrl ? (
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<PlaybackPlayer src={videoUrl} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full w-full flex flex-col items-center justify-center text-slate-500">
|
||||||
|
<VideoOff className="h-12 w-12 mb-3 opacity-20" />
|
||||||
|
<p className="text-sm">No recording available.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
{/* Bottom: Timeline Track */}
|
||||||
|
<ResizablePanel defaultSize={25} minSize={10} className="bg-background flex flex-col min-h-0">
|
||||||
|
<div className="p-2 border-b flex-none bg-muted/10 flex items-center gap-2">
|
||||||
|
<Info className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<span className="text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Timeline Track</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 relative">
|
||||||
|
<div className="absolute inset-0 p-2 overflow-hidden">
|
||||||
|
<EventTimeline />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
{/* RIGHT: Logs & Metrics */}
|
||||||
|
<ResizablePanel defaultSize={35} minSize={20} className="flex flex-col min-h-0 border-l bg-muted/5">
|
||||||
|
{/* Metrics Strip */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 p-3 border-b bg-background flex-none">
|
||||||
|
<Card className="shadow-none border-dashed bg-transparent">
|
||||||
|
<CardContent className="p-3 py-2">
|
||||||
|
<div className="text-[10px] uppercase text-muted-foreground font-semibold mb-0.5">Interventions</div>
|
||||||
|
<div className="text-xl font-mono font-bold flex items-center gap-2">
|
||||||
|
{events.filter(e => e.eventType.includes("intervention")).length}
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5 text-yellow-500" />
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card className="shadow-none border-dashed bg-transparent">
|
||||||
<Card>
|
<CardContent className="p-3 py-2">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<div className="text-[10px] uppercase text-muted-foreground font-semibold mb-0.5">Status</div>
|
||||||
<CardTitle className="text-sm font-medium">Events Logged</CardTitle>
|
<div className="text-xl font-mono font-bold flex items-center gap-2">
|
||||||
<Database className="h-4 w-4 text-muted-foreground" />
|
{trial.status === 'completed' ? 'PASS' : 'INC'}
|
||||||
</CardHeader>
|
<div className={`h-2 w-2 rounded-full ${trial.status === 'completed' ? 'bg-green-500' : 'bg-orange-500'}`} />
|
||||||
<CardContent>
|
</div>
|
||||||
<div className="text-2xl font-bold">{trial.eventCount ?? 0}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
System & user events
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Media Files</CardTitle>
|
|
||||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{trial.mediaCount ?? 0}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Recordings & snapshots
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="overview" className="space-y-4">
|
{/* Log Title */}
|
||||||
<TabsList>
|
<div className="p-2 px-3 border-b bg-muted/20 flex items-center justify-between flex-none">
|
||||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
<span className="text-xs font-semibold flex items-center gap-2">
|
||||||
<TabsTrigger value="events">Event Log</TabsTrigger>
|
<FileText className="h-3.5 w-3.5 text-primary" />
|
||||||
<TabsTrigger value="charts">Charts</TabsTrigger>
|
Event Log
|
||||||
</TabsList>
|
</span>
|
||||||
<TabsContent value="overview" className="space-y-4">
|
<Badge variant="outline" className="text-[10px] h-5">{events.length} Events</Badge>
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Analysis Overview</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Summary of trial execution for {trial.participant.participantCode} in experiment {trial.experiment.name}.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="h-[400px] flex items-center justify-center border-2 border-dashed rounded-md m-4">
|
|
||||||
<div className="text-center text-muted-foreground">
|
|
||||||
<LineChart className="h-10 w-10 mx-auto mb-2 opacity-20" />
|
|
||||||
<p>Detailed analysis visualizations coming soon.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
{/* Scrollable Event List */}
|
||||||
</TabsContent>
|
<div className="flex-1 min-h-0 relative bg-background/50">
|
||||||
<TabsContent value="events">
|
<ScrollArea className="h-full">
|
||||||
<Card>
|
<div className="divide-y divide-border/50">
|
||||||
<CardHeader>
|
{events.map((event, i) => (
|
||||||
<CardTitle>Event Log</CardTitle>
|
<div key={i} className="p-3 py-2 text-sm hover:bg-accent/50 transition-colors cursor-pointer group flex gap-3 items-start">
|
||||||
<CardDescription>
|
<div className="font-mono text-[10px] text-muted-foreground mt-0.5 min-w-[3rem]">
|
||||||
Chronological record of all trial events.
|
{formatTime(new Date(event.timestamp).getTime() - (trial.startedAt?.getTime() ?? 0))}
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="h-[400px] flex items-center justify-center border-2 border-dashed rounded-md m-4">
|
|
||||||
<div className="text-center text-muted-foreground">
|
|
||||||
<p>Event log view placeholder.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
<div className="flex-1 min-w-0 space-y-1">
|
||||||
</Card>
|
<div className="flex items-center justify-between">
|
||||||
</TabsContent>
|
<span className="font-medium text-xs text-foreground group-hover:text-primary transition-colors">
|
||||||
</Tabs>
|
{event.eventType.replace(/_/g, " ")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{event.data && (
|
||||||
|
<div className="text-[10px] text-muted-foreground bg-muted p-1.5 rounded border font-mono whitespace-pre-wrap break-all opacity-80 group-hover:opacity-100">
|
||||||
|
{JSON.stringify(event.data as object, null, 1).replace(/"/g, '').replace(/[{}]/g, '').trim()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{events.length === 0 && (
|
||||||
|
<div className="p-8 text-center text-xs text-muted-foreground italic">
|
||||||
|
No events found in log.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PlaybackProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatTime(ms: number) {
|
||||||
|
if (ms < 0) return "0:00";
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const m = Math.floor(totalSeconds / 60);
|
||||||
|
const s = Math.floor(totalSeconds % 60);
|
||||||
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,366 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Activity,
|
|
||||||
AlertTriangle,
|
|
||||||
Battery,
|
|
||||||
BatteryLow,
|
|
||||||
Bot,
|
|
||||||
CheckCircle,
|
|
||||||
Clock,
|
|
||||||
RefreshCw,
|
|
||||||
Signal,
|
|
||||||
SignalHigh,
|
|
||||||
SignalLow,
|
|
||||||
SignalMedium,
|
|
||||||
WifiOff,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
|
||||||
import { Badge } from "~/components/ui/badge";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
|
|
||||||
import { Progress } from "~/components/ui/progress";
|
|
||||||
|
|
||||||
interface RobotStatusProps {
|
|
||||||
trialId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RobotStatus {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
connectionStatus: "connected" | "disconnected" | "connecting" | "error";
|
|
||||||
batteryLevel?: number;
|
|
||||||
signalStrength?: number;
|
|
||||||
currentMode: string;
|
|
||||||
lastHeartbeat?: Date;
|
|
||||||
errorMessage?: string;
|
|
||||||
capabilities: string[];
|
|
||||||
communicationProtocol: string;
|
|
||||||
isMoving: boolean;
|
|
||||||
position?: {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
z?: number;
|
|
||||||
orientation?: number;
|
|
||||||
};
|
|
||||||
sensors?: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RobotStatus({ trialId: _trialId }: RobotStatusProps) {
|
|
||||||
const [robotStatus, setRobotStatus] = useState<RobotStatus | null>(null);
|
|
||||||
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
|
|
||||||
// Mock robot status - in real implementation, this would come from API/WebSocket
|
|
||||||
useEffect(() => {
|
|
||||||
// Simulate robot status updates
|
|
||||||
const mockStatus: RobotStatus = {
|
|
||||||
id: "robot_001",
|
|
||||||
name: "TurtleBot3 Burger",
|
|
||||||
connectionStatus: "connected",
|
|
||||||
batteryLevel: 85,
|
|
||||||
signalStrength: 75,
|
|
||||||
currentMode: "autonomous_navigation",
|
|
||||||
lastHeartbeat: new Date(),
|
|
||||||
capabilities: ["navigation", "manipulation", "speech", "vision"],
|
|
||||||
communicationProtocol: "ROS2",
|
|
||||||
isMoving: false,
|
|
||||||
position: {
|
|
||||||
x: 1.2,
|
|
||||||
y: 0.8,
|
|
||||||
orientation: 45,
|
|
||||||
},
|
|
||||||
sensors: {
|
|
||||||
lidar: "operational",
|
|
||||||
camera: "operational",
|
|
||||||
imu: "operational",
|
|
||||||
odometry: "operational",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
setRobotStatus(mockStatus);
|
|
||||||
|
|
||||||
// Simulate periodic updates
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
setRobotStatus((prev) => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
batteryLevel: Math.max(
|
|
||||||
0,
|
|
||||||
(prev.batteryLevel ?? 0) - Math.random() * 0.5,
|
|
||||||
),
|
|
||||||
signalStrength: Math.max(
|
|
||||||
0,
|
|
||||||
Math.min(
|
|
||||||
100,
|
|
||||||
(prev.signalStrength ?? 0) + (Math.random() - 0.5) * 10,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
lastHeartbeat: new Date(),
|
|
||||||
position: prev.position
|
|
||||||
? {
|
|
||||||
...prev.position,
|
|
||||||
x: prev.position.x + (Math.random() - 0.5) * 0.1,
|
|
||||||
y: prev.position.y + (Math.random() - 0.5) * 0.1,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setLastUpdate(new Date());
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getConnectionStatusConfig = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case "connected":
|
|
||||||
return {
|
|
||||||
icon: CheckCircle,
|
|
||||||
color: "text-green-600",
|
|
||||||
bgColor: "bg-green-100",
|
|
||||||
label: "Connected",
|
|
||||||
};
|
|
||||||
case "connecting":
|
|
||||||
return {
|
|
||||||
icon: RefreshCw,
|
|
||||||
color: "text-blue-600",
|
|
||||||
bgColor: "bg-blue-100",
|
|
||||||
label: "Connecting",
|
|
||||||
};
|
|
||||||
case "disconnected":
|
|
||||||
return {
|
|
||||||
icon: WifiOff,
|
|
||||||
color: "text-gray-600",
|
|
||||||
bgColor: "bg-gray-100",
|
|
||||||
label: "Disconnected",
|
|
||||||
};
|
|
||||||
case "error":
|
|
||||||
return {
|
|
||||||
icon: AlertTriangle,
|
|
||||||
color: "text-red-600",
|
|
||||||
bgColor: "bg-red-100",
|
|
||||||
label: "Error",
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
icon: WifiOff,
|
|
||||||
color: "text-gray-600",
|
|
||||||
bgColor: "bg-gray-100",
|
|
||||||
label: "Unknown",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSignalIcon = (strength: number) => {
|
|
||||||
if (strength >= 75) return SignalHigh;
|
|
||||||
if (strength >= 50) return SignalMedium;
|
|
||||||
if (strength >= 25) return SignalLow;
|
|
||||||
return Signal;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getBatteryIcon = (level: number) => {
|
|
||||||
return level <= 20 ? BatteryLow : Battery;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRefreshStatus = async () => {
|
|
||||||
setRefreshing(true);
|
|
||||||
// Simulate API call
|
|
||||||
setTimeout(() => {
|
|
||||||
setRefreshing(false);
|
|
||||||
setLastUpdate(new Date());
|
|
||||||
}, 1000);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!robotStatus) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="rounded-lg border p-4 text-center">
|
|
||||||
<div className="text-slate-500">
|
|
||||||
<Bot className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
|
||||||
<p className="text-sm">No robot connected</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusConfig = getConnectionStatusConfig(robotStatus.connectionStatus);
|
|
||||||
const StatusIcon = statusConfig.icon;
|
|
||||||
const SignalIcon = getSignalIcon(robotStatus.signalStrength ?? 0);
|
|
||||||
const BatteryIcon = getBatteryIcon(robotStatus.batteryLevel ?? 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-end">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleRefreshStatus}
|
|
||||||
disabled={refreshing}
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Status Card */}
|
|
||||||
<div className="rounded-lg border p-4">
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* Robot Info */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="font-medium text-slate-900">{robotStatus.name}</div>
|
|
||||||
<Badge
|
|
||||||
className={`${statusConfig.bgColor} ${statusConfig.color}`}
|
|
||||||
variant="secondary"
|
|
||||||
>
|
|
||||||
<StatusIcon className="mr-1 h-3 w-3" />
|
|
||||||
{statusConfig.label}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Connection Details */}
|
|
||||||
<div className="text-sm text-slate-600">
|
|
||||||
Protocol: {robotStatus.communicationProtocol}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Indicators */}
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{/* Battery */}
|
|
||||||
{robotStatus.batteryLevel !== undefined && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center space-x-1 text-xs text-slate-600">
|
|
||||||
<BatteryIcon className="h-3 w-3" />
|
|
||||||
<span>Battery</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Progress
|
|
||||||
value={robotStatus.batteryLevel}
|
|
||||||
className="h-1.5 flex-1"
|
|
||||||
/>
|
|
||||||
<span className="w-8 text-xs font-medium">
|
|
||||||
{Math.round(robotStatus.batteryLevel)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Signal Strength */}
|
|
||||||
{robotStatus.signalStrength !== undefined && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center space-x-1 text-xs text-slate-600">
|
|
||||||
<SignalIcon className="h-3 w-3" />
|
|
||||||
<span>Signal</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Progress
|
|
||||||
value={robotStatus.signalStrength}
|
|
||||||
className="h-1.5 flex-1"
|
|
||||||
/>
|
|
||||||
<span className="w-8 text-xs font-medium">
|
|
||||||
{Math.round(robotStatus.signalStrength)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Current Mode */}
|
|
||||||
<div className="rounded-lg border p-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Activity className="h-3 w-3 text-slate-600" />
|
|
||||||
<span className="text-sm text-slate-600">Mode:</span>
|
|
||||||
</div>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{robotStatus.currentMode
|
|
||||||
.replace(/_/g, " ")
|
|
||||||
.replace(/\b\w/g, (l) => l.toUpperCase())}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
{robotStatus.isMoving && (
|
|
||||||
<div className="mt-2 flex items-center space-x-1 text-xs">
|
|
||||||
<div className="h-1.5 w-1.5 animate-pulse rounded-full"></div>
|
|
||||||
<span>Robot is moving</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Position Info */}
|
|
||||||
{robotStatus.position && (
|
|
||||||
<div className="rounded-lg border p-4">
|
|
||||||
<div className="mb-3 text-sm font-medium text-slate-700">
|
|
||||||
Position
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-slate-600">X:</span>
|
|
||||||
<span className="font-mono">
|
|
||||||
{robotStatus.position.x.toFixed(2)}m
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-slate-600">Y:</span>
|
|
||||||
<span className="font-mono">
|
|
||||||
{robotStatus.position.y.toFixed(2)}m
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{robotStatus.position.orientation !== undefined && (
|
|
||||||
<div className="col-span-2 flex justify-between">
|
|
||||||
<span className="text-slate-600">Orientation:</span>
|
|
||||||
<span className="font-mono">
|
|
||||||
{Math.round(robotStatus.position.orientation)}°
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Sensors Status */}
|
|
||||||
{robotStatus.sensors && (
|
|
||||||
<div className="rounded-lg border p-4">
|
|
||||||
<div className="mb-3 text-sm font-medium text-slate-700">Sensors</div>
|
|
||||||
<div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{Object.entries(robotStatus.sensors).map(([sensor, status]) => (
|
|
||||||
<div
|
|
||||||
key={sensor}
|
|
||||||
className="flex items-center justify-between text-xs"
|
|
||||||
>
|
|
||||||
<span className="text-slate-600 capitalize">{sensor}:</span>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error Alert */}
|
|
||||||
{robotStatus.errorMessage && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertTriangle className="h-4 w-4" />
|
|
||||||
<AlertDescription className="text-sm">
|
|
||||||
{robotStatus.errorMessage}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Last Update */}
|
|
||||||
<div className="flex items-center space-x-1 text-xs text-slate-500">
|
|
||||||
<Clock className="h-3 w-3" />
|
|
||||||
<span>Last update: {lastUpdate.toLocaleTimeString()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -195,9 +195,15 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update local trial state from polling
|
// Update local trial state from polling only if changed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pollingData) {
|
if (pollingData && JSON.stringify(pollingData) !== JSON.stringify(trial)) {
|
||||||
|
// Only update if specific fields we care about have changed to avoid
|
||||||
|
// unnecessary re-renders that might cause UI flashing
|
||||||
|
if (pollingData.status !== trial.status ||
|
||||||
|
pollingData.startedAt?.getTime() !== trial.startedAt?.getTime() ||
|
||||||
|
pollingData.completedAt?.getTime() !== trial.completedAt?.getTime()) {
|
||||||
|
|
||||||
setTrial((prev) => ({
|
setTrial((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
status: pollingData.status,
|
status: pollingData.status,
|
||||||
@@ -209,7 +215,8 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
: prev.completedAt,
|
: prev.completedAt,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}, [pollingData]);
|
}
|
||||||
|
}, [pollingData, trial]);
|
||||||
|
|
||||||
// Auto-start trial on mount if scheduled
|
// Auto-start trial on mount if scheduled
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -675,6 +682,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
onTabChange={setControlPanelTab}
|
onTabChange={setControlPanelTab}
|
||||||
isStarting={startTrialMutation.isPending}
|
isStarting={startTrialMutation.isPending}
|
||||||
onSetAutonomousLife={setAutonomousLife}
|
onSetAutonomousLife={setAutonomousLife}
|
||||||
|
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
center={
|
center={
|
||||||
@@ -695,6 +703,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
completedActionsCount={completedActionsCount}
|
completedActionsCount={completedActionsCount}
|
||||||
onActionCompleted={() => setCompletedActionsCount(c => c + 1)}
|
onActionCompleted={() => setCompletedActionsCount(c => c + 1)}
|
||||||
onCompleteTrial={handleCompleteTrial}
|
onCompleteTrial={handleCompleteTrial}
|
||||||
|
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
right={
|
right={
|
||||||
@@ -706,6 +715,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
connectRos={connectRos}
|
connectRos={connectRos}
|
||||||
disconnectRos={disconnectRos}
|
disconnectRos={disconnectRos}
|
||||||
executeRosAction={executeRosAction}
|
executeRosAction={executeRosAction}
|
||||||
|
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
showDividers={true}
|
showDividers={true}
|
||||||
@@ -720,6 +730,9 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
onAddAnnotation={handleAddAnnotation}
|
onAddAnnotation={handleAddAnnotation}
|
||||||
isSubmitting={addAnnotationMutation.isPending}
|
isSubmitting={addAnnotationMutation.isPending}
|
||||||
trialEvents={trialEvents}
|
trialEvents={trialEvents}
|
||||||
|
// Observation pane is where observers usually work, so not readOnly for them?
|
||||||
|
// But maybe we want 'readOnly' for completed trials.
|
||||||
|
readOnly={trial.status === 'completed'}
|
||||||
/>
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
|
|||||||
268
src/components/trials/wizard/panels/WebcamPanel.tsx
Normal file
268
src/components/trials/wizard/panels/WebcamPanel.tsx
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useCallback, useRef, useState } from "react";
|
||||||
|
import Webcam from "react-webcam";
|
||||||
|
import { Camera, CameraOff, Video, StopCircle, Loader2 } from "lucide-react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
|
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||||
|
import { AspectRatio } from "~/components/ui/aspect-ratio";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
|
||||||
|
export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
|
||||||
|
const [deviceId, setDeviceId] = useState<string | null>(null);
|
||||||
|
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
||||||
|
const [isCameraEnabled, setIsCameraEnabled] = useState(false);
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const webcamRef = useRef<Webcam>(null);
|
||||||
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
|
|
||||||
|
// TRPC mutation for presigned URL
|
||||||
|
const getUploadUrlMutation = api.storage.getUploadPresignedUrl.useMutation();
|
||||||
|
|
||||||
|
const handleDevices = useCallback(
|
||||||
|
(mediaDevices: MediaDeviceInfo[]) => {
|
||||||
|
setDevices(mediaDevices.filter(({ kind, deviceId }) => kind === "videoinput" && deviceId !== ""));
|
||||||
|
},
|
||||||
|
[setDevices],
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
navigator.mediaDevices.enumerateDevices().then(handleDevices);
|
||||||
|
}, [handleDevices]);
|
||||||
|
|
||||||
|
const handleEnableCamera = () => {
|
||||||
|
setIsCameraEnabled(true);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisableCamera = () => {
|
||||||
|
if (isRecording) {
|
||||||
|
handleStopRecording();
|
||||||
|
}
|
||||||
|
setIsCameraEnabled(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartRecording = () => {
|
||||||
|
if (!webcamRef.current?.stream) return;
|
||||||
|
|
||||||
|
setIsRecording(true);
|
||||||
|
chunksRef.current = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const recorder = new MediaRecorder(webcamRef.current.stream, {
|
||||||
|
mimeType: "video/webm"
|
||||||
|
});
|
||||||
|
|
||||||
|
recorder.ondataavailable = (event) => {
|
||||||
|
if (event.data.size > 0) {
|
||||||
|
chunksRef.current.push(event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
recorder.onstop = async () => {
|
||||||
|
const blob = new Blob(chunksRef.current, { type: "video/webm" });
|
||||||
|
await handleUpload(blob);
|
||||||
|
};
|
||||||
|
|
||||||
|
recorder.start();
|
||||||
|
mediaRecorderRef.current = recorder;
|
||||||
|
toast.success("Recording started");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to start recorder:", e);
|
||||||
|
toast.error("Failed to start recording");
|
||||||
|
setIsRecording(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStopRecording = () => {
|
||||||
|
if (mediaRecorderRef.current && isRecording) {
|
||||||
|
mediaRecorderRef.current.stop();
|
||||||
|
setIsRecording(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async (blob: Blob) => {
|
||||||
|
setUploading(true);
|
||||||
|
const filename = `recording-${Date.now()}.webm`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Get Presigned URL
|
||||||
|
const { url } = await getUploadUrlMutation.mutateAsync({
|
||||||
|
filename,
|
||||||
|
contentType: "video/webm",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Upload to S3
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "PUT",
|
||||||
|
body: blob,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "video/webm",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Upload failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Recording uploaded successfully");
|
||||||
|
console.log("Uploaded recording:", filename);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Upload error:", e);
|
||||||
|
toast.error("Failed to upload recording");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex items-center justify-between border-b p-3">
|
||||||
|
<h2 className="text-sm font-semibold flex items-center gap-2">
|
||||||
|
<Camera className="h-4 w-4" />
|
||||||
|
Webcam Feed
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{devices.length > 0 && (
|
||||||
|
<Select
|
||||||
|
value={deviceId ?? undefined}
|
||||||
|
onValueChange={setDeviceId}
|
||||||
|
disabled={!isCameraEnabled || isRecording}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 w-[130px] text-xs">
|
||||||
|
<SelectValue placeholder="Select Camera" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{devices.map((device, key) => (
|
||||||
|
<SelectItem key={key} value={device.deviceId} className="text-xs">
|
||||||
|
{device.label || `Camera ${key + 1}`}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCameraEnabled && (
|
||||||
|
!isRecording ? (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs animate-in fade-in"
|
||||||
|
onClick={handleStartRecording}
|
||||||
|
disabled={uploading}
|
||||||
|
>
|
||||||
|
<Video className="mr-1 h-3 w-3" />
|
||||||
|
Record
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs border-red-500 border text-red-500 hover:bg-red-50"
|
||||||
|
onClick={handleStopRecording}
|
||||||
|
>
|
||||||
|
<StopCircle className="mr-1 h-3 w-3 animate-pulse" />
|
||||||
|
Stop Rec
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCameraEnabled ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={handleDisableCamera}
|
||||||
|
disabled={isRecording}
|
||||||
|
>
|
||||||
|
<CameraOff className="mr-1 h-3 w-3" />
|
||||||
|
Off
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
onClick={handleEnableCamera}
|
||||||
|
>
|
||||||
|
<Camera className="mr-1 h-3 w-3" />
|
||||||
|
Start Camera
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden bg-black p-4 flex items-center justify-center relative">
|
||||||
|
{isCameraEnabled ? (
|
||||||
|
<div className="w-full relative rounded-lg overflow-hidden border border-slate-800">
|
||||||
|
<AspectRatio ratio={16 / 9}>
|
||||||
|
<Webcam
|
||||||
|
ref={webcamRef}
|
||||||
|
audio={false}
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
videoConstraints={{ deviceId: deviceId ?? undefined }}
|
||||||
|
onUserMediaError={(err) => setError(String(err))}
|
||||||
|
className="object-contain w-full h-full"
|
||||||
|
/>
|
||||||
|
</AspectRatio>
|
||||||
|
|
||||||
|
{/* Recording Overlay */}
|
||||||
|
{isRecording && (
|
||||||
|
<div className="absolute top-2 right-2 flex items-center gap-2 bg-black/50 px-2 py-1 rounded-full backdrop-blur-sm">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
|
||||||
|
<span className="text-[10px] font-medium text-white">REC</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Uploading Overlay */}
|
||||||
|
{uploading && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<div className="flex flex-col items-center gap-2 text-white">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
<span className="text-xs font-medium">Uploading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/80">
|
||||||
|
<Alert variant="destructive" className="max-w-xs">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-slate-500">
|
||||||
|
<CameraOff className="mx-auto mb-2 h-12 w-12 opacity-20" />
|
||||||
|
<p className="text-sm">Camera is disabled</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-4"
|
||||||
|
onClick={handleEnableCamera}
|
||||||
|
>
|
||||||
|
Enable Camera
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -98,6 +98,7 @@ interface WizardControlPanelProps {
|
|||||||
onTabChange: (tab: "control" | "step" | "actions" | "robot") => void;
|
onTabChange: (tab: "control" | "step" | "actions" | "robot") => void;
|
||||||
isStarting?: boolean;
|
isStarting?: boolean;
|
||||||
onSetAutonomousLife?: (enabled: boolean) => Promise<boolean | void>;
|
onSetAutonomousLife?: (enabled: boolean) => Promise<boolean | void>;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WizardControlPanel({
|
export function WizardControlPanel({
|
||||||
@@ -118,6 +119,7 @@ export function WizardControlPanel({
|
|||||||
onTabChange,
|
onTabChange,
|
||||||
isStarting = false,
|
isStarting = false,
|
||||||
onSetAutonomousLife,
|
onSetAutonomousLife,
|
||||||
|
readOnly = false,
|
||||||
}: WizardControlPanelProps) {
|
}: WizardControlPanelProps) {
|
||||||
const [autonomousLife, setAutonomousLife] = React.useState(true);
|
const [autonomousLife, setAutonomousLife] = React.useState(true);
|
||||||
|
|
||||||
@@ -187,7 +189,7 @@ export function WizardControlPanel({
|
|||||||
}}
|
}}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={isStarting}
|
disabled={isStarting || readOnly}
|
||||||
>
|
>
|
||||||
<Play className="mr-2 h-4 w-4" />
|
<Play className="mr-2 h-4 w-4" />
|
||||||
{isStarting ? "Starting..." : "Start Trial"}
|
{isStarting ? "Starting..." : "Start Trial"}
|
||||||
@@ -201,14 +203,14 @@ export function WizardControlPanel({
|
|||||||
onClick={onPauseTrial}
|
onClick={onPauseTrial}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={false}
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
<Pause className="mr-1 h-3 w-3" />
|
<Pause className="mr-1 h-3 w-3" />
|
||||||
Pause
|
Pause
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={onNextStep}
|
onClick={onNextStep}
|
||||||
disabled={currentStepIndex >= steps.length - 1}
|
disabled={(currentStepIndex >= steps.length - 1) || readOnly}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
<SkipForward className="mr-1 h-3 w-3" />
|
<SkipForward className="mr-1 h-3 w-3" />
|
||||||
@@ -223,6 +225,7 @@ export function WizardControlPanel({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
<CheckCircle className="mr-2 h-4 w-4" />
|
<CheckCircle className="mr-2 h-4 w-4" />
|
||||||
Complete Trial
|
Complete Trial
|
||||||
@@ -233,6 +236,7 @@ export function WizardControlPanel({
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
<X className="mr-2 h-4 w-4" />
|
<X className="mr-2 h-4 w-4" />
|
||||||
Abort Trial
|
Abort Trial
|
||||||
@@ -277,7 +281,7 @@ export function WizardControlPanel({
|
|||||||
id="autonomous-life"
|
id="autonomous-life"
|
||||||
checked={autonomousLife}
|
checked={autonomousLife}
|
||||||
onCheckedChange={handleAutonomousLifeChange}
|
onCheckedChange={handleAutonomousLifeChange}
|
||||||
disabled={!_isConnected}
|
disabled={!_isConnected || readOnly}
|
||||||
className="scale-75"
|
className="scale-75"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -368,7 +372,7 @@ export function WizardControlPanel({
|
|||||||
console.log("[WizardControlPanel] Acknowledge clicked");
|
console.log("[WizardControlPanel] Acknowledge clicked");
|
||||||
onExecuteAction("acknowledge");
|
onExecuteAction("acknowledge");
|
||||||
}}
|
}}
|
||||||
disabled={false}
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
<CheckCircle className="mr-2 h-3 w-3" />
|
<CheckCircle className="mr-2 h-3 w-3" />
|
||||||
Acknowledge
|
Acknowledge
|
||||||
@@ -382,7 +386,7 @@ export function WizardControlPanel({
|
|||||||
console.log("[WizardControlPanel] Intervene clicked");
|
console.log("[WizardControlPanel] Intervene clicked");
|
||||||
onExecuteAction("intervene");
|
onExecuteAction("intervene");
|
||||||
}}
|
}}
|
||||||
disabled={false}
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
<AlertCircle className="mr-2 h-3 w-3" />
|
<AlertCircle className="mr-2 h-3 w-3" />
|
||||||
Intervene
|
Intervene
|
||||||
@@ -396,7 +400,7 @@ export function WizardControlPanel({
|
|||||||
console.log("[WizardControlPanel] Add Note clicked");
|
console.log("[WizardControlPanel] Add Note clicked");
|
||||||
onExecuteAction("note", { content: "Wizard note" });
|
onExecuteAction("note", { content: "Wizard note" });
|
||||||
}}
|
}}
|
||||||
disabled={false}
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
<User className="mr-2 h-3 w-3" />
|
<User className="mr-2 h-3 w-3" />
|
||||||
Add Note
|
Add Note
|
||||||
@@ -412,7 +416,7 @@ export function WizardControlPanel({
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="w-full justify-start"
|
className="w-full justify-start"
|
||||||
onClick={() => onExecuteAction("step_complete")}
|
onClick={() => onExecuteAction("step_complete")}
|
||||||
disabled={false}
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
<CheckCircle className="mr-2 h-3 w-3" />
|
<CheckCircle className="mr-2 h-3 w-3" />
|
||||||
Mark Complete
|
Mark Complete
|
||||||
@@ -441,11 +445,13 @@ export function WizardControlPanel({
|
|||||||
<ScrollArea className="h-full">
|
<ScrollArea className="h-full">
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
{studyId && onExecuteRobotAction ? (
|
{studyId && onExecuteRobotAction ? (
|
||||||
|
<div className={readOnly ? "pointer-events-none opacity-50" : ""}>
|
||||||
<RobotActionsPanel
|
<RobotActionsPanel
|
||||||
studyId={studyId}
|
studyId={studyId}
|
||||||
trialId={trial.id}
|
trialId={trial.id}
|
||||||
onExecuteAction={onExecuteRobotAction}
|
onExecuteAction={onExecuteRobotAction}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
|||||||
@@ -10,18 +10,13 @@ import {
|
|||||||
User,
|
User,
|
||||||
Activity,
|
Activity,
|
||||||
Zap,
|
Zap,
|
||||||
Eye,
|
|
||||||
List,
|
|
||||||
Loader2,
|
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
|
||||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
|
||||||
|
|
||||||
interface StepData {
|
interface StepData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -107,6 +102,7 @@ interface WizardExecutionPanelProps {
|
|||||||
onCompleteTrial?: () => void;
|
onCompleteTrial?: () => void;
|
||||||
completedActionsCount: number;
|
completedActionsCount: number;
|
||||||
onActionCompleted: () => void;
|
onActionCompleted: () => void;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WizardExecutionPanel({
|
export function WizardExecutionPanel({
|
||||||
@@ -126,47 +122,13 @@ export function WizardExecutionPanel({
|
|||||||
onCompleteTrial,
|
onCompleteTrial,
|
||||||
completedActionsCount,
|
completedActionsCount,
|
||||||
onActionCompleted,
|
onActionCompleted,
|
||||||
|
readOnly = false,
|
||||||
}: WizardExecutionPanelProps) {
|
}: WizardExecutionPanelProps) {
|
||||||
// Local state removed in favor of parent state to prevent reset on re-render
|
// Local state removed in favor of parent state to prevent reset on re-render
|
||||||
// const [completedCount, setCompletedCount] = React.useState(0);
|
// const [completedCount, setCompletedCount] = React.useState(0);
|
||||||
|
|
||||||
const activeActionIndex = completedActionsCount;
|
const activeActionIndex = completedActionsCount;
|
||||||
|
|
||||||
const getStepIcon = (type: string) => {
|
|
||||||
switch (type) {
|
|
||||||
case "wizard_action":
|
|
||||||
return User;
|
|
||||||
case "robot_action":
|
|
||||||
return Bot;
|
|
||||||
case "parallel_steps":
|
|
||||||
return Activity;
|
|
||||||
case "conditional_branch":
|
|
||||||
return AlertCircle;
|
|
||||||
default:
|
|
||||||
return Play;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStepStatus = (stepIndex: number) => {
|
|
||||||
if (stepIndex < currentStepIndex) return "completed";
|
|
||||||
if (stepIndex === currentStepIndex && trial.status === "in_progress")
|
|
||||||
return "active";
|
|
||||||
return "pending";
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStepVariant = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case "completed":
|
|
||||||
return "default";
|
|
||||||
case "active":
|
|
||||||
return "secondary";
|
|
||||||
case "pending":
|
|
||||||
return "outline";
|
|
||||||
default:
|
|
||||||
return "outline";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Pre-trial state
|
// Pre-trial state
|
||||||
if (trial.status === "scheduled") {
|
if (trial.status === "scheduled") {
|
||||||
return (
|
return (
|
||||||
@@ -252,7 +214,7 @@ export function WizardExecutionPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Simplified Content - Sequential Focus */}
|
{/* Simplified Content - Sequential Focus */}
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="relative flex-1 overflow-hidden">
|
||||||
<ScrollArea className="h-full">
|
<ScrollArea className="h-full">
|
||||||
{currentStep ? (
|
{currentStep ? (
|
||||||
<div className="flex flex-col gap-6 p-6">
|
<div className="flex flex-col gap-6 p-6">
|
||||||
@@ -281,7 +243,6 @@ export function WizardExecutionPanel({
|
|||||||
{currentStep.actions.map((action, idx) => {
|
{currentStep.actions.map((action, idx) => {
|
||||||
const isCompleted = idx < activeActionIndex;
|
const isCompleted = idx < activeActionIndex;
|
||||||
const isActive = idx === activeActionIndex;
|
const isActive = idx === activeActionIndex;
|
||||||
const isPending = idx > activeActionIndex;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -328,6 +289,7 @@ export function WizardExecutionPanel({
|
|||||||
);
|
);
|
||||||
onActionCompleted();
|
onActionCompleted();
|
||||||
}}
|
}}
|
||||||
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
Skip
|
Skip
|
||||||
</Button>
|
</Button>
|
||||||
@@ -348,6 +310,7 @@ export function WizardExecutionPanel({
|
|||||||
);
|
);
|
||||||
onActionCompleted();
|
onActionCompleted();
|
||||||
}}
|
}}
|
||||||
|
disabled={readOnly || isExecuting}
|
||||||
>
|
>
|
||||||
<Play className="mr-2 h-4 w-4" />
|
<Play className="mr-2 h-4 w-4" />
|
||||||
Execute
|
Execute
|
||||||
@@ -364,6 +327,7 @@ export function WizardExecutionPanel({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onActionCompleted();
|
onActionCompleted();
|
||||||
}}
|
}}
|
||||||
|
disabled={readOnly || isExecuting}
|
||||||
>
|
>
|
||||||
Mark Done
|
Mark Done
|
||||||
</Button>
|
</Button>
|
||||||
@@ -394,6 +358,7 @@ export function WizardExecutionPanel({
|
|||||||
{ autoAdvance: false },
|
{ autoAdvance: false },
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
disabled={readOnly || isExecuting}
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-3.5 w-3.5" />
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -410,6 +375,7 @@ export function WizardExecutionPanel({
|
|||||||
category: "system_issue"
|
category: "system_issue"
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
<AlertTriangle className="h-3.5 w-3.5" />
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -432,6 +398,7 @@ export function WizardExecutionPanel({
|
|||||||
? "bg-blue-600 hover:bg-blue-700"
|
? "bg-blue-600 hover:bg-blue-700"
|
||||||
: "bg-green-600 hover:bg-green-700"
|
: "bg-green-600 hover:bg-green-700"
|
||||||
}`}
|
}`}
|
||||||
|
disabled={readOnly || isExecuting}
|
||||||
>
|
>
|
||||||
{currentStepIndex === steps.length - 1 ? "Complete Trial" : "Complete Step"}
|
{currentStepIndex === steps.length - 1 ? "Complete Trial" : "Complete Step"}
|
||||||
<ArrowRight className="ml-2 h-5 w-5" />
|
<ArrowRight className="ml-2 h-5 w-5" />
|
||||||
@@ -445,22 +412,15 @@ export function WizardExecutionPanel({
|
|||||||
{currentStep.type === "wizard_action" && (
|
{currentStep.type === "wizard_action" && (
|
||||||
<div className="rounded-xl border border-dashed p-6 space-y-4">
|
<div className="rounded-xl border border-dashed p-6 space-y-4">
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Manual Controls</h3>
|
<h3 className="text-sm font-medium text-muted-foreground">Manual Controls</h3>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 gap-3">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-12 justify-start"
|
className="h-12 justify-start border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800"
|
||||||
onClick={() => onExecuteAction("acknowledge")}
|
|
||||||
>
|
|
||||||
<CheckCircle className="mr-2 h-4 w-4" />
|
|
||||||
Acknowledge
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-12 justify-start"
|
|
||||||
onClick={() => onExecuteAction("intervene")}
|
onClick={() => onExecuteAction("intervene")}
|
||||||
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
<Zap className="mr-2 h-4 w-4" />
|
<Zap className="mr-2 h-4 w-4" />
|
||||||
Intervene
|
Flag Issue / Intervention
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -472,6 +432,8 @@ export function WizardExecutionPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
{/* Scroll Hint Fade */}
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-background to-transparent z-10" />
|
||||||
</div>
|
</div>
|
||||||
</div >
|
</div >
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import { Badge } from "~/components/ui/badge";
|
|||||||
import { Separator } from "~/components/ui/separator";
|
import { Separator } from "~/components/ui/separator";
|
||||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||||
import { Progress } from "~/components/ui/progress";
|
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { WebcamPanel } from "./WebcamPanel";
|
||||||
|
|
||||||
interface WizardMonitoringPanelProps {
|
interface WizardMonitoringPanelProps {
|
||||||
rosConnected: boolean;
|
rosConnected: boolean;
|
||||||
@@ -33,6 +33,7 @@ interface WizardMonitoringPanelProps {
|
|||||||
actionId: string,
|
actionId: string,
|
||||||
parameters: Record<string, unknown>,
|
parameters: Record<string, unknown>,
|
||||||
) => Promise<unknown>;
|
) => Promise<unknown>;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WizardMonitoringPanel = function WizardMonitoringPanel({
|
const WizardMonitoringPanel = function WizardMonitoringPanel({
|
||||||
@@ -43,15 +44,21 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
|||||||
connectRos,
|
connectRos,
|
||||||
disconnectRos,
|
disconnectRos,
|
||||||
executeRosAction,
|
executeRosAction,
|
||||||
|
readOnly = false,
|
||||||
}: WizardMonitoringPanelProps) {
|
}: WizardMonitoringPanelProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col gap-2 p-2">
|
||||||
{/* Header */}
|
{/* Camera View - Always Visible */}
|
||||||
<div className="flex items-center justify-between border-b p-3">
|
<div className="shrink-0 bg-black rounded-lg overflow-hidden border shadow-sm h-48 sm:h-56 relative group">
|
||||||
<h2 className="text-sm font-semibold">Robot Control</h2>
|
<WebcamPanel readOnly={readOnly} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Robot Status and Controls */}
|
{/* Robot Controls - Scrollable */}
|
||||||
|
<div className="flex-1 min-h-0 bg-background rounded-lg border shadow-sm overflow-hidden flex flex-col">
|
||||||
|
<div className="px-3 py-2 border-b bg-muted/30 flex items-center gap-2">
|
||||||
|
<Bot className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Robot Control</span>
|
||||||
|
</div>
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="space-y-4 p-3">
|
<div className="space-y-4 p-3">
|
||||||
{/* Robot Status */}
|
{/* Robot Status */}
|
||||||
@@ -112,7 +119,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full text-xs"
|
className="w-full text-xs"
|
||||||
onClick={() => connectRos()}
|
onClick={() => connectRos()}
|
||||||
disabled={rosConnecting || rosConnected}
|
disabled={rosConnecting || rosConnected || readOnly}
|
||||||
>
|
>
|
||||||
<Bot className="mr-1 h-3 w-3" />
|
<Bot className="mr-1 h-3 w-3" />
|
||||||
{rosConnecting
|
{rosConnecting
|
||||||
@@ -127,6 +134,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full text-xs"
|
className="w-full text-xs"
|
||||||
onClick={() => disconnectRos()}
|
onClick={() => disconnectRos()}
|
||||||
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
<PowerOff className="mr-1 h-3 w-3" />
|
<PowerOff className="mr-1 h-3 w-3" />
|
||||||
Disconnect
|
Disconnect
|
||||||
@@ -173,6 +181,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
|||||||
speed: 0.3,
|
speed: 0.3,
|
||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
}}
|
}}
|
||||||
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
↺ Turn L
|
↺ Turn L
|
||||||
</Button>
|
</Button>
|
||||||
@@ -185,6 +194,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
|||||||
speed: 0.5,
|
speed: 0.5,
|
||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
}}
|
}}
|
||||||
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
↑ Forward
|
↑ Forward
|
||||||
</Button>
|
</Button>
|
||||||
@@ -197,6 +207,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
|||||||
speed: 0.3,
|
speed: 0.3,
|
||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
}}
|
}}
|
||||||
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
Turn R ↻
|
Turn R ↻
|
||||||
</Button>
|
</Button>
|
||||||
@@ -211,6 +222,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
|||||||
speed: 0.3,
|
speed: 0.3,
|
||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
}}
|
}}
|
||||||
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
← Left
|
← Left
|
||||||
</Button>
|
</Button>
|
||||||
@@ -223,6 +235,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
|||||||
console.error,
|
console.error,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
■ Stop
|
■ Stop
|
||||||
</Button>
|
</Button>
|
||||||
@@ -235,6 +248,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
|||||||
speed: 0.3,
|
speed: 0.3,
|
||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
}}
|
}}
|
||||||
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
Right →
|
Right →
|
||||||
</Button>
|
</Button>
|
||||||
@@ -250,6 +264,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
|||||||
speed: 0.3,
|
speed: 0.3,
|
||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
}}
|
}}
|
||||||
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
↓ Back
|
↓ Back
|
||||||
</Button>
|
</Button>
|
||||||
@@ -270,9 +285,10 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Type text to speak..."
|
placeholder="Type text to speak..."
|
||||||
className="flex-1 rounded-md border border-input bg-background px-2 py-1 text-xs ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="flex-1 rounded-md border border-input bg-background px-2 py-1 text-xs ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
||||||
|
disabled={readOnly}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && e.currentTarget.value.trim()) {
|
if (e.key === "Enter" && e.currentTarget.value.trim() && !readOnly) {
|
||||||
executeRosAction("nao6-ros2", "say_text", {
|
executeRosAction("nao6-ros2", "say_text", {
|
||||||
text: e.currentTarget.value.trim(),
|
text: e.currentTarget.value.trim(),
|
||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
@@ -293,6 +309,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
|||||||
input.value = "";
|
input.value = "";
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
Say
|
Say
|
||||||
</Button>
|
</Button>
|
||||||
@@ -311,6 +328,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
|||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
Say Hello
|
Say Hello
|
||||||
</Button>
|
</Button>
|
||||||
@@ -325,6 +343,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
|||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
Say Ready
|
Say Ready
|
||||||
</Button>
|
</Button>
|
||||||
@@ -334,6 +353,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
|||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
11
src/components/ui/aspect-ratio.tsx
Normal file
11
src/components/ui/aspect-ratio.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||||
|
|
||||||
|
function AspectRatio({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
||||||
|
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { AspectRatio }
|
||||||
@@ -64,6 +64,7 @@ export interface CompiledExecutionAction {
|
|||||||
parameterSchemaRaw?: unknown;
|
parameterSchemaRaw?: unknown;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
retryable?: boolean;
|
retryable?: boolean;
|
||||||
|
children?: CompiledExecutionAction[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Compile Entry Point ---------- */
|
/* ---------- Compile Entry Point ---------- */
|
||||||
@@ -136,11 +137,12 @@ function compileAction(
|
|||||||
robotId: action.source.robotId,
|
robotId: action.source.robotId,
|
||||||
baseActionId: action.source.baseActionId,
|
baseActionId: action.source.baseActionId,
|
||||||
},
|
},
|
||||||
execution: action.execution,
|
execution: action.execution!, // Assumes validation passed
|
||||||
parameters: action.parameters,
|
parameters: action.parameters,
|
||||||
parameterSchemaRaw: action.parameterSchemaRaw,
|
parameterSchemaRaw: action.parameterSchemaRaw,
|
||||||
timeout: action.execution.timeoutMs,
|
timeout: action.execution?.timeoutMs,
|
||||||
retryable: action.execution.retryable,
|
retryable: action.execution?.retryable,
|
||||||
|
children: action.children?.map((child, i) => compileAction(child, i)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,16 +151,23 @@ function compileAction(
|
|||||||
export function collectPluginDependencies(design: ExperimentDesign): string[] {
|
export function collectPluginDependencies(design: ExperimentDesign): string[] {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
for (const step of design.steps) {
|
for (const step of design.steps) {
|
||||||
for (const action of step.actions) {
|
collectDependenciesFromActions(step.actions, set);
|
||||||
|
}
|
||||||
|
return Array.from(set).sort();
|
||||||
|
}
|
||||||
|
// Helper to recursively collect from actions list directly would be cleaner
|
||||||
|
function collectDependenciesFromActions(actions: ExperimentAction[], set: Set<string>) {
|
||||||
|
for (const action of actions) {
|
||||||
if (action.source.kind === "plugin" && action.source.pluginId) {
|
if (action.source.kind === "plugin" && action.source.pluginId) {
|
||||||
const versionPart = action.source.pluginVersion
|
const versionPart = action.source.pluginVersion
|
||||||
? `@${action.source.pluginVersion}`
|
? `@${action.source.pluginVersion}`
|
||||||
: "";
|
: "";
|
||||||
set.add(`${action.source.pluginId}${versionPart}`);
|
set.add(`${action.source.pluginId}${versionPart}`);
|
||||||
}
|
}
|
||||||
|
if (action.children) {
|
||||||
|
collectDependenciesFromActions(action.children, set);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Array.from(set).sort();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Integrity Hash Generation ---------- */
|
/* ---------- Integrity Hash Generation ---------- */
|
||||||
@@ -199,6 +208,12 @@ function buildStructuralSignature(
|
|||||||
timeout: a.timeout,
|
timeout: a.timeout,
|
||||||
retryable: a.retryable ?? false,
|
retryable: a.retryable ?? false,
|
||||||
parameterKeys: summarizeParametersForHash(a.parameters),
|
parameterKeys: summarizeParametersForHash(a.parameters),
|
||||||
|
children: a.children?.map(c => ({
|
||||||
|
id: c.id,
|
||||||
|
// Recurse structural signature for children
|
||||||
|
type: c.type,
|
||||||
|
parameterKeys: summarizeParametersForHash(c.parameters),
|
||||||
|
})),
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
pluginDependencies,
|
pluginDependencies,
|
||||||
|
|||||||
@@ -53,15 +53,18 @@ export interface ActionDefinition {
|
|||||||
};
|
};
|
||||||
execution?: ExecutionDescriptor;
|
execution?: ExecutionDescriptor;
|
||||||
parameterSchemaRaw?: unknown; // snapshot of original schema for validation/audit
|
parameterSchemaRaw?: unknown; // snapshot of original schema for validation/audit
|
||||||
|
nestable?: boolean; // If true, this action can contain child actions
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExperimentAction {
|
export interface ExperimentAction {
|
||||||
id: string;
|
id: string;
|
||||||
type: ActionType;
|
type: string; // e.g. "wizard_speak", "robot_move"
|
||||||
name: string;
|
name: string;
|
||||||
|
description?: string; // Optional description
|
||||||
parameters: Record<string, unknown>;
|
parameters: Record<string, unknown>;
|
||||||
duration?: number;
|
duration?: number; // Estimated duration in seconds
|
||||||
category: ActionCategory;
|
category: ActionCategory;
|
||||||
|
// Provenance (where did this come from?)
|
||||||
source: {
|
source: {
|
||||||
kind: "core" | "plugin";
|
kind: "core" | "plugin";
|
||||||
pluginId?: string;
|
pluginId?: string;
|
||||||
@@ -69,8 +72,14 @@ export interface ExperimentAction {
|
|||||||
robotId?: string | null;
|
robotId?: string | null;
|
||||||
baseActionId?: string;
|
baseActionId?: string;
|
||||||
};
|
};
|
||||||
execution: ExecutionDescriptor;
|
// Execution (how do we run this?)
|
||||||
|
execution?: ExecutionDescriptor;
|
||||||
|
|
||||||
|
// Snapshot of parameter schema at the time of addition (for drift detection)
|
||||||
parameterSchemaRaw?: unknown;
|
parameterSchemaRaw?: unknown;
|
||||||
|
|
||||||
|
// Nested actions (control flow)
|
||||||
|
children?: ExperimentAction[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StepTrigger {
|
export interface StepTrigger {
|
||||||
|
|||||||
@@ -90,17 +90,26 @@ const executionDescriptorSchema = z
|
|||||||
// Action parameter snapshot is a free-form structure retained for audit
|
// Action parameter snapshot is a free-form structure retained for audit
|
||||||
const parameterSchemaRawSchema = z.unknown().optional();
|
const parameterSchemaRawSchema = z.unknown().optional();
|
||||||
|
|
||||||
// Action schema (loose input → normalized internal)
|
// Base action schema (without recursion)
|
||||||
const visualActionInputSchema = z
|
const baseActionSchema = z.object({
|
||||||
.object({
|
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
type: z.string().min(1),
|
type: z.string().min(1),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
|
description: z.string().optional(),
|
||||||
category: actionCategoryEnum.optional(),
|
category: actionCategoryEnum.optional(),
|
||||||
parameters: z.record(z.string(), z.unknown()).default({}),
|
parameters: z.record(z.string(), z.unknown()).default({}),
|
||||||
source: actionSourceSchema.optional(),
|
source: actionSourceSchema.optional(),
|
||||||
execution: executionDescriptorSchema.optional(),
|
execution: executionDescriptorSchema.optional(),
|
||||||
parameterSchemaRaw: parameterSchemaRawSchema,
|
parameterSchemaRaw: parameterSchemaRawSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
type VisualActionInput = z.infer<typeof baseActionSchema> & {
|
||||||
|
children?: VisualActionInput[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const visualActionInputSchema: z.ZodType<VisualActionInput> = baseActionSchema
|
||||||
|
.extend({
|
||||||
|
children: z.lazy(() => z.array(visualActionInputSchema)).optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -144,8 +153,7 @@ export function parseVisualDesignSteps(raw: unknown): {
|
|||||||
issues.push(
|
issues.push(
|
||||||
...zodErr.issues.map(
|
...zodErr.issues.map(
|
||||||
(issue) =>
|
(issue) =>
|
||||||
`steps${
|
`steps${issue.path.length ? "." + issue.path.join(".") : ""
|
||||||
issue.path.length ? "." + issue.path.join(".") : ""
|
|
||||||
}: ${issue.message} (code=${issue.code})`,
|
}: ${issue.message} (code=${issue.code})`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -155,8 +163,7 @@ export function parseVisualDesignSteps(raw: unknown): {
|
|||||||
// Normalize to internal ExperimentStep[] shape
|
// Normalize to internal ExperimentStep[] shape
|
||||||
const inputSteps = parsed.data;
|
const inputSteps = parsed.data;
|
||||||
|
|
||||||
const normalized: ExperimentStep[] = inputSteps.map((s, idx) => {
|
const normalizeAction = (a: VisualActionInput): ExperimentAction => {
|
||||||
const actions: ExperimentAction[] = s.actions.map((a) => {
|
|
||||||
// Default provenance
|
// Default provenance
|
||||||
const source: {
|
const source: {
|
||||||
kind: "core" | "plugin";
|
kind: "core" | "plugin";
|
||||||
@@ -200,8 +207,9 @@ export function parseVisualDesignSteps(raw: unknown): {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: a.id,
|
id: a.id,
|
||||||
type: a.type, // dynamic (pluginId.actionId)
|
type: a.type,
|
||||||
name: a.name,
|
name: a.name,
|
||||||
|
description: a.description,
|
||||||
parameters: a.parameters ?? {},
|
parameters: a.parameters ?? {},
|
||||||
duration: undefined,
|
duration: undefined,
|
||||||
category: (a.category ?? "wizard") as ActionCategory,
|
category: (a.category ?? "wizard") as ActionCategory,
|
||||||
@@ -216,8 +224,12 @@ export function parseVisualDesignSteps(raw: unknown): {
|
|||||||
},
|
},
|
||||||
execution,
|
execution,
|
||||||
parameterSchemaRaw: a.parameterSchemaRaw,
|
parameterSchemaRaw: a.parameterSchemaRaw,
|
||||||
|
children: a.children?.map(normalizeAction) ?? [],
|
||||||
};
|
};
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const normalized: ExperimentStep[] = inputSteps.map((s, idx) => {
|
||||||
|
const actions: ExperimentAction[] = s.actions.map(normalizeAction);
|
||||||
|
|
||||||
// Construct step
|
// Construct step
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { robotsRouter } from "~/server/api/routers/robots";
|
|||||||
import { studiesRouter } from "~/server/api/routers/studies";
|
import { studiesRouter } from "~/server/api/routers/studies";
|
||||||
import { trialsRouter } from "~/server/api/routers/trials";
|
import { trialsRouter } from "~/server/api/routers/trials";
|
||||||
import { usersRouter } from "~/server/api/routers/users";
|
import { usersRouter } from "~/server/api/routers/users";
|
||||||
|
import { storageRouter } from "~/server/api/routers/storage";
|
||||||
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,6 +33,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
collaboration: collaborationRouter,
|
collaboration: collaborationRouter,
|
||||||
admin: adminRouter,
|
admin: adminRouter,
|
||||||
dashboard: dashboardRouter,
|
dashboard: dashboardRouter,
|
||||||
|
storage: storageRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
71
src/server/api/routers/storage.ts
Normal file
71
src/server/api/routers/storage.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||||
|
import { s3Client } from "~/server/storage";
|
||||||
|
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||||
|
import { env } from "~/env";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { mediaCaptures } from "~/server/db/schema";
|
||||||
|
|
||||||
|
export const storageRouter = createTRPCRouter({
|
||||||
|
getUploadPresignedUrl: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
filename: z.string(),
|
||||||
|
contentType: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const bucket = env.MINIO_BUCKET_NAME ?? "hristudio-data";
|
||||||
|
const key = input.filename;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const command = new PutObjectCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: key,
|
||||||
|
ContentType: input.contentType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
key,
|
||||||
|
bucket,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating presigned URL:", error);
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Failed to generate upload URL",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
saveRecording: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
trialId: z.string(),
|
||||||
|
storagePath: z.string(),
|
||||||
|
fileSize: z.number().optional(),
|
||||||
|
format: z.string().optional(),
|
||||||
|
mediaType: z.enum(["video", "audio", "image"]).default("video"),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const { db } = ctx;
|
||||||
|
|
||||||
|
await db.insert(mediaCaptures).values({
|
||||||
|
trialId: input.trialId,
|
||||||
|
mediaType: input.mediaType,
|
||||||
|
storagePath: input.storagePath,
|
||||||
|
fileSize: input.fileSize,
|
||||||
|
format: input.format,
|
||||||
|
startTimestamp: new Date(), // Approximate
|
||||||
|
// metadata: { uploadedBy: ctx.session.user.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -30,6 +30,10 @@ import {
|
|||||||
TrialExecutionEngine,
|
TrialExecutionEngine,
|
||||||
type ActionDefinition,
|
type ActionDefinition,
|
||||||
} from "~/server/services/trial-execution";
|
} from "~/server/services/trial-execution";
|
||||||
|
import { s3Client } from "~/server/storage";
|
||||||
|
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||||
|
import { env } from "~/env";
|
||||||
|
|
||||||
// Helper function to check if user has access to trial
|
// Helper function to check if user has access to trial
|
||||||
async function checkTrialAccess(
|
async function checkTrialAccess(
|
||||||
@@ -270,15 +274,34 @@ export const trialsRouter = createTRPCRouter({
|
|||||||
.from(trialEvents)
|
.from(trialEvents)
|
||||||
.where(eq(trialEvents.trialId, input.id));
|
.where(eq(trialEvents.trialId, input.id));
|
||||||
|
|
||||||
const mediaCount = await db
|
const media = await db
|
||||||
.select({ count: count() })
|
.select()
|
||||||
.from(mediaCaptures)
|
.from(mediaCaptures)
|
||||||
.where(eq(mediaCaptures.trialId, input.id));
|
.where(eq(mediaCaptures.trialId, input.id))
|
||||||
|
.orderBy(desc(mediaCaptures.createdAt)); // Get latest first
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...trial[0],
|
...trial[0],
|
||||||
eventCount: eventCount[0]?.count ?? 0,
|
eventCount: eventCount[0]?.count ?? 0,
|
||||||
mediaCount: mediaCount[0]?.count ?? 0,
|
mediaCount: media.length,
|
||||||
|
media: await Promise.all(media.map(async (m) => {
|
||||||
|
let url = "";
|
||||||
|
try {
|
||||||
|
// Generate Presigned GET URL
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: env.MINIO_BUCKET_NAME ?? "hristudio-data",
|
||||||
|
Key: m.storagePath,
|
||||||
|
});
|
||||||
|
url = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to sign URL for media", m.id, e);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
url, // Add the signed URL to the response
|
||||||
|
contentType: m.format === 'webm' ? 'video/webm' : 'application/octet-stream', // Infer or store content type
|
||||||
|
};
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
20
src/server/storage.ts
Normal file
20
src/server/storage.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { S3Client } from "@aws-sdk/client-s3";
|
||||||
|
import { env } from "~/env";
|
||||||
|
|
||||||
|
const globalForS3 = globalThis as unknown as {
|
||||||
|
s3Client: S3Client | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const s3Client =
|
||||||
|
globalForS3.s3Client ??
|
||||||
|
new S3Client({
|
||||||
|
region: env.MINIO_REGION ?? "us-east-1",
|
||||||
|
endpoint: env.MINIO_ENDPOINT,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: env.MINIO_ACCESS_KEY ?? "minioadmin",
|
||||||
|
secretAccessKey: env.MINIO_SECRET_KEY ?? "minioadmin",
|
||||||
|
},
|
||||||
|
forcePathStyle: true, // Needed for MinIO
|
||||||
|
});
|
||||||
|
|
||||||
|
if (env.NODE_ENV !== "production") globalForS3.s3Client = s3Client;
|
||||||
@@ -4,8 +4,7 @@
|
|||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans:
|
--font-sans: var(--font-sans), ui-sans-serif, system-ui, sans-serif,
|
||||||
var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif,
|
|
||||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,9 +44,7 @@
|
|||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--font-sans: Geist Mono, monospace;
|
|
||||||
--font-mono: Geist Mono, monospace;
|
|
||||||
--font-serif: Geist Mono, monospace;
|
|
||||||
--radius: 0rem;
|
--radius: 0rem;
|
||||||
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
|
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
|
||||||
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
|
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
|
||||||
@@ -108,9 +105,7 @@
|
|||||||
--sidebar-border: oklch(0.85 0.03 245);
|
--sidebar-border: oklch(0.85 0.03 245);
|
||||||
--sidebar-ring: oklch(0.6 0.05 240);
|
--sidebar-ring: oklch(0.6 0.05 240);
|
||||||
--destructive-foreground: oklch(0.9702 0 0);
|
--destructive-foreground: oklch(0.9702 0 0);
|
||||||
--font-sans: Geist Mono, monospace;
|
|
||||||
--font-serif: Geist Mono, monospace;
|
|
||||||
--font-mono: Geist Mono, monospace;
|
|
||||||
--shadow-color: hsl(0 0% 0%);
|
--shadow-color: hsl(0 0% 0%);
|
||||||
--shadow-opacity: 0;
|
--shadow-opacity: 0;
|
||||||
--shadow-blur: 0px;
|
--shadow-blur: 0px;
|
||||||
@@ -171,6 +166,43 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.12 0.008 250);
|
||||||
|
--foreground: oklch(0.95 0.005 250);
|
||||||
|
--card: oklch(0.18 0.008 250);
|
||||||
|
--card-foreground: oklch(0.95 0.005 250);
|
||||||
|
--popover: oklch(0.2 0.01 250);
|
||||||
|
--popover-foreground: oklch(0.95 0.005 250);
|
||||||
|
--primary: oklch(0.65 0.1 240);
|
||||||
|
--primary-foreground: oklch(0.08 0.02 250);
|
||||||
|
--secondary: oklch(0.25 0.015 245);
|
||||||
|
--secondary-foreground: oklch(0.92 0.008 250);
|
||||||
|
--muted: oklch(0.22 0.01 250);
|
||||||
|
--muted-foreground: oklch(0.65 0.02 245);
|
||||||
|
--accent: oklch(0.35 0.025 245);
|
||||||
|
--accent-foreground: oklch(0.92 0.008 250);
|
||||||
|
--destructive: oklch(0.7022 0.1892 22.2279);
|
||||||
|
--border: oklch(0.3 0.015 250);
|
||||||
|
--input: oklch(0.28 0.015 250);
|
||||||
|
--ring: oklch(0.65 0.1 240);
|
||||||
|
--chart-1: oklch(0.65 0.1 240);
|
||||||
|
--chart-2: oklch(0.7 0.12 200);
|
||||||
|
--chart-3: oklch(0.75 0.15 160);
|
||||||
|
--chart-4: oklch(0.8 0.12 120);
|
||||||
|
--chart-5: oklch(0.7 0.18 80);
|
||||||
|
--sidebar: oklch(0.14 0.025 250);
|
||||||
|
--sidebar-foreground: oklch(0.88 0.02 250);
|
||||||
|
--sidebar-primary: oklch(0.8 0.06 240);
|
||||||
|
--sidebar-primary-foreground: oklch(0.12 0.025 250);
|
||||||
|
--sidebar-accent: oklch(0.22 0.04 245);
|
||||||
|
--sidebar-accent-foreground: oklch(0.88 0.02 250);
|
||||||
|
--sidebar-border: oklch(0.32 0.035 250);
|
||||||
|
--sidebar-ring: oklch(0.55 0.08 240);
|
||||||
|
--destructive-foreground: oklch(0.95 0.01 250);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
|
|||||||
Reference in New Issue
Block a user