16 Commits

Author SHA1 Message Date
Sean O'Connor
3959cf23f7 feat(forms): add public form access and response submission for participants
- Implemented public access to forms with `getPublic` procedure.
- Added `submitPublic` procedure for participants to submit responses.
- Created a new participant form page to handle form display and submission.
- Enhanced form validation and error handling for required fields.
- Introduced CSV export functionality for form responses.
- Updated form listing and template creation procedures.
- Added README for homepage screenshots.
2026-03-23 11:07:02 -04:00
3270e3f8fe docs: update documentation for forms system and role-based access
- Add forms system to README key features
- Update router/table counts to reflect new forms router
- Add forms section to quick-reference with types, templates, routes
- Clarify study-level user roles (owner/researcher/wizard/observer)
2026-03-22 18:11:42 -04:00
bfd1924897 fix: update forms pages to use proper page layout
- Remove EntityView wrapper, use standard space-y-6 pattern
- Full page width instead of container max-w
- Consistent with experiments/participants pages
2026-03-22 18:09:01 -04:00
0827a791c6 fix: standardize MinIO bucket name to hristudio-data
- Update docker-compose to create hristudio-data bucket instead of hristudio
- Fix files.ts, storage.ts, trials.ts, lib/storage/minio.ts to use consistent bucket name
- All now default to hristudio-data matching compose bucket creation
2026-03-22 17:57:34 -04:00
ecf0ab9103 feat: add form templates
- Add isTemplate and templateName fields to forms
- Add listTemplates and createFromTemplate API endpoints
- Add template selection to new form page UI
- Add sample templates and forms to seed script:
  - Informed Consent template
  - Post-Session Survey template
  - Demographics questionnaire template
2026-03-22 17:53:16 -04:00
49e0df016a feat: complete forms system overhaul
- Add new forms table with type (consent/survey/questionnaire)
- Add formResponses table for submissions
- Add forms API router with full CRUD:
  - list, get, create, update, delete
  - setActive, createVersion
  - getResponses, submitResponse
- Add forms list page with card-based UI
- Add form builder with field types (text, textarea, multiple_choice, checkbox, rating, yes_no, date, signature)
- Add form viewer with edit mode and preview
- Add responses viewing with participant info
2026-03-22 17:43:12 -04:00
8529d0ef89 fix: add role-based permissions to forms page
- Hide Generate Default Template and Save Changes buttons for wizard/observer
- Backend already enforces owner/researcher for mutations
- UI now provides cleaner experience for read-only roles
2026-03-22 17:26:52 -04:00
67ad904f62 feat: add role-based permissions and profile page improvements
- Add getMyMemberships API endpoint for user role lookup
- Add getMemberRole helper for profile page display
- Add role-based UI controls to study page (owner/researcher only)
- Add canManage checks to experiments, participants, trials pages
- Hide management actions for wizard/observer roles

Backend already enforces permissions; UI now provides cleaner UX
2026-03-22 17:25:04 -04:00
519e6a2606 ui: complete profile page redesign
- Modern card-based layout with large avatar
- Inline editing for name/email with save/cancel
- Password change dialog with validation
- Security section with danger zone
- Studies access quick link
- Consistent teal theme colors
- Uses PageHeader pattern
- Better loading states
2026-03-22 17:08:50 -04:00
b353ef7c9f ui: dashboard redesign, member management, dark mode fixes
- Simplified dashboard using PageHeader/page-layout
- Fixed dark mode live session banner
- Added AddMemberDialog component for study team management
- Study page now has working member add/remove
- Fixed toast imports to use sonner
2026-03-22 17:05:28 -04:00
cbd31e9aa4 ui: complete dashboard redesign
- New modern dashboard layout with gradient background
- Quick action cards with teal glow effects
- Live trials banner with pulsing indicator
- Stats grid with colored icons
- Study list with bot icons
- Quick links sidebar
- Recent trials section with status badges
- Proper null safety and type checking
2026-03-22 16:55:41 -04:00
37feea8df3 ui: fix dead links in dashboard, update theme to teal/cyan
- Fix broken /trials links to use study-scoped routes
- Fix /wizard/ link to proper wizard URL with studyId
- Add studyId to getLiveTrials API response
- Update theme colors to teal/cyan sci-fi style
- Add custom CSS utilities for glow effects and chamfered corners
- Consolidate docs and archive outdated files
2026-03-22 16:49:20 -04:00
cf3597881b docs: consolidate and archive documentation
- Move 30+ outdated docs to docs/_archive/
- Move obsolete root files to _archive/
- Update README.md (Better Auth, current features)
- Update docs/README.md (new architecture diagram)
- Update docs/quick-reference.md (consolidated)
- Update docs/project-status.md (March 2026 state)
- Update docs/nao6-quick-reference.md (14 actions, Docker services)
- Update docs/implementation-guide.md (Better Auth, git submodule)
- Update docs/proposal.tex (timeline updates)
- Archive errors.txt, plugin_dump.json, test HTML files
2026-03-22 16:38:28 -04:00
Sean O'Connor
add3380307 fix: upgrade to Next.js 16.2.1 and resolve bundling issues
- Fixed client bundle contamination by moving child_process-dependent code
- Created standalone /api/robots/command route for SSH robot commands
- Created plugins router to replace robots.plugins for plugin management
- Added getStudyPlugins procedure to studies router
- Fixed trial.studyId references to trial.experiment.studyId
- Updated WizardInterface to use REST API for robot commands
2026-03-22 01:08:13 -04:00
Sean O'Connor
79bb298756 revert: stay on Next.js 16.1.6 due to bundling issues in 16.2.1 2026-03-22 00:50:29 -04:00
Sean O'Connor
a5762ec935 feat: implement WebSocket for real-time trial updates
- Create standalone WebSocket server (ws-server.ts) on port 3001 using Bun
- Add ws_connections table to track active connections in database
- Create global WebSocket manager that persists across component unmounts
- Fix useWebSocket hook to prevent infinite re-renders and use refs
- Fix TrialForm Select components with proper default values
- Add trialId to WebSocket URL for server-side tracking
- Update package.json with dev:ws script for separate WS server
2026-03-22 00:48:43 -04:00
89 changed files with 6835 additions and 2989 deletions

View File

@@ -64,16 +64,15 @@ bun dev
## Technology Stack ## Technology Stack
- **Framework**: Next.js 15 with App Router and React 19 RC - **Framework**: Next.js 15 (16.x compatible) with App Router and React 19
- **Language**: TypeScript (strict mode) - 100% type safety throughout - **Language**: TypeScript (strict mode) - 100% type safety throughout
- **Database**: PostgreSQL with Drizzle ORM for type-safe operations - **Database**: PostgreSQL with Drizzle ORM for type-safe operations
- **Authentication**: NextAuth.js v5 with database sessions and JWT - **Authentication**: Better Auth with database sessions
- **API**: tRPC for end-to-end type-safe client-server communication - **API**: tRPC for end-to-end type-safe client-server communication
- **UI**: Tailwind CSS + shadcn/ui (built on Radix UI primitives) - **UI**: Tailwind CSS + shadcn/ui (built on Radix UI primitives)
- **Storage**: Cloudflare R2 (S3-compatible) for media files - **Storage**: Cloudflare R2 (S3-compatible) for media files
- **Deployment**: Vercel serverless platform with Edge Runtime - **Real-time**: WebSocket with polling fallback for trial execution
- **Package Manager**: Bun exclusively - **Package Manager**: Bun exclusively
- **Real-time**: WebSocket with Edge Runtime compatibility
## Architecture ## Architecture
@@ -203,14 +202,11 @@ src/
Comprehensive documentation available in the `docs/` folder: Comprehensive documentation available in the `docs/` folder:
- **[Quick Reference](docs/quick-reference.md)**: 5-minute setup guide and essential commands - **[Quick Reference](docs/quick-reference.md)**: Essential commands and setup
- **[Project Overview](docs/project-overview.md)**: Complete feature overview and architecture - **[Implementation Guide](docs/implementation-guide.md)**: Technical implementation details
- **[Implementation Details](docs/implementation-details.md)**: Architecture decisions and patterns - **[Project Status](docs/project-status.md)**: Current development state
- **[Database Schema](docs/database-schema.md)**: Complete PostgreSQL schema documentation - **[NAO6 Integration](docs/nao6-quick-reference.md)**: Robot setup and commands
- **[API Routes](docs/api-routes.md)**: Comprehensive tRPC API reference - **[Archive](docs/_archive/)**: Historical documentation (outdated)
- **[Core Blocks System](docs/core-blocks-system.md)**: Repository-based block architecture
- **[Plugin System](docs/plugin-system-implementation-guide.md)**: Robot integration guide
- **[Project Status](docs/project-status.md)**: Current completion status (98% complete)
## Research Paper ## Research Paper
@@ -234,19 +230,39 @@ Full paper available at: [docs/paper.md](docs/paper.md)
- **4 User Roles**: Complete role-based access control - **4 User Roles**: Complete role-based access control
- **Plugin System**: Extensible robot integration architecture - **Plugin System**: Extensible robot integration architecture
- **Trial System**: Unified design with real-time execution capabilities - **Trial System**: Unified design with real-time execution capabilities
- **WebSocket Ready**: Real-time trial updates with polling fallback
- **Docker Integration**: NAO6 deployment via docker-compose
- **Conditional Branching**: Experiment flow with wizard choices and convergence paths
## NAO6 Robot Integration ## NAO6 Robot Integration
Complete NAO6 robot integration is available in the separate **[nao6-hristudio-integration](../nao6-hristudio-integration/)** repository. Complete NAO6 robot integration is available in the separate **[nao6-hristudio-integration](../nao6-hristudio-integration/)** repository.
### Features ### Features
- Complete ROS2 driver integration for NAO V6.0 - Complete ROS2 Humble driver integration for NAO V6.0
- Docker-based deployment with three services: nao_driver, ros_bridge, ros_api
- WebSocket communication via rosbridge - WebSocket communication via rosbridge
- 9 robot actions: speech, movement, gestures, sensors, LEDs - 14 robot actions: speech, movement, gestures, sensors, LEDs, animations
- Real-time control from wizard interface - Real-time control from wizard interface
- Production-ready with NAOqi 2.8.7.4 - Production-ready with NAOqi 2.8.7.4, ROS2 Humble
### Quick Start ### Docker Deployment
```bash
# Start NAO integration
cd nao6-hristudio-integration
docker compose up -d
# Start HRIStudio
cd ~/Documents/Projects/hristudio
bun dev
# Access
# - HRIStudio: http://localhost:3000
# - Test page: http://localhost:3000/nao-test
# - rosbridge: ws://localhost:9090
```
### Quick Start Commands
```bash ```bash
# Start NAO integration # Start NAO integration
cd ~/naoqi_ros2_ws cd ~/naoqi_ros2_ws

116
bun.lock
View File

@@ -61,14 +61,14 @@
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lucide-react": "^0.536.0", "lucide-react": "^0.536.0",
"minio": "^8.0.6", "minio": "^8.0.6",
"next": "^16.1.6", "next": "16.2.1",
"next-auth": "^5.0.0-beta.30", "next-auth": "^5.0.0-beta.30",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"postgres": "^3.4.8", "postgres": "^3.4.8",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.4", "react": "19.2.4",
"react-day-picker": "^9.13.2", "react-day-picker": "^9.13.2",
"react-dom": "^19.2.4", "react-dom": "19.2.4",
"react-hook-form": "^7.71.1", "react-hook-form": "^7.71.1",
"react-resizable-panels": "^3.0.6", "react-resizable-panels": "^3.0.6",
"react-signature-canvas": "^1.1.0-alpha.2", "react-signature-canvas": "^1.1.0-alpha.2",
@@ -90,12 +90,12 @@
"@types/bun": "^1.3.9", "@types/bun": "^1.3.9",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/node": "^20.19.33", "@types/node": "^20.19.33",
"@types/react": "^19.2.14", "@types/react": "19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "19.2.3",
"@types/uuid": "^11.0.0", "@types/uuid": "^11.0.0",
"drizzle-kit": "^0.30.6", "drizzle-kit": "^0.30.6",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-next": "^15.5.12", "eslint-config-next": "16.2.1",
"eslint-plugin-drizzle": "^0.2.3", "eslint-plugin-drizzle": "^0.2.3",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.8.1", "prettier": "^3.8.1",
@@ -115,6 +115,10 @@
"sharp", "sharp",
"unrs-resolver", "unrs-resolver",
], ],
"overrides": {
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
},
"packages": { "packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
@@ -208,8 +212,40 @@
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.3", "", {}, "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw=="], "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.3", "", {}, "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
"@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
"@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@better-auth/core": ["@better-auth/core@1.5.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "better-call": "1.3.2", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-1oR/2jAp821Dcf67kQYHUoyNcdc1TcShfw4QMK0YTVntuRES5mUOyvEJql5T6eIuLfaqaN4LOF78l0FtF66HXA=="], "@better-auth/core": ["@better-auth/core@1.5.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "better-call": "1.3.2", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-1oR/2jAp821Dcf67kQYHUoyNcdc1TcShfw4QMK0YTVntuRES5mUOyvEJql5T6eIuLfaqaN4LOF78l0FtF66HXA=="],
"@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0", "drizzle-orm": ">=0.41.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-HAi9xAP40oDt48QZeYBFTcmg3vt1Jik90GwoRIfangd7VGbxesIIDBJSnvwMbZ52GBIc6+V4FRw9lasNiNrPfw=="], "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0", "drizzle-orm": ">=0.41.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-HAi9xAP40oDt48QZeYBFTcmg3vt1Jik90GwoRIfangd7VGbxesIIDBJSnvwMbZ52GBIc6+V4FRw9lasNiNrPfw=="],
@@ -402,25 +438,25 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@next/env": ["@next/env@16.1.6", "", {}, "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ=="], "@next/env": ["@next/env@16.2.1", "", {}, "sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg=="],
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.5.12", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-+ZRSDFTv4aC96aMb5E41rMjysx8ApkryevnvEYZvPZO52KvkqP5rNExLUXJFr9P4s0f3oqNQR6vopCZsPWKDcQ=="], "@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.2.1", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-r0epZGo24eT4g08jJlg2OEryBphXqO8aL18oajoTKLzHJ6jVr6P6FI58DLMug04MwD3j8Fj0YK0slyzneKVyzA=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw=="], "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ=="], "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw=="], "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ=="], "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ=="], "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg=="], "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.1.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw=="], "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A=="], "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg=="],
"@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="], "@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="],
@@ -612,8 +648,6 @@
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
"@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.12.0", "", {}, "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw=="],
"@shadcn/ui": ["@shadcn/ui@0.0.4", "", { "dependencies": { "chalk": "5.2.0", "commander": "^10.0.0", "execa": "^7.0.0", "fs-extra": "^11.1.0", "node-fetch": "^3.3.0", "ora": "^6.1.2", "prompts": "^2.4.2", "zod": "^3.20.2" }, "bin": { "ui": "dist/index.js" } }, "sha512-0dtu/5ApsOZ24qgaZwtif8jVwqol7a4m1x5AxPuM1k5wxhqU7t/qEfBGtaSki1R8VlbTQfCj5PAlO45NKCa7Gg=="], "@shadcn/ui": ["@shadcn/ui@0.0.4", "", { "dependencies": { "chalk": "5.2.0", "commander": "^10.0.0", "execa": "^7.0.0", "fs-extra": "^11.1.0", "node-fetch": "^3.3.0", "ora": "^6.1.2", "prompts": "^2.4.2", "zod": "^3.20.2" }, "bin": { "ui": "dist/index.js" } }, "sha512-0dtu/5ApsOZ24qgaZwtif8jVwqol7a4m1x5AxPuM1k5wxhqU7t/qEfBGtaSki1R8VlbTQfCj5PAlO45NKCa7Gg=="],
"@smithy/abort-controller": ["@smithy/abort-controller@4.2.8", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw=="], "@smithy/abort-controller": ["@smithy/abort-controller@4.2.8", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw=="],
@@ -1034,6 +1068,8 @@
"browser-or-node": ["browser-or-node@2.1.1", "", {}, "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg=="], "browser-or-node": ["browser-or-node@2.1.1", "", {}, "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"bson": ["bson@7.2.0", "", {}, "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ=="], "bson": ["bson@7.2.0", "", {}, "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ=="],
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
@@ -1082,6 +1118,8 @@
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="], "copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="],
"core-js": ["core-js@3.48.0", "", {}, "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ=="], "core-js": ["core-js@3.48.0", "", {}, "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ=="],
@@ -1140,6 +1178,8 @@
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"electron-to-chromium": ["electron-to-chromium@1.5.321", "", {}, "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ=="],
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="], "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
@@ -1170,11 +1210,13 @@
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@9.39.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw=="], "eslint": ["eslint@9.39.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw=="],
"eslint-config-next": ["eslint-config-next@15.5.12", "", { "dependencies": { "@next/eslint-plugin-next": "15.5.12", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^5.0.0" }, "peerDependencies": { "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", "typescript": ">=3.3.1" }, "optionalPeers": ["typescript"] }, "sha512-ktW3XLfd+ztEltY5scJNjxjHwtKWk6vU2iwzZqSN09UsbBmMeE/cVlJ1yESg6Yx5LW7p/Z8WzUAgYXGLEmGIpg=="], "eslint-config-next": ["eslint-config-next@16.2.1", "", { "dependencies": { "@next/eslint-plugin-next": "16.2.1", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^7.0.0", "globals": "16.4.0", "typescript-eslint": "^8.46.0" }, "peerDependencies": { "eslint": ">=9.0.0", "typescript": ">=3.3.1" }, "optionalPeers": ["typescript"] }, "sha512-qhabwjQZ1Mk53XzXvmogf8KQ0tG0CQXF0CZ56+2/lVhmObgmaqj7x5A1DSrWdZd3kwI7GTPGUjFne+krRxYmFg=="],
"eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="], "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="],
@@ -1190,7 +1232,7 @@
"eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="], "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="],
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
@@ -1264,6 +1306,8 @@
"gel": ["gel@2.1.1", "", { "dependencies": { "@petamoriken/float16": "^3.8.7", "debug": "^4.3.4", "env-paths": "^3.0.0", "semver": "^7.6.2", "shell-quote": "^1.8.1", "which": "^4.0.0" }, "bin": { "gel": "dist/cli.mjs" } }, "sha512-Newg9X7mRYskoBjSw70l1YnJ/ZGbq64VPyR821H5WVkTGpHG2O0mQILxCeUhxdYERLFY9B4tUyKLyf3uMTjtKw=="], "gel": ["gel@2.1.1", "", { "dependencies": { "@petamoriken/float16": "^3.8.7", "debug": "^4.3.4", "env-paths": "^3.0.0", "semver": "^7.6.2", "shell-quote": "^1.8.1", "which": "^4.0.0" }, "bin": { "gel": "dist/cli.mjs" } }, "sha512-Newg9X7mRYskoBjSw70l1YnJ/ZGbq64VPyR821H5WVkTGpHG2O0mQILxCeUhxdYERLFY9B4tUyKLyf3uMTjtKw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
@@ -1300,6 +1344,10 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="], "html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="],
"html2pdf.js": ["html2pdf.js@0.14.0", "", { "dependencies": { "dompurify": "^3.3.1", "html2canvas": "^1.0.0", "jspdf": "^4.0.0" } }, "sha512-yvNJgE/8yru2UeGflkPdjW8YEY+nDH5X7/2WG4uiuSCwYiCp8PZ8EKNiTAa6HxJ1NjC51fZSIEq6xld5CADKBQ=="], "html2pdf.js": ["html2pdf.js@0.14.0", "", { "dependencies": { "dompurify": "^3.3.1", "html2canvas": "^1.0.0", "jspdf": "^4.0.0" } }, "sha512-yvNJgE/8yru2UeGflkPdjW8YEY+nDH5X7/2WG4uiuSCwYiCp8PZ8EKNiTAa6HxJ1NjC51fZSIEq6xld5CADKBQ=="],
@@ -1400,6 +1448,8 @@
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
@@ -1464,6 +1514,8 @@
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@0.536.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2PgvNa9v+qz4Jt/ni8vPLt4jwoFybXHuubQT8fv4iCW5TjDxkbZjNZZHa485ad73NSEn/jdsEtU57eE1g+ma8A=="], "lucide-react": ["lucide-react@0.536.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2PgvNa9v+qz4Jt/ni8vPLt4jwoFybXHuubQT8fv4iCW5TjDxkbZjNZZHa485ad73NSEn/jdsEtU57eE1g+ma8A=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
@@ -1510,7 +1562,7 @@
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"next": ["next@16.1.6", "", { "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.6", "@next/swc-darwin-x64": "16.1.6", "@next/swc-linux-arm64-gnu": "16.1.6", "@next/swc-linux-arm64-musl": "16.1.6", "@next/swc-linux-x64-gnu": "16.1.6", "@next/swc-linux-x64-musl": "16.1.6", "@next/swc-win32-arm64-msvc": "16.1.6", "@next/swc-win32-x64-msvc": "16.1.6", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw=="], "next": ["next@16.2.1", "", { "dependencies": { "@next/env": "16.2.1", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.1", "@next/swc-darwin-x64": "16.2.1", "@next/swc-linux-arm64-gnu": "16.2.1", "@next/swc-linux-arm64-musl": "16.2.1", "@next/swc-linux-x64-gnu": "16.2.1", "@next/swc-linux-x64-musl": "16.2.1", "@next/swc-win32-arm64-msvc": "16.2.1", "@next/swc-win32-x64-msvc": "16.2.1", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q=="],
"next-auth": ["next-auth@5.0.0-beta.30", "", { "dependencies": { "@auth/core": "0.41.0" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", "nodemailer": "^7.0.7", "react": "^18.2.0 || ^19.0.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg=="], "next-auth": ["next-auth@5.0.0-beta.30", "", { "dependencies": { "@auth/core": "0.41.0" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", "nodemailer": "^7.0.7", "react": "^18.2.0 || ^19.0.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg=="],
@@ -1520,6 +1572,8 @@
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
"npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="], "npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="],
"oauth4webapi": ["oauth4webapi@3.6.1", "", {}, "sha512-b39+drVyA4aNUptFOhkkmGWnG/BE7dT29SW/8PVYElqp7j/DBqzm5SS1G+MUD07XlTcBOAG+6Cb/35Cx2kHIuQ=="], "oauth4webapi": ["oauth4webapi@3.6.1", "", {}, "sha512-b39+drVyA4aNUptFOhkkmGWnG/BE7dT29SW/8PVYElqp7j/DBqzm5SS1G+MUD07XlTcBOAG+6Cb/35Cx2kHIuQ=="],
@@ -1866,6 +1920,8 @@
"unrs-resolver": ["unrs-resolver@1.11.1", "", { "dependencies": { "napi-postinstall": "^0.3.0" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.11.1", "@unrs/resolver-binding-android-arm64": "1.11.1", "@unrs/resolver-binding-darwin-arm64": "1.11.1", "@unrs/resolver-binding-darwin-x64": "1.11.1", "@unrs/resolver-binding-freebsd-x64": "1.11.1", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-musl": "1.11.1", "@unrs/resolver-binding-wasm32-wasi": "1.11.1", "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg=="], "unrs-resolver": ["unrs-resolver@1.11.1", "", { "dependencies": { "napi-postinstall": "^0.3.0" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.11.1", "@unrs/resolver-binding-android-arm64": "1.11.1", "@unrs/resolver-binding-darwin-arm64": "1.11.1", "@unrs/resolver-binding-darwin-x64": "1.11.1", "@unrs/resolver-binding-freebsd-x64": "1.11.1", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-musl": "1.11.1", "@unrs/resolver-binding-wasm32-wasi": "1.11.1", "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "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-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "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-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
@@ -1918,10 +1974,14 @@
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
"zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], "zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
"@auth/core/jose": ["jose@6.0.12", "", {}, "sha512-T8xypXs8CpmiIi78k0E+Lk7T2zlK4zDyg+o1CZ4AkOHgDg98ogdP2BeZ61lTFKFyoEwJ9RgAgN+SdM3iPgNonQ=="], "@auth/core/jose": ["jose@6.0.12", "", {}, "sha512-T8xypXs8CpmiIi78k0E+Lk7T2zlK4zDyg+o1CZ4AkOHgDg98ogdP2BeZ61lTFKFyoEwJ9RgAgN+SdM3iPgNonQ=="],
@@ -1946,6 +2006,16 @@
"@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.3.4", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA=="], "@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.3.4", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA=="],
"@babel/core/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"@babel/core/json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
@@ -2028,10 +2098,14 @@
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"browserslist/caniuse-lite": ["caniuse-lite@1.0.30001780", "", {}, "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ=="],
"cmdk/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "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-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.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-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="], "cmdk/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "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-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.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-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="],
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"eslint-config-next/globals": ["globals@16.4.0", "", {}, "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw=="],
"eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
"eslint-import-resolver-typescript/tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="], "eslint-import-resolver-typescript/tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],

View File

@@ -36,8 +36,8 @@ services:
entrypoint: > entrypoint: >
/bin/sh -c " /bin/sh -c "
/usr/bin/mc alias set myminio http://minio:9000 minioadmin minioadmin; /usr/bin/mc alias set myminio http://minio:9000 minioadmin minioadmin;
/usr/bin/mc mb myminio/hristudio; /usr/bin/mc mb myminio/hristudio-data;
/usr/bin/mc anonymous set public myminio/hristudio; /usr/bin/mc anonymous set public myminio/hristudio-data;
exit 0; exit 0;
" "

View File

@@ -1,307 +1,174 @@
# HRIStudio Documentation # HRIStudio Documentation
Welcome to the comprehensive documentation for HRIStudio - a web-based platform for standardizing and improving Wizard of Oz (WoZ) studies in Human-Robot Interaction research. HRIStudio is a web-based Wizard-of-Oz platform for Human-Robot Interaction research.
## 📚 Documentation Overview ## Quick Links
This documentation suite provides everything needed to understand, build, deploy, and maintain HRIStudio. It's designed for AI agents, developers, and technical teams implementing the platform. | Document | Description |
|----------|-------------|
| **[Quick Reference](quick-reference.md)** | Essential commands, setup, troubleshooting |
| **[Project Status](project-status.md)** | Current development state (March 2026) |
| **[Implementation Guide](implementation-guide.md)** | Full technical implementation |
| **[NAO6 Integration](nao6-quick-reference.md)** | Robot setup and commands |
### **🚀 Quick Start** ## Getting Started
**New to HRIStudio?** Start here:
1. **[Quick Reference](./quick-reference.md)** - 5-minute setup and key concepts
2. **[Project Overview](./project-overview.md)** - Complete feature overview and goals
3. **[Implementation Guide](./implementation-guide.md)** - Step-by-step technical implementation
### **📋 Core Documentation** (8 Files)
#### **Project Specifications**
1. **[Project Overview](./project-overview.md)**
- Executive summary and project goals
- Core features and system architecture
- User roles and permissions
- Technology stack overview
- Key concepts and success metrics
2. **[Feature Requirements](./feature-requirements.md)**
- Detailed user stories and acceptance criteria
- Functional requirements by module
- Non-functional requirements
- UI/UX specifications
- Integration requirements
#### **Technical Implementation**
3. **[Database Schema](./database-schema.md)**
- Complete PostgreSQL schema with Drizzle ORM
- Table definitions and relationships
- Indexes and performance optimizations
- Views and stored procedures
- Migration guidelines
4. **[API Routes](./api-routes.md)**
- Comprehensive tRPC route documentation
- Request/response schemas
- Authentication requirements
- WebSocket events
- Rate limiting and error handling
5. **[Core Blocks System](./core-blocks-system.md)**
- Repository-based plugin architecture
- 26 essential blocks across 4 categories
- Event triggers, wizard actions, control flow, observation
- Block loading and validation system
- Integration with experiment designer
6. **[Plugin System Implementation](./plugin-system-implementation-guide.md)**
- Robot plugin architecture and development
- Repository management and trust levels
- Plugin installation and configuration
- Action definitions and parameter schemas
- ROS2 integration patterns
7. **[Implementation Guide](./implementation-guide.md)**
- Step-by-step technical implementation
- Code examples and patterns
- Frontend and backend architecture
- Real-time features implementation
- Testing strategies
8. **[Implementation Details](./implementation-details.md)**
- Architecture decisions and rationale
- Unified editor experiences (significant code reduction)
- DataTable migration achievements
- Development database and seed system
- Performance optimization strategies
#### **Operations & Deployment**
9. **[Deployment & Operations](./deployment-operations.md)**
- Infrastructure requirements
- Vercel deployment strategies
- Monitoring and observability
- Backup and recovery procedures
- Security operations
10. **[ROS2 Integration](./ros2-integration.md)**
- rosbridge WebSocket architecture
- Client-side ROS connection management
- Message type definitions
- Robot plugin implementation
- Security considerations for robot communication
### **📊 Project Status**
11. **[Project Status](./project-status.md)**
- Overall completion status (complete)
- Implementation progress by feature
- Sprint planning and development velocity
- Production readiness assessment
- Core blocks system completion
12. **[Quick Reference](./quick-reference.md)**
- 5-minute setup guide
- Essential commands and patterns
- API reference and common workflows
- Core blocks system overview
- Key concepts and architecture overview
13. **[Work in Progress](./work_in_progress.md)**
- Recent changes and improvements
- Core blocks system implementation
- Plugin architecture enhancements
- Panel-based wizard interface (matching experiment designer)
- Technical debt resolution
- UI/UX enhancements
### **🤖 Robot Integration Guides**
14. **[NAO6 Complete Integration Guide](./nao6-integration-complete-guide.md)** - Comprehensive NAO6 setup, troubleshooting, and production deployment
15. **[NAO6 Quick Reference](./nao6-quick-reference.md)** - Essential commands and troubleshooting for NAO6 integration
16. **[NAO6 ROS2 Setup](./nao6-ros2-setup.md)** - Basic NAO6 ROS2 driver installation guide
### **📖 Academic References**
17. **[Research Paper](./root.tex)** - Academic LaTeX document
18. **[Bibliography](./refs.bib)** - Research references
---
## 🎯 **Documentation Structure Benefits**
### **Streamlined Organization**
- **Consolidated documentation** - Easier navigation and maintenance
- **Logical progression** - From overview → implementation → deployment
- **Consolidated achievements** - All progress tracking in unified documents
- **Clear entry points** - Quick reference for immediate needs
### **Comprehensive Coverage**
- **Complete technical specs** - Database, API, and implementation details
- **Step-by-step guidance** - From project setup to production deployment
- **Real-world examples** - Code patterns and configuration samples
- **Performance insights** - Optimization strategies and benchmark results
---
## 🚀 **Getting Started Paths**
### **For Developers**
1. **[Quick Reference](./quick-reference.md)** - Immediate setup and key commands
2. **[Implementation Guide](./implementation-guide.md)** - Technical implementation steps
3. **[Database Schema](./database-schema.md)** - Data model understanding
4. **[API Routes](./api-routes.md)** - Backend integration
### **For Project Managers**
1. **[Project Overview](./project-overview.md)** - Complete feature understanding
2. **[Project Status](./project-status.md)** - Current progress and roadmap
3. **[Feature Requirements](./feature-requirements.md)** - Detailed specifications
4. **[Deployment & Operations](./deployment-operations.md)** - Infrastructure planning
### **For Researchers**
1. **[Project Overview](./project-overview.md)** - Research platform capabilities
2. **[Feature Requirements](./feature-requirements.md)** - User workflows and features
3. **[NAO6 Quick Reference](./nao6-quick-reference.md)** - Essential NAO6 robot control commands
4. **[ROS2 Integration](./ros2-integration.md)** - Robot platform integration
5. **[Research Paper](./root.tex)** - Academic context and methodology
### **For Robot Integration**
1. **[NAO6 Complete Integration Guide](./nao6-integration-complete-guide.md)** - Full NAO6 setup and troubleshooting
2. **[NAO6 Quick Reference](./nao6-quick-reference.md)** - Essential commands and quick fixes
3. **[ROS2 Integration](./ros2-integration.md)** - General robot integration patterns
---
## 🛠️ **Prerequisites**
### **Development Environment**
- **[Bun](https://bun.sh)** - Package manager and runtime
- **[PostgreSQL](https://postgresql.org)** 15+ - Primary database
- **[Docker](https://docker.com)** - Containerized development (optional)
### **Production Deployment**
- **[Vercel](https://vercel.com)** account - Serverless deployment platform
- **PostgreSQL** database - Vercel Postgres or external provider
- **[Cloudflare R2](https://cloudflare.com/products/r2/)** - S3-compatible storage
---
## ⚡ **Quick Setup (5 Minutes)**
### 1. Clone & Install
```bash ```bash
# Clone and install git clone https://github.com/soconnor0919/hristudio.git
git clone <repo-url> hristudio
cd hristudio cd hristudio
git submodule update --init --recursive
bun install bun install
# Start database
bun run docker:up
# Setup database and seed data
bun db:push
bun db:seed
# Start development
bun dev
``` ```
**Default Login**: `sean@soconnor.dev` / `password123` ### 2. Start Database
```bash
bun run docker:up
bun db:push
bun db:seed
```
--- ### 3. Start Application
```bash
bun dev
# Visit http://localhost:3000
# Login: sean@soconnor.dev / password123
```
## 📋 **Key Features Overview** ### 4. Start NAO6 Robot (optional)
```bash
cd ~/Documents/Projects/nao6-hristudio-integration
docker compose up -d
```
## Current Architecture
```
┌──────────────────────────────────────────────────────────┐
│ HRIStudio Platform │
├──────────────────────────────────────────────────────────┤
│ UI Layer (Next.js + React + shadcn/ui) │
│ ├── Experiment Designer (drag-and-drop) │
│ ├── Wizard Interface (real-time trial execution) │
│ └── Observer/Participant Views │
├──────────────────────────────────────────────────────────┤
│ Logic Layer (tRPC + Better Auth) │
│ ├── 12 tRPC routers (studies, experiments, trials...) │
│ ├── Role-based authentication (4 roles) │
│ └── WebSocket for real-time updates │
├──────────────────────────────────────────────────────────┤
│ Data Layer (PostgreSQL + Drizzle ORM) │
│ ├── 31 tables with complete relationships │
│ ├── Plugin system with identifier-based lookup │
│ └── Comprehensive event logging │
├──────────────────────────────────────────────────────────┤
│ Robot Integration (ROS2 via WebSocket) │
│ Docker: nao_driver, ros_bridge, ros_api │
│ Plugin identifier: "nao6-ros2" │
└──────────────────────────────────────────────────────────┘
```
## Key Features
### **Research Workflow Support**
- **Hierarchical Structure**: Study → Experiment → Trial → Step → Action - **Hierarchical Structure**: Study → Experiment → Trial → Step → Action
- **Visual Experiment Designer**: Repository-based plugin architecture with 26 core blocks - **Visual Designer**: 26+ core blocks (events, wizard actions, control flow, observation)
- **Core Block Categories**: Events, wizard actions, control flow, observation blocks - **Conditional Branching**: Wizard choices with convergence paths
- **Real-time Trial Execution**: Live wizard control with data capture - **WebSocket Real-time**: Trial updates with auto-reconnect
- **Multi-role Collaboration**: Administrator, Researcher, Wizard, Observer - **Plugin System**: Robot-agnostic via identifier lookup
- **Comprehensive Data Management**: Synchronized multi-modal capture - **Docker NAO6**: Three-service ROS2 integration
- **Forms System**: Consent forms, surveys, questionnaires with templates
- **Role-based Access**: Owner, Researcher, Wizard, Observer permissions
### **Technical Excellence** ## System Components
- **Full Type Safety**: End-to-end TypeScript with strict mode
- **Production Ready**: Vercel deployment with Edge Runtime
- **Performance Optimized**: Database indexes and query optimization
- **Security First**: Role-based access control throughout
- **Modern Stack**: Next.js 15, tRPC, Drizzle ORM, shadcn/ui
- **Consistent Architecture**: Panel-based interfaces across visual programming tools
### **Development Experience** ### Backend (src/server/)
- **Unified Components**: Significant reduction in code duplication - `api/routers/` - 13 tRPC routers (studies, experiments, trials, participants, forms, etc.)
- **Panel Architecture**: 90% code sharing between experiment designer and wizard interface - `db/schema.ts` - Drizzle schema (33 tables)
- **Consolidated Wizard**: 3-panel design with trial controls, horizontal timeline, and unified robot controls - `services/trial-execution.ts` - Trial execution engine
- **Enterprise DataTables**: Advanced filtering, export, pagination - `services/websocket-manager.ts` - Real-time connections
- **Comprehensive Testing**: Realistic seed data with complete scenarios
- **Developer Friendly**: Clear patterns and extensive documentation
### **Robot Integration** ### Frontend (src/)
- **NAO6 Full Support**: Complete ROS2 integration with movement, speech, and sensor control - `app/` - Next.js App Router pages
- **Real-time Control**: WebSocket-based robot control through web interface - `components/trials/wizard/` - Wizard interface
- **Safety Features**: Emergency stops, movement limits, and comprehensive monitoring - `components/trials/forms/` - Form builder and viewer
- **Production Ready**: Tested with NAO V6.0 / NAOqi 2.8.7.4 / ROS2 Humble - `hooks/useWebSocket.ts` - Real-time trial updates
- **Troubleshooting Guides**: Complete documentation for setup and problem resolution - `lib/ros/wizard-ros-service.ts` - Robot control
## Plugin Identifier System
```typescript
// Plugins table has:
// - identifier: "nao6-ros2" (unique, machine-readable)
// - name: "NAO6 Robot (ROS2 Integration)" (display)
// Lookup order in trial execution:
1. Look up by identifier (e.g., "nao6-ros2")
2. Fall back to name (e.g., "NAO6 Robot")
3. Return null if not found
```
## Branching Flow
```
Step 3 (Comprehension Check)
└── wizard_wait_for_response
├── "Correct" → nextStepId = step4a.id
└── "Incorrect" → nextStepId = step4b.id
Step 4a/4b (Branch A/B)
└── conditions.nextStepId: step5.id → converge
Step 5 (Story Continues)
└── Linear progression to conclusion
```
## Development Workflow
```bash
# Make changes
# ...
# Validate
bun typecheck
bun lint
# Push schema (if changed)
bun db:push
# Reseed (if data changed)
bun db:seed
```
## Common Issues
| Issue | Solution |
|-------|----------|
| Build errors | `rm -rf .next && bun build` |
| DB issues | `bun db:push --force && bun db:seed` |
| Type errors | Check `bun typecheck` output |
| WebSocket fails | Verify port 3001 available |
## External Resources
- [Thesis (honors-thesis)](https://github.com/soconnor0919/honors-thesis)
- [NAO6 Integration](https://github.com/soconnor0919/nao6-hristudio-integration)
- [Robot Plugins](https://github.com/soconnor0919/robot-plugins)
## File Index
### Primary Documentation
- `README.md` - Project overview
- `docs/README.md` - This file
- `docs/quick-reference.md` - Commands & setup
- `docs/nao6-quick-reference.md` - NAO6 commands
### Technical Documentation
- `docs/implementation-guide.md` - Full technical implementation
- `docs/project-status.md` - Development status
### Archive (Historical)
- `docs/_archive/` - Old documentation (outdated but preserved)
--- ---
## 🎊 **Project Status: Production Ready** **Last Updated**: March 22, 2026
**Current Completion**: Complete ✅
**Status**: Ready for immediate deployment
**Active Work**: Experiment designer enhancement
### **Completed Achievements**
-**Complete Backend** - Full API coverage with 11 tRPC routers
-**Professional UI** - Unified experiences with shadcn/ui components
-**Type Safety** - Zero TypeScript errors in production code
-**Database Schema** - 31 tables with comprehensive relationships
-**Authentication** - Role-based access control system
-**Visual Designer** - Repository-based plugin architecture
-**Consolidated Wizard Interface** - 3-panel design with horizontal timeline and unified robot controls
-**Core Blocks System** - 26 blocks across events, wizard, control, observation
-**Plugin Architecture** - Unified system for core blocks and robot actions
-**Development Environment** - Realistic test data and scenarios
-**NAO6 Robot Integration** - Full ROS2 integration with comprehensive control and monitoring
-**Intelligent Control Flow** - Loops with implicit approval, branching, parallel execution
---
## 📞 **Support and Resources**
### **Documentation Quality**
This documentation is comprehensive and self-contained. For implementation:
1. **Start with Quick Reference** for immediate setup
2. **Follow Implementation Guide** for step-by-step development
3. **Reference Technical Specs** for detailed implementation
4. **Check Project Status** for current progress and roadmap
### **Key Integration Points**
- **Authentication**: NextAuth.js v5 with database sessions
- **File Storage**: Cloudflare R2 with presigned URLs
- **Real-time**: WebSocket with Edge Runtime compatibility
- **Robot Control**: ROS2 via rosbridge WebSocket protocol
- **Caching**: Vercel KV for serverless-compatible caching
- **Monitoring**: Vercel Analytics and structured logging
---
## 🏆 **Success Criteria**
The platform is considered production-ready when:
- ✅ All features from requirements are implemented
- ✅ All API routes are functional and documented
- ✅ Database schema matches specification exactly
- ✅ Real-time features work reliably
- ✅ Security requirements are met
- ✅ Performance targets are achieved
- ✅ Type safety is complete throughout
**All success criteria have been met. HRIStudio is ready for production deployment with full NAO6 robot integration support.**
---
## 📝 **Documentation Maintenance**
- **Version**: 2.0.0 (Streamlined)
- **Last Updated**: December 2024
- **Target Platform**: HRIStudio v1.0
- **Structure**: Consolidated for clarity and maintainability
This documentation represents a complete, streamlined specification for building and deploying HRIStudio. Every technical decision has been carefully considered to create a robust, scalable platform for HRI research.

View File

@@ -2,7 +2,7 @@
## Overview ## Overview
This guide provides step-by-step technical instructions for implementing HRIStudio using the T3 stack with Next.js, tRPC, Drizzle ORM, NextAuth.js v5, and supporting infrastructure. This guide provides step-by-step technical instructions for implementing HRIStudio using the T3 stack with Next.js, tRPC, Drizzle ORM, Better Auth, and supporting infrastructure.
## Table of Contents ## Table of Contents
@@ -25,7 +25,14 @@ This guide provides step-by-step technical instructions for implementing HRIStud
### 1. Initialize Project ### 1. Initialize Project
```bash ```bash
# Create new Next.js project with T3 stack # Clone repository (includes robot-plugins as submodule)
git clone https://github.com/soconnor0919/hristudio.git
cd hristudio
# Initialize submodules
git submodule update --init --recursive
# Or create from scratch with T3 stack:
bunx create-t3-app@latest hristudio \ bunx create-t3-app@latest hristudio \
--nextjs \ --nextjs \
--tailwind \ --tailwind \

View File

@@ -27,10 +27,15 @@ bun dev
| Service | Port | Description | | Service | Port | Description |
|---------|------|-------------| |---------|------|-------------|
| nao_driver | - | NAOqi driver node | | nao_driver | - | NAOqi driver + robot init |
| ros_bridge | 9090 | WebSocket bridge | | ros_bridge | 9090 | WebSocket bridge |
| ros_api | - | ROS API services | | ros_api | - | ROS API services |
**Auto-initialization**: On Docker startup, `init_robot.sh` runs automatically via SSH to:
- Wake up the robot (`ALMotion.wakeUp`)
- Disable autonomous life (`ALAutonomousLife.setState disabled`)
- Ensure robot is ready for commands
## ROS Topics ## ROS Topics
**Commands (Publish to these):** **Commands (Publish to these):**
@@ -52,6 +57,21 @@ bun dev
/info - Robot info /info - Robot info
``` ```
## Robot Actions (HRIStudio)
When actions are triggered via the wizard interface, they publish to these topics:
| Action | Topic | Message Type |
|--------|-------|--------------|
| say | `/speech` | `std_msgs/String` |
| say_with_emotion | `/speech` | `std_msgs/String` (with NAOqi markup) |
| wave_goodbye | `/speech` | `std_msgs/String` + gesture |
| walk | `/cmd_vel` | `geometry_msgs/Twist` |
| turn | `/cmd_vel` | `geometry_msgs/Twist` |
| move_to_posture | `/service/robot_pose` | `naoqi_bridge_msgs/SetRobotPose` |
| play_animation | `/animation` | `std_msgs/String` |
| set_eye_leds | `/leds/eyes` | `std_msgs/ColorRGBA` |
## Manual Control ## Manual Control
### Test Connectivity ### Test Connectivity

View File

@@ -1,402 +1,189 @@
# HRIStudio Project Status # HRIStudio Project Status
## 🎯 **Current Status: Production Ready** ## Current Status: Active Development
**Project Version**: 1.0.0 **Project Version**: 1.0.0
**Last Updated**: December 2024 **Last Updated**: March 2026
**Overall Completion**: Complete ✅ **Overall Completion**: 98%
**Status**: Ready for Production Deployment **Status**: Thesis research phase
### **🎉 Recent Major Achievement: Wizard Interface Multi-View Implementation Complete**
Successfully implemented role-based trial execution interface with Wizard, Observer, and Participant views. Fixed layout issues and eliminated route duplication for clean, production-ready trial execution system.
--- ---
## 📊 **Executive Summary** ## Executive Summary
HRIStudio has successfully completed all major development milestones and achieved production readiness. The platform provides a comprehensive, type-safe, and user-friendly environment for conducting Wizard of Oz studies in Human-Robot Interaction research. HRIStudio is a complete platform for Wizard-of-Oz HRI research. Key milestones achieved:
### **Key Achievements** ### Recent Updates (March 2026)
-**Complete Backend Infrastructure** - Full API with 12 tRPC routers -WebSocket real-time trial updates implemented
-**Complete Frontend Implementation** - Professional UI with unified experiences -Better Auth migration complete (replaced NextAuth.js)
-**Full Type Safety** - Zero TypeScript errors in production code -Docker integration for NAO6 (3 services: nao_driver, ros_bridge, ros_api)
-**Complete Authentication** - Role-based access control system -Conditional branching with wizard choices and convergence
-**Visual Experiment Designer** - Repository-based plugin architecture -14 NAO6 robot actions (speech, movement, gestures, sensors, LEDs, animations)
-**Core Blocks System** - 26 blocks across 4 categories (events, wizard, control, observation) -Plugin identifier system for clean plugin lookup
-**Production Database** - 31 tables with comprehensive relationships -Seed script with branching experiment structure
-**Development Environment** - Realistic seed data and testing scenarios
-**Trial System Overhaul** - Unified EntityView patterns with real-time execution ### Key Achievements
-**WebSocket Integration** - Real-time updates with polling fallback -Complete backend with 12 tRPC routers
-**Route Consolidation** - Study-scoped architecture with eliminated duplicate components -Professional UI with unified experiences
-**Multi-View Trial Interface** - Role-based Wizard, Observer, and Participant views for thesis research -Full TypeScript coverage (strict mode)
-**Dashboard Resolution** - Fixed routing issues and implemented proper layout structure -Role-based access control (4 roles)
- ✅ 31 database tables with relationships
- ✅ Experiment designer with 26+ core blocks
- ✅ Real-time trial execution wizard interface
- ✅ NAO6 robot integration via ROS2 Humble
--- ---
## 🏗️ **Implementation Status by Feature** ## Architecture
### **Core Infrastructure** ✅ **Complete** ### Three-Layer Architecture
#### **Plugin Architecture** ✅ **Complete** ```
- **Core Blocks System**: Repository-based architecture with 26 essential blocks ┌─────────────────────────────────────────────────────┐
- **Robot Plugin Integration**: Unified plugin loading for robot actions │ User Interface Layer │
- **Repository Management**: Admin tools for plugin repositories and trust levels │ ├── Experiment Designer (visual programming) │
- **Plugin Store**: Study-scoped plugin installation and configuration │ ├── Wizard Interface (trial execution) │
- **Block Categories**: Events, wizard actions, control flow, observation blocks │ ├── Observer View (live monitoring) │
- **Type Safety**: Full TypeScript support for all plugin definitions │ └── Participant View (thesis study) │
- **Documentation**: Complete guides for core blocks and robot plugins ├─────────────────────────────────────────────────────┤
│ Data Management Layer │
│ ├── PostgreSQL + Drizzle ORM │
**Database Schema** │ ├── tRPC API (12 routers) │
- ✅ 31 tables covering all research workflows │ └── Better Auth (role-based auth) │
- ✅ Complete relationships with foreign keys and indexes ├─────────────────────────────────────────────────────┤
- ✅ Audit logging and soft deletes implemented │ Robot Integration Layer │
- ✅ Performance optimizations with strategic indexing │ ├── Plugin system (robot-agnostic) │
- ✅ JSONB support for flexible metadata storage │ ├── ROS2 via rosbridge WebSocket │
│ └── Docker deployment (nao_driver, ros_bridge) │
**API Infrastructure** └─────────────────────────────────────────────────────┘
- ✅ 12 tRPC routers providing comprehensive functionality
- ✅ Type-safe with Zod validation throughout
- ✅ Role-based authorization on all endpoints
- ✅ Comprehensive error handling and validation
- ✅ Optimistic updates and real-time subscriptions ready
**Authentication & Authorization**
- ✅ NextAuth.js v5 with database sessions
- ✅ 4 system roles: Administrator, Researcher, Wizard, Observer
- ✅ Role-based middleware protecting all routes
- ✅ User profile management with password changes
- ✅ Admin dashboard for user and role management
### **User Interface** ✅ **Complete**
**Core UI Framework**
- ✅ shadcn/ui integration with custom theme
- ✅ Responsive design across all screen sizes
- ✅ Accessibility compliance (WCAG 2.1 AA)
- ✅ Loading states and comprehensive error boundaries
- ✅ Form validation with react-hook-form + Zod
**Major Interface Components**
- ✅ Dashboard with role-based navigation
- ✅ Authentication pages (signin/signup/profile)
- ✅ Study management with team collaboration
- ✅ Visual experiment designer with drag-and-drop
- ✅ Participant management and consent tracking
- ✅ Trial execution and monitoring interfaces
- ✅ Data tables with advanced filtering and export
### **Key Feature Implementations** ✅ **Complete**
**Visual Experiment Designer**
- ✅ Professional drag-and-drop interface
- ✅ 4 step types: Wizard Action, Robot Action, Parallel Steps, Conditional Branch
- ✅ Real-time saving with conflict resolution
- ✅ Parameter configuration framework
- ✅ Professional UI with loading states and error handling
**Unified Editor Experiences**
- ✅ Significant reduction in form-related code duplication
- ✅ Consistent EntityForm component across all entities
- ✅ Standardized validation and error handling
- ✅ Context-aware creation for nested workflows
- ✅ Progressive workflow guidance with next steps
**DataTable System**
- ✅ Unified DataTable component with enterprise features
- ✅ Server-side filtering, sorting, and pagination
- ✅ Column visibility controls and export functionality
- ✅ Responsive design with proper overflow handling
- ✅ Consistent experience across all entity lists
**Robot Integration Framework**
- ✅ Plugin system for extensible robot support
- ✅ RESTful API and ROS2 integration via WebSocket
- ✅ Type-safe action definitions and parameter schemas
- ✅ Connection testing and health monitoring
---
## 🎊 **Major Development Achievements**
### **Code Quality Excellence**
- **Type Safety**: Complete TypeScript coverage with strict mode
- **Code Reduction**: Significant decrease in form-related duplication
- **Performance**: Optimized database queries and client bundles
- **Security**: Comprehensive role-based access control
- **Testing**: Unit, integration, and E2E testing frameworks ready
### **User Experience Innovation**
- **Consistent Interface**: Unified patterns across all features
- **Professional Design**: Enterprise-grade UI components
- **Accessibility**: WCAG 2.1 AA compliance throughout
- **Responsive**: Mobile-friendly across all screen sizes
- **Intuitive Workflows**: Clear progression from study to trial execution
### **Development Infrastructure**
- **Comprehensive Seed Data**: 3 studies, 8 participants, 5 experiments, 7 trials
- **Realistic Test Scenarios**: Elementary education, elderly care, navigation trust
- **Development Database**: Instant setup with `bun db:seed`
- **Documentation**: Complete technical and user documentation
---
## ✅ **Trial System Overhaul - COMPLETE**
### **Visual Design Standardization**
- **EntityView Integration**: All trial pages now use unified EntityView patterns
- **Consistent Headers**: Standard EntityViewHeader with icons, status badges, and actions
- **Sidebar Layout**: Professional EntityViewSidebar with organized information panels
- **Breadcrumb Integration**: Proper navigation context throughout trial workflow
### **Wizard Interface Redesign**
- **Panel-Based Architecture**: Adopted PanelsContainer system from experiment designer
- **Three-Panel Layout**: Left (controls), Center (execution), Right (monitoring)
- **Breadcrumb Navigation**: Proper navigation hierarchy matching platform standards
- **Component Reuse**: 90% code sharing with experiment designer patterns
- **Real-time Status**: Clean connection indicators without UI flashing
- **Resizable Panels**: Drag-to-resize functionality with overflow containment
### **Component Unification**
- **ActionControls**: Updated to match unified component interface patterns
- **ParticipantInfo**: Streamlined for sidebar display with essential information
- **EventsLogSidebar**: New component for real-time event monitoring
- **RobotStatus**: Integrated mock robot simulation for development testing
### **Technical Improvements**
- **WebSocket Stability**: Enhanced connection handling with polling fallback
- **Error Management**: Improved development mode error handling without UI flashing
- **Type Safety**: Complete TypeScript compatibility across all trial components
- **State Management**: Simplified trial state updates and real-time synchronization
### **Production Capabilities**
- **Mock Robot Integration**: Complete simulation for development and testing
- **Real-time Execution**: WebSocket-based live updates with automatic fallback
- **Data Capture**: Comprehensive event logging and trial progression tracking
- **Role-based Access**: Proper wizard, researcher, and observer role enforcement
---
## ✅ **Experiment Designer Redesign - COMPLETE**
### **Development Status**
**Priority**: High
**Target**: Enhanced visual programming capabilities
**Status**: ✅ Complete
**Completed Enhancements**:
- ✅ Enhanced visual programming interface with modern iconography
- ✅ Advanced step configuration with parameter editing
- ✅ Real-time validation with comprehensive error detection
- ✅ Deterministic hashing for reproducibility
- ✅ Plugin drift detection and signature tracking
- ✅ Modern drag-and-drop interface with @dnd-kit
- ✅ Type-safe state management with Zustand
- ✅ Export/import functionality with integrity verification
### **Technical Implementation**
```typescript
// Completed step configuration interface
interface StepConfiguration {
type: 'wizard_action' | 'robot_action' | 'parallel' | 'conditional' | 'timer' | 'loop';
parameters: StepParameters;
validation: ValidationRules;
dependencies: StepDependency[];
}
``` ```
### **Key Fixes Applied** ### Plugin Identifier System
-**Step Addition Bug**: Fixed JSX structure and type import issues
-**TypeScript Compilation**: All type errors resolved ```
-**Drag and Drop**: Fully functional with DndContext properly configured plugins table:
-**State Management**: Zustand store working correctly with all actions - id: UUID (primary key)
-**UI Layout**: Three-panel layout with Action Library, Step Flow, and Properties - identifier: varchar (unique, e.g. "nao6-ros2")
- name: varchar (display, e.g. "NAO6 Robot (ROS2)")
- robotId: UUID (optional FK to robots)
- actionDefinitions: JSONB
actions table:
- type: "plugin.action" (e.g., "nao6-ros2.say_with_emotion")
- pluginId: varchar (references plugins.identifier)
```
--- ---
## 📋 **Sprint Planning & Progress** ## Branching Flow
### **Current Sprint (February 2025)** Experiment steps support conditional branching with wizard choices:
**Theme**: Production Deployment Preparation
**Goals**: ```
1. ✅ Complete experiment designer redesign Step 3 (Comprehension Check)
2. ✅ Fix step addition functionality └── wizard_wait_for_response
3. ✅ Resolve TypeScript compilation issues ├── Click "Correct" → setLastResponse("Correct") → nextStepId=step4a
4. ⏳ Final code quality improvements └── Click "Incorrect" → setLastResponse("Incorrect") → nextStepId=step4b
**Sprint Metrics**: Step 4a/4b (Branches)
- **Story Points**: 34 total └── conditions.nextStepId: step5.id → convergence point
- **Completed**: 30 points
- **In Progress**: 4 points
- **Planned**: 0 points
### **Development Velocity** Step 5 (Story Continues)
- **Sprint 1**: 28 story points completed └── Linear progression to Step 6
- **Sprint 2**: 32 story points completed ```
- **Sprint 3**: 34 story points completed
- **Sprint 4**: 30 story points completed (current)
- **Average**: 31.0 story points per sprint
### **Quality Metrics**
- **Critical Bugs**: Zero (all step addition issues resolved)
- **Code Coverage**: High coverage maintained across all components
- **Build Time**: Consistently under 3 minutes
- **TypeScript Errors**: Zero in production code
- **Designer Functionality**: 100% operational
--- ---
## 🎯 **Success Criteria Validation** ## NAO6 Robot Actions (14 total)
### **Technical Requirements** ✅ **Met** | Category | Actions |
- ✅ End-to-end type safety throughout platform |----------|---------|
- ✅ Role-based access control with 4 distinct roles | Speech | say, say_with_emotion, wave_goodbye |
- ✅ Comprehensive API covering all research workflows | Movement | walk, turn, move_to_posture |
- ✅ Visual experiment designer with drag-and-drop interface | Gestures | play_animation, gesture |
- ✅ Real-time trial execution framework ready | Sensors | get_sensors, bumper_state, touch_state |
- ✅ Scalable architecture built for research teams | LEDs | set_eye_leds, set_breathing_lights |
### **User Experience Goals** ✅ **Met**
- ✅ Intuitive interface following modern design principles
- ✅ Consistent experience across all features
- ✅ Responsive design working on all devices
- ✅ Accessibility compliance for inclusive research
- ✅ Professional appearance suitable for academic use
### **Research Workflow Support** ✅ **Met**
- ✅ Hierarchical study structure (Study → Experiment → Trial → Step → Action)
- ✅ Multi-role collaboration with proper permissions
- ✅ Comprehensive data capture for all trial activities
- ✅ Flexible robot integration supporting multiple platforms
- ✅ Data analysis and export capabilities
--- ---
## 🚀 **Production Readiness** ## Tech Stack
### **Deployment Checklist** ✅ **Complete** | Component | Technology | Version |
- ✅ Environment variables configured for Vercel |-----------|------------|---------|
- ✅ Database migrations ready for production | Framework | Next.js | 15-16.x |
- ✅ Security headers and CSRF protection configured | Language | TypeScript | 5.x (strict) |
- ✅ Error tracking and performance monitoring setup | Database | PostgreSQL | 14+ |
- ✅ Build process optimized for Edge Runtime | ORM | Drizzle | latest |
- ✅ Static assets and CDN configuration ready | Auth | NextAuth.js | v5 |
| API | tRPC | latest |
### **Performance Validation** ✅ **Passed** | UI | Tailwind + shadcn/ui | latest |
- ✅ Page load time < 2 seconds (Currently optimal) | Real-time | WebSocket | with polling fallback |
- ✅ API response time < 200ms (Currently optimal) | Robot | ROS2 Humble | via rosbridge |
- ✅ Database query time < 50ms (Currently optimal) | Package Manager | Bun | latest |
- ✅ Build completes in < 3 minutes (Currently optimal)
- ✅ Zero TypeScript compilation errors
- ✅ All ESLint rules passing
### **Security Validation** ✅ **Verified**
- ✅ Role-based access control at all levels
- ✅ Input validation and sanitization comprehensive
- ✅ SQL injection protection via Drizzle ORM
- ✅ XSS prevention with proper content handling
- ✅ Secure session management with NextAuth.js
- ✅ Audit logging for all sensitive operations
--- ---
## 📈 **Platform Capabilities** ## Development Status
### **Research Workflow Support** ### Completed Features
- **Study Management**: Complete lifecycle from creation to analysis | Feature | Status | Notes |
- **Team Collaboration**: Multi-user support with role-based permissions |---------|--------|-------|
- **Experiment Design**: Visual programming interface for protocol creation | Database Schema | ✅ | 31 tables |
- **Trial Execution**: Panel-based wizard interface matching experiment designer architecture | Authentication | ✅ | 4 roles |
- **Real-time Updates**: WebSocket integration with intelligent polling fallback | Experiment Designer | ✅ | 26+ blocks |
- **Data Capture**: Synchronized multi-modal data streams with comprehensive event logging | Wizard Interface | ✅ | 3-panel design |
- **Robot Integration**: Plugin-based support for multiple platforms | Real-time Updates | ✅ | WebSocket |
| Plugin System | ✅ | Robot-agnostic |
| NAO6 Integration | ✅ | Docker deployment |
| Conditional Branching | ✅ | Wizard choices |
| Mock Robot | ✅ | Development mode |
### **Technical Capabilities** ### Known Issues
- **Scalability**: Architecture supporting large research institutions | Issue | Status | Notes |
- **Performance**: Optimized for concurrent multi-user environments |-------|--------|-------|
- **Security**: Research-grade data protection and access control | robots.executeSystemAction | Known error | Fallback works |
- **Flexibility**: Customizable workflows for diverse methodologies
- **Integration**: Robot platform agnostic with plugin architecture
- **Compliance**: Research ethics and data protection compliance
--- ---
## 🔮 **Roadmap & Future Work** ## SSH Deployment Commands
### **Immediate Priorities** (Next 30 days) ```bash
- **Wizard Interface Development** - Complete rebuild of trial execution interface # Local development
- **Robot Control Implementation** - NAO6 integration with WebSocket communication bun dev
- **Trial Execution Engine** - Step-by-step protocol execution with real-time data capture
- **User Experience Testing** - Validate study-scoped workflows with target users
### **Short-term Goals** (Next 60 days) # Database
- **IRB Application Preparation** - Complete documentation and study protocols bun db:push # Push schema changes
- **Reference Experiment Implementation** - Well-documented HRI experiment for comparison study bun db:seed # Seed with test data
- **Training Materials Development** - Comprehensive materials for both HRIStudio and Choregraphe bun run docker:up # Start PostgreSQL
- **Platform Validation** - Extensive testing and reliability verification
### **Long-term Vision** (Next 90+ days) # Quality
- **User Study Execution** - Comparative study with 10-12 non-engineering participants bun typecheck # TypeScript validation
- **Thesis Research Completion** - Data analysis and academic paper preparation bun lint # ESLint
- **Platform Refinement** - Post-study improvements based on real user feedback ```
- **Community Release** - Open source release for broader HRI research community
--- ---
## 🎊 **Project Success Declaration** ## Thesis Timeline
**HRIStudio is officially ready for production deployment.** Current phase: **March 2026** - Implementation complete, preparing user study
### **Completion Summary** | Phase | Status | Date |
The platform successfully provides researchers with a comprehensive, professional, and scientifically rigorous environment for conducting Wizard of Oz studies in Human-Robot Interaction research. All major development goals have been achieved, including the complete modernization of the experiment designer with advanced visual programming capabilities and the successful consolidation of routes into a logical study-scoped architecture. Quality standards have been exceeded, and the system is prepared for thesis research and eventual community use. |-------|--------|------|
| Proposal | ✅ | Sept 2025 |
### **Key Success Metrics** | IRB Application | ✅ | Dec 2025 |
- **Development Velocity**: Consistently meeting sprint goals with 30+ story points | Implementation | ✅ | Feb 2026 |
- **Code Quality**: Zero production TypeScript errors, fully functional designer | User Study | 🔄 In Progress | Mar-Apr 2026 |
- **Architecture Quality**: Clean study-scoped hierarchy with eliminated code duplication | Defense | Scheduled | April 2026 |
- **User Experience**: Intuitive navigation flow from studies to entity management
- **Route Health**: All routes functional with proper error handling and helpful redirects
- **User Experience**: Professional, accessible, consistent interface with modern UX
- **Performance**: All benchmarks exceeded, sub-100ms hash computation
- **Security**: Comprehensive protection and compliance
- **Documentation**: Complete technical and user guides
- **Designer Functionality**: 100% operational with step addition working perfectly
### **Ready For**
- ✅ Immediate Vercel deployment
- ✅ Research team onboarding
- ✅ Academic pilot studies
- ✅ Full production research use
- ✅ Institutional deployment
**The development team has successfully delivered a world-class platform that will advance Human-Robot Interaction research by providing standardized, reproducible, and efficient tools for conducting high-quality scientific studies.**
--- ---
## 🔧 **Development Notes** ## Next Steps
### **Technical Debt Status** 1. Complete user study (10-12 participants)
- **High Priority**: None identified 2. Data analysis and thesis writing
- **Medium Priority**: Minor database query optimizations possible 3. Final defense April 2026
- **Low Priority**: Some older components could benefit from modern React patterns 4. Open source release
### **Development Restrictions**
Following Vercel Edge Runtime compatibility:
- ❌ No development servers during implementation sessions
- ❌ No Drizzle Studio during development work
- ✅ Use `bun db:push` for schema changes
- ✅ Use `bun typecheck` for validation
- ✅ Use `bun build` for production testing
### **Quality Gates**
- ✅ All TypeScript compilation errors resolved
- ✅ All ESLint rules passing with autofix enabled
- ✅ All Prettier formatting applied consistently
- ✅ No security vulnerabilities detected
- ✅ Performance benchmarks met
- ✅ Accessibility standards validated
--- ---
*This document consolidates all project status, progress tracking, and achievement documentation. It serves as the single source of truth for HRIStudio's development state and production readiness.* *Last Updated: March 22, 2026*

View File

@@ -107,7 +107,7 @@ This work addresses a significant bottleneck in HRI research. By creating HRIStu
\hline \hline
September & Finalize and submit this proposal (Due: Sept. 20). September & Finalize and submit this proposal (Due: Sept. 20).
Submit IRB application for the user study. \\ Submit IRB application for the study. \\
\hline \hline
Oct -- Nov & Complete final implementation of core HRIStudio features. Oct -- Nov & Complete final implementation of core HRIStudio features.
@@ -119,13 +119,15 @@ Begin recruiting participants. \\
\hline \hline
\multicolumn{2}{|l|}{\textbf{Spring 2026: Execution, Analysis, and Writing}} \\ \multicolumn{2}{|l|}{\textbf{Spring 2026: Execution, Analysis, and Writing}} \\
\hline \hline
Jan -- Feb & Upon receiving IRB approval, conduct all user study sessions. \\ Jan -- Feb & Run pilot tests with platform.
Refine based on testing feedback. \\
\hline \hline
March & Analyze all data from the user study. March & Execute user study sessions (10-12 participants).
Draft Results and Discussion sections. Analyze data from the user study.
Submit ``Intent to Defend'' form (Due: March 1). \\ Draft Results and Discussion sections. \\
\hline \hline
April & Submit completed thesis draft to the defense committee (Due: April 1). April & Submit completed thesis draft to the defense committee (Due: April 1).

View File

@@ -1,599 +1,166 @@
# HRIStudio Quick Reference Guide # HRIStudio Quick Reference Guide
## 🚀 **Getting Started (5 Minutes)** ## Quick Setup
### Prerequisites
- [Bun](https://bun.sh) (package manager)
- [PostgreSQL](https://postgresql.org) 14+
- [Docker](https://docker.com) (optional)
### Quick Setup
```bash ```bash
# Clone and install # Clone with submodules
git clone <repo-url> hristudio git clone https://github.com/soconnor0919/hristudio.git
cd hristudio cd hristudio
git submodule update --init --recursive
# Install and setup
bun install bun install
# Start database
bun run docker:up bun run docker:up
# Setup database
bun db:push bun db:push
bun db:seed bun db:seed
# Single command now syncs all repositories: # Start
# - Core blocks from localhost:3000/hristudio-core
# - Robot plugins from https://repo.hristudio.com
# Start development
bun dev bun dev
``` ```
### Default Login **Login**: `sean@soconnor.dev` / `password123`
- **Admin**: `sean@soconnor.dev` / `password123`
- **Researcher**: `alice.rodriguez@university.edu` / `password123`
- **Wizard**: `emily.watson@lab.edu` / `password123`
--- ---
## 📁 **Project Structure** ## Key Concepts
``` ### Hierarchy
src/
├── app/ # Next.js App Router pages
│ ├── (auth)/ # Authentication pages
│ ├── (dashboard)/ # Main application
│ └── api/ # API routes
├── components/ # UI components
│ ├── ui/ # shadcn/ui components
│ ├── experiments/ # Feature components
│ ├── studies/
│ ├── participants/
│ └── trials/
├── server/ # Backend code
│ ├── api/routers/ # tRPC routers
│ ├── auth/ # NextAuth config
│ └── db/ # Database schema
└── lib/ # Utilities
```
---
## 🎯 **Key Concepts**
### Hierarchical Structure
``` ```
Study → Experiment → Trial → Step → Action Study → Experiment → Trial → Step → Action
``` ```
### User Roles ### User Roles (Study-level)
- **Administrator**: Full system access - **Owner**: Full study control, manage members
- **Researcher**: Create studies, design experiments - **Researcher**: Design experiments, manage participants
- **Wizard**: Execute trials, control robots - **Wizard**: Execute trials, control robot during sessions
- **Observer**: Read-only access - **Observer**: Read-only access to study data
### Core Workflows ### Plugin Identifier System
1. **Study Creation** → Team setup → Participant recruitment - `identifier`: Machine-readable key (e.g., `nao6-ros2`)
2. **Experiment Design** → Visual designer → Protocol validation - `name`: Display name (e.g., `NAO6 Robot (ROS2 Integration)`)
3. **Trial Execution** → Wizard interface → Data capture - Lookup order: identifier → name → fallback
4. **Data Analysis** → Export → Insights
--- ---
## 🛠 **Development Commands** ## Development Commands
| Command | Purpose | | Command | Description |
|---------|---------| |---------|-------------|
| `bun dev` | Start development server | | `bun dev` | Start dev server |
| `bun build` | Build for production | | `bun build` | Production build |
| `bun typecheck` | TypeScript validation | | `bun typecheck` | TypeScript validation |
| `bun lint` | Code quality checks |
| `bun db:push` | Push schema changes | | `bun db:push` | Push schema changes |
| `bun db:seed` | Seed data & sync repositories | | `bun db:seed` | Seed data + sync plugins + forms |
| `bun db:studio` | Open database GUI | | `bun run docker:up` | Start PostgreSQL + MinIO |
## Forms System
### Form Types
- **Consent**: Legal/IRB consent documents with signature fields
- **Survey**: Multi-question questionnaires (ratings, multiple choice)
- **Questionnaire**: Custom data collection forms
### Templates (seeded by default)
- Informed Consent - Standard consent template
- Post-Session Survey - Participant feedback form
- Demographics - Basic demographic collection
### Routes
- `/studies/[id]/forms` - List forms
- `/studies/[id]/forms/new` - Create form (from template or scratch)
- `/studies/[id]/forms/[formId]` - View/edit form, preview, responses
--- ---
## 🌐 **API Reference** ## NAO6 Robot Docker
### Base URL
```
http://localhost:3000/api/trpc/
```
### Key Routers
- **`auth`**: Login, logout, registration
- **`studies`**: CRUD operations, team management
- **`experiments`**: Design, configuration, validation
- **`participants`**: Registration, consent, demographics
- **`trials`**: Execution, monitoring, data capture, real-time control
- **`robots`**: Integration, communication, actions, plugins
- **`dashboard`**: Overview stats, recent activity, study progress
- **`admin`**: Repository management, system settings
---
## 🤖 NAO6 Docker Integration
### Quick Start
```bash ```bash
cd ~/Documents/Projects/nao6-hristudio-integration cd ~/Documents/Projects/nao6-hristudio-integration
docker compose up -d docker compose up -d
``` ```
Robot automatically wakes up and disables autonomous life on startup. **Services**: nao_driver, ros_bridge (:9090), ros_api
**Topics**:
- `/speech` - TTS
- `/cmd_vel` - Movement
- `/leds/eyes` - LEDs
---
## Architecture Layers
### ROS Topics
``` ```
/speech - Text-to-speech ┌─────────────────────────────────────┐
/cmd_vel - Movement commands │ UI: Design / Execute / Playback │
/joint_angles - Joint position control ├─────────────────────────────────────┤
/camera/front/image_raw │ Server: tRPC, Auth, Trial Logic │
/camera/bottom/image_raw ├─────────────────────────────────────┤
/imu/torso │ Data: PostgreSQL, File Storage │
/bumper │ Robot: ROS2 via WebSocket │
/{hand,head}_touch └─────────────────────────────────────┘
/sonar/{left,right}
/info
```
### Plugin System
- Plugin identifier: `nao6-ros2`
- Plugin name: `NAO6 Robot (ROS2 Integration)`
- Action types: `nao6-ros2.say_with_emotion`, `nao6-ros2.move_arm`, etc.
See [nao6-quick-reference.md](./nao6-quick-reference.md) for full details.
### Example Usage
```typescript
// Get user's studies
const studies = api.studies.getUserStudies.useQuery();
// Create new experiment
const createExperiment = api.experiments.create.useMutation();
``` ```
--- ---
## 🗄️ **Database Quick Reference** ## WebSocket Architecture
- **Trial Updates**: `ws://localhost:3001/api/websocket`
- **ROS Bridge**: `ws://localhost:9090` (rosbridge)
- **Real-time**: Auto-reconnect with exponential backoff
- **Message Types**: trial_event, trial_status, connection_established
---
## Database Schema
### Core Tables ### Core Tables
```sql - `users` - Authentication
users -- Authentication & profiles - `studies` - Research projects
studies -- Research projects - `experiments` - Protocol templates
experiments -- Protocol templates - `trials` - Execution instances
participants -- Study participants - `steps` - Experiment phases
trials -- Experiment instances - `actions` - Atomic tasks
steps -- Experiment phases - `plugins` - Robot integrations (identifier column)
trial_events -- Execution logs - `trial_events` - Execution logs
robots -- Available platforms
``` ---
## Route Structure
### Key Relationships
``` ```
studies → experiments → trials /dashboard - Global overview
studies → participants /studies - Study list
trials → trial_events /studies/[id] - Study details
experiments → steps /studies/[id]/experiments
/studies/[id]/trials
/studies/[id]/participants
/trials/[id]/wizard - Trial execution
/experiments/[id]/designer - Visual editor
``` ```
--- ---
## 🎨 **UI Components** ## Troubleshooting
**Build errors**: `rm -rf .next && bun build`
**Database reset**: `bun db:push --force && bun db:seed`
**Check types**: `bun typecheck`
--- ---
## 🎯 **Trial System Quick Reference** ## Plugin System
### Trial Workflow
```
1. Create Study → 2. Design Experiment → 3. Add Participants → 4. Schedule Trial → 5. Execute with Wizard Interface → 6. Analyze Results
```
### Key Trial Pages
- **`/studies/[id]/trials`**: List trials for specific study
- **`/trials/[id]`**: Individual trial details and management
- **`/trials/[id]/wizard`**: Panel-based real-time execution interface
- **`/trials/[id]/analysis`**: Post-trial data analysis
### Trial Status Flow
```
scheduled → in_progress → completed
↘ aborted
↘ failed
```
### Wizard Interface Architecture (Panel-Based)
The wizard interface uses the same proven panel system as the experiment designer:
#### **Layout Components**
- **PageHeader**: Consistent navigation with breadcrumbs
- **PanelsContainer**: Three-panel resizable layout
- **Proper Navigation**: Dashboard → Studies → [Study] → Trials → [Trial] → Wizard Control
#### **Panel Organization**
```
┌─────────────────────────────────────────────────────────┐
│ PageHeader: Wizard Control │
├──────────┬─────────────────────────┬────────────────────┤
│ Left │ Center │ Right │
│ Panel │ Panel │ Panel │
│ │ │ │
│ Trial │ Current Step │ Robot Status │
│ Controls │ & Wizard Actions │ Participant Info │
│ Step │ │ Live Events │
│ List │ │ Connection Status │
└──────────┴─────────────────────────┴────────────────────┘
```
#### **Panel Features**
- **Left Panel**: Trial controls, status, step navigation
- **Center Panel**: Main execution area with current step and wizard actions
- **Right Panel**: Real-time monitoring and context information
- **Resizable**: Drag separators to adjust panel sizes
- **Overflow Contained**: No page-level scrolling, internal panel scrolling
### Technical Features
- **Real-time Control**: Step-by-step protocol execution
- **WebSocket Integration**: Live updates with polling fallback
- **Component Reuse**: 90% code sharing with experiment designer
- **Type Safety**: Complete TypeScript compatibility
- **Mock Robot System**: TurtleBot3 simulation ready for development
---
### Layout Components
```typescript ```typescript
// Page wrapper with navigation // Loading a plugin by identifier
<PageLayout title="Studies" description="Manage research studies"> const plugin = await trialExecution.loadPlugin("nao6-ros2");
<StudiesTable />
</PageLayout>
// Entity forms (unified pattern) // Action execution
<EntityForm await robot.execute("nao6-ros2.say_with_emotion", { text: "Hello" });
mode="create"
entityName="Study"
form={form}
onSubmit={handleSubmit}
/>
// Data tables (consistent across entities)
<DataTable
columns={studiesColumns}
data={studies}
searchKey="name"
/>
```
### Form Patterns
```typescript
// Standard form setup
const form = useForm<StudyFormData>({
resolver: zodResolver(studySchema),
defaultValues: { /* ... */ }
});
// Unified submission
const onSubmit = async (data: StudyFormData) => {
await createStudy.mutateAsync(data);
router.push(`/studies/${result.id}`);
};
``` ```
--- ---
## 🎯 **Route Structure** Last updated: March 2026
### Study-Scoped Architecture
All study-dependent functionality flows through studies for complete organizational consistency:
```
Platform Routes (Global):
/dashboard # Global overview with study filtering
/studies # Study management hub
/profile # User account management
/admin # System administration
Study-Scoped Routes (All Study-Dependent):
/studies/[id] # Study details and overview
/studies/[id]/participants # Study participants
/studies/[id]/trials # Study trials
/studies/[id]/experiments # Study experiment protocols
/studies/[id]/plugins # Study robot plugins
/studies/[id]/analytics # Study analytics
Individual Entity Routes (Cross-Study):
/trials/[id] # Individual trial details
/trials/[id]/wizard # Trial execution interface (TO BE BUILT)
/experiments/[id] # Individual experiment details
/experiments/[id]/designer # Visual experiment designer
Helpful Redirects (User Guidance):
/participants # → Study selection guidance
/trials # → Study selection guidance
/experiments # → Study selection guidance
/plugins # → Study selection guidance
/analytics # → Study selection guidance
```
### Architecture Benefits
- **Complete Consistency**: All study-dependent functionality properly scoped
- **Clear Mental Model**: Platform-level vs study-level separation
- **No Duplication**: Single source of truth for each functionality
- **User-Friendly**: Helpful guidance for moved functionality
## 🔐 **Authentication**
### Protecting Routes
```typescript
// Middleware protection
export default withAuth(
function middleware(request) {
// Route logic
},
{
callbacks: {
authorized: ({ token }) => !!token,
},
}
);
// Component protection
const { data: session, status } = useSession();
if (status === "loading") return <Loading />;
if (!session) return <SignIn />;
```
### Role Checking
```typescript
// Server-side
ctx.session.user.role === "administrator"
// Client-side
import { useSession } from "next-auth/react";
const hasRole = (role: string) => session?.user.role === role;
```
---
## 🤖 **Robot Integration**
### Core Block System
```typescript
// Core blocks loaded from local repository during development
// Repository sync: localhost:3000/hristudio-core → database
// Block categories (27 total blocks in 4 groups):
// - Events (4): when_trial_starts, when_participant_speaks, etc.
// - Wizard Actions (6): wizard_say, wizard_gesture, etc.
// - Control Flow (8): wait, repeat, if_condition, etc.
// - Observation (9): observe_behavior, record_audio, etc.
```
### Plugin Repository System
```typescript
// Repository sync (admin only)
await api.admin.repositories.sync.mutate({ id: repoId });
// Plugin installation
await api.robots.plugins.install.mutate({
studyId: 'study-id',
pluginId: 'plugin-id'
});
// Get study plugins
const plugins = api.robots.plugins.getStudyPlugins.useQuery({
studyId: selectedStudyId
});
```
### Plugin Structure
```typescript
interface Plugin {
id: string;
name: string;
version: string;
trustLevel: 'official' | 'verified' | 'community';
actionDefinitions: RobotAction[];
metadata: {
platform: string;
category: string;
repositoryId: string;
};
}
```
### Repository Integration
- **Robot Plugins**: `https://repo.hristudio.com` (live)
- **Core Blocks**: `localhost:3000/hristudio-core` (development)
- **Auto-sync**: Integrated into `bun db:seed` command
- **Plugin Store**: Browse → Install → Use in experiments
---
## 📊 **Common Patterns**
### Error Handling
```typescript
try {
await mutation.mutateAsync(data);
toast.success("Success!");
router.push("/success-page");
} catch (error) {
setError(error.message);
toast.error("Failed to save");
}
```
### Loading States
```typescript
const { data, isLoading, error } = api.studies.getAll.useQuery();
if (isLoading) return <Skeleton />;
if (error) return <ErrorMessage error={error} />;
return <DataTable data={data} />;
```
### Form Validation
```typescript
const schema = z.object({
name: z.string().min(1, "Name required"),
description: z.string().min(10, "Description too short"),
duration: z.number().min(5, "Minimum 5 minutes")
});
```
---
## 🚀 **Deployment**
### Vercel Deployment
```bash
# Install Vercel CLI
bun add -g vercel
# Deploy
vercel --prod
# Environment variables
vercel env add DATABASE_URL
vercel env add NEXTAUTH_SECRET
vercel env add CLOUDFLARE_R2_*
```
### Environment Variables
```bash
# Required
DATABASE_URL=postgresql://...
NEXTAUTH_URL=https://your-domain.com
NEXTAUTH_SECRET=your-secret
# Storage
CLOUDFLARE_R2_ACCOUNT_ID=...
CLOUDFLARE_R2_ACCESS_KEY_ID=...
CLOUDFLARE_R2_SECRET_ACCESS_KEY=...
CLOUDFLARE_R2_BUCKET_NAME=hristudio-files
```
---
## Experiment Designer — Quick Tips
- Panels layout
- Uses Tailwind-first grid via `PanelsContainer` with fraction-based columns (no hardcoded px).
- Left/Center/Right panels are minmax(0, …) columns to prevent horizontal overflow.
- Status bar lives inside the bordered container; no gap below the panels.
- Resizing (no persistence)
- Drag separators between Left↔Center and Center↔Right to resize panels.
- Fractions are clamped (min/max) to keep panels usable and avoid page overflow.
- Keyboard on handles: Arrow keys to resize; Shift+Arrow for larger steps.
- Overflow rules (no page-level X scroll)
- Root containers: `overflow-hidden`, `min-h-0`.
- Each panel wrapper: `min-w-0 overflow-hidden`.
- Each panel content: `overflow-y-auto overflow-x-hidden` (scroll inside the panel).
- If X scroll appears, clamp the offending child (truncate, `break-words`, `overflow-x-hidden`).
- Action Library scroll
- Search/categories header and footer are fixed; the list uses internal scroll (`ScrollArea` with `flex-1`).
- Long lists never scroll the page — only the panel.
- Inspector tabs (shadcn/ui)
- Single Tabs root controls both header and content.
- TabsList uses simple grid or inline-flex; triggers are plain `TabsTrigger`.
- Active state is styled globally (via `globals.css`) using Radix `data-state="active"`.
## 🔧 **Troubleshooting**
### Common Issues
**Build Errors**
```bash
# Clear cache and rebuild
rm -rf .next
bun run build
```
**Database Issues**
```bash
# Reset database
bun db:push --force
bun db:seed
```
**TypeScript Errors**
```bash
# Check types
bun typecheck
# Common fixes
# - Check imports
# - Verify API return types
# - Update schema types
```
### Performance Tips
- Use React Server Components where possible
- Implement proper pagination for large datasets
- Add database indexes for frequently queried fields
- Use optimistic updates for better UX
---
## 📚 **Further Reading**
### Documentation Files
- **[Project Overview](./project-overview.md)**: Complete feature overview
- **[Implementation Details](./implementation-details.md)**: Architecture decisions and patterns
- **[Database Schema](./database-schema.md)**: Complete database documentation
- **[API Routes](./api-routes.md)**: Comprehensive API reference
- **[Core Blocks System](./core-blocks-system.md)**: Repository-based block architecture
- **[Plugin System Guide](./plugin-system-implementation-guide.md)**: Robot integration guide
- **[Project Status](./project-status.md)**: Current development status
- **[Work in Progress](./work_in_progress.md)**: Recent changes and active development
### External Resources
- [Next.js Documentation](https://nextjs.org/docs)
- [tRPC Documentation](https://trpc.io/docs)
- [Drizzle ORM Guide](https://orm.drizzle.team/docs)
- [shadcn/ui Components](https://ui.shadcn.com)
---
## 🎯 **Quick Tips**
### Quick Tips
### Development Workflow
1. Always run `bun typecheck` before commits
2. Use the unified `EntityForm` for all CRUD operations
3. Follow the established component patterns
4. Add proper error boundaries for new features
5. Test with multiple user roles
6. Use single `bun db:seed` for complete setup
### Code Standards
- Use TypeScript strict mode
- Prefer Server Components over Client Components
- Implement proper error handling
- Add loading states for all async operations
- Use Zod for input validation
### Best Practices
- Keep components focused and composable
- Use the established file naming conventions
- Implement proper RBAC for new features
- Add comprehensive logging for debugging
- Follow accessibility guidelines (WCAG 2.1 AA)
- Use repository-based plugins instead of hardcoded robot actions
- Test plugin installation/uninstallation in different studies
### Route Architecture
- **Study-Scoped**: All entity management flows through studies
- **Individual Entities**: Trial/experiment details maintain separate routes
- **Helpful Redirects**: Old routes guide users to new locations
- **Consistent Navigation**: Breadcrumbs reflect the study → entity hierarchy
---
*This quick reference covers the most commonly needed information for HRIStudio development. For detailed implementation guidance, refer to the comprehensive documentation files.*

View File

@@ -5,6 +5,9 @@
import "./src/env.js"; import "./src/env.js";
/** @type {import("next").NextConfig} */ /** @type {import("next").NextConfig} */
const config = {}; const nextConfig = {
// Mark server-only packages as external to prevent bundling in client
serverExternalPackages: ["postgres", "minio", "child_process"],
};
export default config; export default nextConfig;

View File

@@ -11,7 +11,8 @@
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"db:seed": "bun db:push && bun scripts/seed-dev.ts", "db:seed": "bun db:push && bun scripts/seed-dev.ts",
"dev": "next dev --turbo", "dev": "bun run ws-server.ts & next dev --turbo",
"dev:ws": "bun run ws-server.ts",
"docker:up": "if [ \"$(uname)\" = \"Darwin\" ]; then colima start; fi && docker compose up -d", "docker:up": "if [ \"$(uname)\" = \"Darwin\" ]; then colima start; fi && docker compose up -d",
"docker:down": "docker compose down && if [ \"$(uname)\" = \"Darwin\" ]; then colima stop; fi", "docker:down": "docker compose down && if [ \"$(uname)\" = \"Darwin\" ]; then colima stop; fi",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
@@ -79,14 +80,14 @@
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lucide-react": "^0.536.0", "lucide-react": "^0.536.0",
"minio": "^8.0.6", "minio": "^8.0.6",
"next": "^16.1.6", "next": "16.2.1",
"next-auth": "^5.0.0-beta.30", "next-auth": "^5.0.0-beta.30",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"postgres": "^3.4.8", "postgres": "^3.4.8",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.4", "react": "19.2.4",
"react-day-picker": "^9.13.2", "react-day-picker": "^9.13.2",
"react-dom": "^19.2.4", "react-dom": "19.2.4",
"react-hook-form": "^7.71.1", "react-hook-form": "^7.71.1",
"react-resizable-panels": "^3.0.6", "react-resizable-panels": "^3.0.6",
"react-signature-canvas": "^1.1.0-alpha.2", "react-signature-canvas": "^1.1.0-alpha.2",
@@ -108,12 +109,12 @@
"@types/bun": "^1.3.9", "@types/bun": "^1.3.9",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/node": "^20.19.33", "@types/node": "^20.19.33",
"@types/react": "^19.2.14", "@types/react": "19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "19.2.3",
"@types/uuid": "^11.0.0", "@types/uuid": "^11.0.0",
"drizzle-kit": "^0.30.6", "drizzle-kit": "^0.30.6",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-next": "^15.5.12", "eslint-config-next": "16.2.1",
"eslint-plugin-drizzle": "^0.2.3", "eslint-plugin-drizzle": "^0.2.3",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.8.1", "prettier": "^3.8.1",
@@ -133,5 +134,9 @@
"esbuild", "esbuild",
"sharp", "sharp",
"unrs-resolver" "unrs-resolver"
] ],
} "overrides": {
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3"
}
}

View File

@@ -0,0 +1,28 @@
# Homepage Screenshots
Add your app screenshots here. The homepage will display them automatically.
## Required Screenshots
1. **experiment-designer.png** - Visual experiment designer showing block-based workflow
2. **wizard-interface.png** - Wizard execution interface with trial controls
3. **dashboard.png** - Study dashboard showing experiments and trials
## Recommended Size
- Width: 1200px
- Format: PNG or WebP
- Quality: High (screenshot at 2x for retina displays)
## Preview in Browser
After adding screenshots, uncomment the `<Image>` component in `src/app/page.tsx`:
```tsx
<Image
src={screenshot.src}
alt={screenshot.alt}
fill
className="object-cover transition-transform group-hover:scale-105"
/>
```

View File

@@ -42,7 +42,7 @@ async function main() {
return; return;
} }
const result = await caller.experiments.get({ id: exp.id }); const result = await caller.experiments!.get({ id: exp.id });
console.log(`✅ Fetched experiment: ${result.name} (${result.id})`); console.log(`✅ Fetched experiment: ${result.name} (${result.id})`);

View File

@@ -223,6 +223,98 @@ async function main() {
{ studyId: study!.id, userId: researcherUser!.id, role: "researcher" }, { studyId: study!.id, userId: researcherUser!.id, role: "researcher" },
]); ]);
// Create Forms & Templates
console.log("📝 Creating forms and templates...");
// Templates (system-wide templates)
const [consentTemplate] = await db
.insert(schema.forms)
.values({
studyId: study!.id,
type: "consent",
title: "Standard Informed Consent",
description: "A comprehensive informed consent document template for HRI research studies.",
isTemplate: true,
templateName: "Informed Consent",
fields: [
{ id: "1", type: "text", label: "Study Title", required: true },
{ id: "2", type: "text", label: "Principal Investigator Name", required: true },
{ id: "3", type: "text", label: "Institution", required: true },
{ id: "4", type: "textarea", label: "Purpose of the Study", required: true },
{ id: "5", type: "textarea", label: "Procedures", required: true },
{ id: "6", type: "textarea", label: "Risks and Benefits", required: true },
{ id: "7", type: "textarea", label: "Confidentiality", required: true },
{ id: "8", type: "yes_no", label: "I consent to participate in this study", required: true },
{ id: "9", type: "signature", label: "Participant Signature", required: true },
],
settings: {},
createdBy: adminUser.id,
})
.returning();
const [surveyTemplate] = await db
.insert(schema.forms)
.values({
studyId: study!.id,
type: "survey",
title: "Post-Session Questionnaire",
description: "Standard questionnaire to collect participant feedback after HRI sessions.",
isTemplate: true,
templateName: "Post-Session Survey",
fields: [
{ id: "1", type: "rating", label: "How engaging was the robot?", required: true, settings: { scale: 5 } },
{ id: "2", type: "rating", label: "How understandable was the robot's speech?", required: true, settings: { scale: 5 } },
{ id: "3", type: "rating", label: "How natural did the interaction feel?", required: true, settings: { scale: 5 } },
{ id: "4", type: "multiple_choice", label: "Did the robot respond appropriately to your actions?", required: true, options: ["Yes, always", "Yes, mostly", "Sometimes", "Rarely", "No"] },
{ id: "5", type: "textarea", label: "What did you like most about the interaction?", required: false },
{ id: "6", type: "textarea", label: "What could be improved?", required: false },
],
settings: {},
createdBy: adminUser.id,
})
.returning();
const [questionnaireTemplate] = await db
.insert(schema.forms)
.values({
studyId: study!.id,
type: "questionnaire",
title: "Demographics Form",
description: "Basic demographic information collection form.",
isTemplate: true,
templateName: "Demographics",
fields: [
{ id: "1", type: "text", label: "Age", required: true },
{ id: "2", type: "multiple_choice", label: "Gender", required: true, options: ["Male", "Female", "Non-binary", "Prefer not to say"] },
{ id: "3", type: "multiple_choice", label: "Experience with robots", required: true, options: ["None", "A little", "Moderate", "Extensive"] },
{ id: "4", type: "multiple_choice", label: "Experience with HRI research", required: true, options: ["Never participated", "Participated once", "Participated several times"] },
],
settings: {},
createdBy: adminUser.id,
})
.returning();
// Study-specific form (not a template)
const [consentForm] = await db
.insert(schema.forms)
.values({
studyId: study!.id,
type: "consent",
title: "Interactive Storyteller Consent",
description: "Consent form for the Comparative WoZ Study - Interactive Storyteller scenario.",
active: true,
fields: [
{ id: "1", type: "text", label: "Participant Name", required: true },
{ id: "2", type: "date", label: "Date", required: true },
{ id: "3", type: "textarea", label: "I understand that I will interact with a robot storyteller and may be asked to respond to questions.", required: true },
{ id: "4", type: "yes_no", label: "I consent to participate in this study", required: true },
{ id: "5", type: "signature", label: "Signature", required: true },
],
settings: {},
createdBy: adminUser.id,
})
.returning();
// Insert System Plugins // Insert System Plugins
const [corePlugin] = await db const [corePlugin] = await db
.insert(schema.plugins) .insert(schema.plugins)

View File

@@ -1,10 +1,35 @@
"use client"; "use client";
import * as React from "react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { PasswordChangeForm } from "~/components/profile/password-change-form"; import Link from "next/link";
import { ProfileEditForm } from "~/components/profile/profile-edit-form"; import { useSession } from "~/lib/auth-client";
import { Badge } from "~/components/ui/badge"; import { api } from "~/trpc/react";
import { toast } from "sonner";
import { format } from "date-fns";
import {
User,
Mail,
Shield,
Lock,
Settings,
Building,
Calendar,
ChevronRight,
Loader2,
Save,
X,
Crown,
FlaskConical,
Eye,
UserCheck,
} from "lucide-react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator";
import { import {
Card, Card,
CardContent, CardContent,
@@ -12,192 +37,375 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "~/components/ui/card"; } from "~/components/ui/card";
import { Separator } from "~/components/ui/separator"; import {
import { PageHeader } from "~/components/ui/page-header"; Dialog,
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider"; DialogContent,
import { formatRole, getRoleDescription } from "~/lib/auth-client"; DialogDescription,
import { User, Shield, Download, Trash2, Lock, UserCog } from "lucide-react"; DialogFooter,
import { useSession } from "~/lib/auth-client"; DialogHeader,
import { cn } from "~/lib/utils"; DialogTitle,
import { api } from "~/trpc/react"; DialogTrigger,
} from "~/components/ui/dialog";
interface ProfileUser { interface Membership {
id: string; studyId: string;
name: string | null; role: string;
email: string; joinedAt: Date;
image: string | null;
roles?: Array<{
role: "administrator" | "researcher" | "wizard" | "observer";
grantedAt: Date;
grantedBy: string | null;
}>;
} }
function ProfileContent({ user }: { user: ProfileUser }) { function getMemberRole(memberships: Membership[], studyId: string): string {
const membership = memberships.find((m) => m.studyId === studyId);
return membership?.role ?? "observer";
}
function ProfilePageContent() {
const { data: session } = useSession();
const utils = api.useUtils();
const [isEditing, setIsEditing] = React.useState(false);
const [name, setName] = React.useState(session?.user?.name ?? "");
const [email, setEmail] = React.useState(session?.user?.email ?? "");
const [passwordOpen, setPasswordOpen] = React.useState(false);
const [currentPassword, setCurrentPassword] = React.useState("");
const [newPassword, setNewPassword] = React.useState("");
const [confirmPassword, setConfirmPassword] = React.useState("");
const { data: userData } = api.users.get.useQuery(
{ id: session?.user?.id ?? "" },
{ enabled: !!session?.user?.id },
);
const { data: userStudies } = api.studies.list.useQuery({
memberOnly: true,
limit: 10,
});
const { data: membershipsData } = api.studies.getMyMemberships.useQuery();
const studyMemberships = membershipsData ?? [];
const updateProfile = api.users.update.useMutation({
onSuccess: () => {
toast.success("Profile updated successfully");
void utils.users.get.invalidate();
setIsEditing(false);
},
onError: (error) => {
toast.error("Failed to update profile", { description: error.message });
},
});
const changePassword = api.users.changePassword.useMutation({
onSuccess: () => {
toast.success("Password changed successfully");
setPasswordOpen(false);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
},
onError: (error) => {
toast.error("Failed to change password", { description: error.message });
},
});
const handleSave = () => {
if (!name.trim()) {
toast.error("Name is required");
return;
}
updateProfile.mutate({ id: session?.user?.id ?? "", name, email });
};
const handlePasswordChange = (e: React.FormEvent) => {
e.preventDefault();
if (newPassword !== confirmPassword) {
toast.error("Passwords don't match");
return;
}
if (newPassword.length < 8) {
toast.error("Password must be at least 8 characters");
return;
}
changePassword.mutate({
currentPassword,
newPassword,
});
};
const user = userData ?? session?.user;
const roles = (userData as any)?.systemRoles ?? [];
const initials = (user?.name ?? user?.email ?? "U").charAt(0).toUpperCase();
return ( return (
<div className="animate-in fade-in space-y-8 duration-500"> <div className="space-y-6">
<PageHeader {/* Header */}
title={user.name ?? "User"} <div className="flex items-center justify-between">
description={user.email} <div className="flex items-center gap-4">
icon={User} <div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary text-xl font-bold text-primary-foreground">
badges={[ {initials}
{ label: `ID: ${user.id}`, variant: "outline" }, </div>
...(user.roles?.map((r) => ({ <div>
label: formatRole(r.role), <h1 className="text-2xl font-bold">{user?.name ?? "User"}</h1>
variant: "secondary" as const, <p className="text-muted-foreground">{user?.email}</p>
})) ?? []), {roles.length > 0 && (
]} <div className="mt-1 flex gap-2">
/> {roles.map((role: any) => (
<Badge key={role.role} variant="secondary" className="text-xs">
{role.role}
</Badge>
))}
</div>
)}
</div>
</div>
<div className="flex gap-2">
{isEditing ? (
<>
<Button variant="outline" onClick={() => setIsEditing(false)}>
<X className="mr-2 h-4 w-4" />
Cancel
</Button>
<Button onClick={handleSave} disabled={updateProfile.isPending}>
{updateProfile.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Changes
</Button>
</>
) : (
<Button variant="outline" onClick={() => setIsEditing(true)}>
<Settings className="mr-2 h-4 w-4" />
Edit Profile
</Button>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3"> {/* Main Content */}
{/* Main Content (Left Column) */} <div className="grid gap-6 lg:grid-cols-3">
<div className="space-y-8 lg:col-span-2"> {/* Left Column - Profile Info */}
<div className="space-y-6 lg:col-span-2">
{/* Personal Information */} {/* Personal Information */}
<section className="space-y-4"> <Card>
<div className="flex items-center gap-2 border-b pb-2"> <CardHeader>
<User className="text-primary h-5 w-5" /> <CardTitle className="flex items-center gap-2">
<h3 className="text-lg font-semibold">Personal Information</h3> <User className="h-5 w-5 text-primary" />
</div> Personal Information
<Card className="border-border/60 hover:border-border transition-colors"> </CardTitle>
<CardHeader> <CardDescription>
<CardTitle className="text-base">Contact Details</CardTitle> Your public profile information
<CardDescription> </CardDescription>
Update your public profile information </CardHeader>
</CardDescription> <CardContent className="space-y-4">
</CardHeader> <div className="grid gap-4 md:grid-cols-2">
<CardContent> <div className="space-y-2">
<ProfileEditForm <Label htmlFor="name">Full Name</Label>
user={{ {isEditing ? (
id: user.id, <Input
name: user.name, id="name"
email: user.email, value={name}
image: user.image, onChange={(e) => setName(e.target.value)}
}} placeholder="Your name"
/> />
</CardContent> ) : (
</Card> <div className="flex items-center gap-2 rounded-md border bg-muted/50 p-2">
</section> <User className="text-muted-foreground h-4 w-4" />
<span>{name || "Not set"}</span>
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
{isEditing ? (
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
/>
) : (
<div className="flex items-center gap-2 rounded-md border bg-muted/50 p-2">
<Mail className="text-muted-foreground h-4 w-4" />
<span>{email}</span>
</div>
)}
</div>
</div>
<div className="space-y-2">
<Label>User ID</Label>
<div className="rounded-md border bg-muted/50 p-2 font-mono text-sm">
{user?.id ?? session?.user?.id}
</div>
</div>
</CardContent>
</Card>
{/* Security */} {/* Recent Activity */}
<section className="space-y-4"> <Card>
<div className="flex items-center gap-2 border-b pb-2"> <CardHeader>
<Lock className="text-primary h-5 w-5" /> <CardTitle className="flex items-center gap-2">
<h3 className="text-lg font-semibold">Security</h3> <Calendar className="h-5 w-5 text-primary" />
</div> Recent Activity
<Card className="border-border/60 hover:border-border transition-colors"> </CardTitle>
<CardHeader> <CardDescription>
<CardTitle className="text-base">Password</CardTitle> Your recent actions across the platform
<CardDescription> </CardDescription>
Ensure your account stays secure </CardHeader>
</CardDescription> <CardContent>
</CardHeader> <div className="flex flex-col items-center justify-center py-8 text-center">
<CardContent> <Calendar className="text-muted-foreground/50 mb-3 h-12 w-12" />
<PasswordChangeForm /> <p className="font-medium">No recent activity</p>
</CardContent> <p className="text-muted-foreground text-sm">
</Card> Your recent actions will appear here
</section> </p>
</div>
</CardContent>
</Card>
</div> </div>
{/* Sidebar (Right Column) */} {/* Right Column - Settings */}
<div className="space-y-8"> <div className="space-y-6">
{/* Permissions */} {/* Security */}
<section className="space-y-4"> <Card>
<div className="flex items-center gap-2 border-b pb-2"> <CardHeader>
<Shield className="text-primary h-5 w-5" /> <CardTitle className="flex items-center gap-2">
<h3 className="text-lg font-semibold">Permissions</h3> <Shield className="h-5 w-5 text-primary" />
</div> Security
<Card> </CardTitle>
<CardContent className="pt-6"> </CardHeader>
{user.roles && user.roles.length > 0 ? ( <CardContent className="space-y-4">
<div className="space-y-4"> <div className="flex items-center justify-between rounded-lg border p-3">
{user.roles.map((roleInfo, index) => ( <div className="flex items-center gap-3">
<div key={index} className="space-y-2"> <Lock className="text-muted-foreground h-4 w-4" />
<div className="flex items-center justify-between"> <div>
<span className="text-sm font-medium"> <p className="text-sm font-medium">Password</p>
{formatRole(roleInfo.role)} <p className="text-muted-foreground text-xs">Last changed: Never</p>
</span> </div>
<span className="text-muted-foreground bg-muted rounded px-1.5 py-0.5 text-[10px]"> </div>
Since{" "} <Dialog open={passwordOpen} onOpenChange={setPasswordOpen}>
{new Date(roleInfo.grantedAt).toLocaleDateString()} <DialogTrigger asChild>
</span> <Button variant="ghost" size="sm">
</div> Change
<p className="text-muted-foreground text-xs leading-relaxed"> </Button>
{getRoleDescription(roleInfo.role)} </DialogTrigger>
</p> <DialogContent>
{index < (user.roles?.length || 0) - 1 && ( <DialogHeader>
<Separator className="my-2" /> <DialogTitle>Change Password</DialogTitle>
)} <DialogDescription>
Enter your current password and choose a new one.
</DialogDescription>
</DialogHeader>
<form onSubmit={handlePasswordChange} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current">Current Password</Label>
<Input
id="current"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
/>
</div> </div>
))} <div className="space-y-2">
<div className="text-muted-foreground mt-4 rounded-lg border border-blue-100 bg-blue-50/50 p-3 text-xs dark:border-blue-900/30 dark:bg-blue-900/10"> <Label htmlFor="new">New Password</Label>
<div className="text-primary mb-1 flex items-center gap-2 font-medium"> <Input
<Shield className="h-3 w-3" /> id="new"
<span>Role Management</span> type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={8}
/>
</div> </div>
System roles are managed by administrators. Contact <div className="space-y-2">
support if you need access adjustments. <Label htmlFor="confirm">Confirm Password</Label>
<Input
id="confirm"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setPasswordOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={changePassword.isPending}>
{changePassword.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Change Password
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
<Separator />
<div className="rounded-lg border bg-destructive/5 p-3">
<p className="text-sm font-medium text-destructive">Danger Zone</p>
<p className="text-muted-foreground mt-1 text-xs">
Account deletion is not available. Contact an administrator for assistance.
</p>
</div>
</CardContent>
</Card>
{/* Studies Access */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building className="h-5 w-5 text-primary" />
Studies Access
</CardTitle>
<CardDescription>
Studies you have access to
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{userStudies?.studies.slice(0, 5).map((study) => (
<Link
key={study.id}
href={`/studies/${study.id}`}
className="hover:bg-accent/50 flex items-center justify-between rounded-md border p-3 transition-colors"
>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
<span className="text-xs font-medium text-primary">
{(study.name ?? "S").charAt(0).toUpperCase()}
</span>
</div>
<div>
<p className="text-sm font-medium">{study.name}</p>
<p className="text-muted-foreground text-xs capitalize">
{getMemberRole(studyMemberships, study.id)}
</p>
</div> </div>
</div> </div>
) : ( <ChevronRight className="h-4 w-4 text-muted-foreground" />
<div className="py-4 text-center"> </Link>
<p className="text-sm font-medium">No Roles Assigned</p> ))}
<p className="text-muted-foreground mt-1 text-xs"> {(!userStudies?.studies.length) && (
Contact an admin to request access. <div className="flex flex-col items-center justify-center py-4 text-center">
</p> <Building className="text-muted-foreground/50 mb-2 h-8 w-8" />
<Button size="sm" variant="outline" className="mt-3 w-full"> <p className="text-sm">No studies yet</p>
Request Access <Button variant="link" size="sm" asChild className="mt-1">
</Button> <Link href="/studies/new">Create a study</Link>
</div>
)}
</CardContent>
</Card>
</section>
{/* Data & Privacy */}
<section className="space-y-4">
<div className="flex items-center gap-2 border-b pb-2">
<Download className="text-primary h-5 w-5" />
<h3 className="text-lg font-semibold">Data & Privacy</h3>
</div>
<Card className="border-destructive/10 bg-destructive/5 overflow-hidden">
<CardContent className="space-y-4 pt-6">
<div>
<h4 className="mb-1 text-sm font-semibold">Export Data</h4>
<p className="text-muted-foreground mb-3 text-xs">
Download a copy of your personal data.
</p>
<Button
variant="outline"
size="sm"
className="bg-background w-full"
disabled
>
<Download className="mr-2 h-3 w-3" />
Download Archive
</Button> </Button>
</div> </div>
<Separator className="bg-destructive/10" /> )}
<div> {userStudies && userStudies.studies.length > 5 && (
<h4 className="text-destructive mb-1 text-sm font-semibold"> <Button variant="ghost" size="sm" asChild className="w-full">
Delete Account <Link href="/studies">
</h4> View all {userStudies.studies.length} studies <ChevronRight className="ml-1 h-3 w-3" />
<p className="text-muted-foreground mb-3 text-xs"> </Link>
This action is irreversible. </Button>
</p> )}
<Button </CardContent>
variant="destructive" </Card>
size="sm"
className="w-full"
disabled
>
<Trash2 className="mr-2 h-3 w-3" />
Delete Account
</Button>
</div>
</CardContent>
</Card>
</section>
</div> </div>
</div> </div>
</div> </div>
@@ -206,22 +414,11 @@ function ProfileContent({ user }: { user: ProfileUser }) {
export default function ProfilePage() { export default function ProfilePage() {
const { data: session, isPending } = useSession(); const { data: session, isPending } = useSession();
const { data: userData, isPending: isUserPending } = api.auth.me.useQuery(
undefined,
{
enabled: !!session?.user,
},
);
useBreadcrumbsEffect([ if (isPending) {
{ label: "Dashboard", href: "/dashboard" },
{ label: "Profile" },
]);
if (isPending || isUserPending) {
return ( return (
<div className="text-muted-foreground animate-pulse p-8"> <div className="flex items-center justify-center p-12">
Loading profile... <Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
</div> </div>
); );
} }
@@ -230,13 +427,5 @@ export default function ProfilePage() {
redirect("/auth/signin"); redirect("/auth/signin");
} }
const user: ProfileUser = { return <ProfilePageContent />;
id: session.user.id,
name: userData?.name ?? session.user.name ?? null,
email: userData?.email ?? session.user.email,
image: userData?.image ?? session.user.image ?? null,
roles: userData?.systemRoles as ProfileUser["roles"],
};
return <ProfileContent user={user} />;
} }

View File

@@ -8,6 +8,9 @@ import type {
} from "~/lib/experiment-designer/types"; } from "~/lib/experiment-designer/types";
import { api } from "~/trpc/server"; import { api } from "~/trpc/server";
import { DesignerPageClient } from "./DesignerPageClient"; import { DesignerPageClient } from "./DesignerPageClient";
import { db } from "~/server/db";
import { studyPlugins, plugins } from "~/server/db/schema";
import { desc, eq } from "drizzle-orm";
interface ExperimentDesignerPageProps { interface ExperimentDesignerPageProps {
params: Promise<{ params: Promise<{
@@ -74,10 +77,20 @@ export default async function ExperimentDesignerPage({
actionDefinitions: Array<{ id: string }> | null; actionDefinitions: Array<{ id: string }> | null;
}; };
}; };
const rawInstalledPluginsUnknown: unknown = const installedPluginsResult = await db
await api.robots.plugins.getStudyPlugins({ .select({
studyId: experiment.study.id, plugin: {
}); id: plugins.id,
name: plugins.name,
version: plugins.version,
actionDefinitions: plugins.actionDefinitions,
},
})
.from(studyPlugins)
.innerJoin(plugins, eq(studyPlugins.pluginId, plugins.id))
.where(eq(studyPlugins.studyId, experiment.study.id))
.orderBy(desc(studyPlugins.installedAt));
const rawInstalledPluginsUnknown = installedPluginsResult;
function asRecord(v: unknown): Record<string, unknown> | null { function asRecord(v: unknown): Record<string, unknown> | null {
return v && typeof v === "object" return v && typeof v === "object"

View File

@@ -31,6 +31,8 @@ export default function StudyExperimentsPage() {
} }
}, [studyId, selectedStudyId, setSelectedStudyId]); }, [studyId, selectedStudyId, setSelectedStudyId]);
const canManage = study?.userRole === "owner" || study?.userRole === "researcher";
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <PageHeader
@@ -38,12 +40,14 @@ export default function StudyExperimentsPage() {
description="Design and manage experiment protocols for this study" description="Design and manage experiment protocols for this study"
icon={FlaskConical} icon={FlaskConical}
actions={ actions={
<Button asChild> canManage ? (
<a href={`/studies/${studyId}/experiments/new`}> <Button asChild>
<Plus className="mr-2 h-4 w-4" /> <a href={`/studies/${studyId}/experiments/new`}>
Create Experiment <Plus className="mr-2 h-4 w-4" />
</a> Create Experiment
</Button> </a>
</Button>
) : null
} }
/> />

View File

@@ -0,0 +1,983 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useSession } from "~/lib/auth-client";
import { notFound } from "next/navigation";
import Link from "next/link";
import {
FileText,
ArrowLeft,
Plus,
Trash2,
GripVertical,
FileSignature,
ClipboardList,
FileQuestion,
Save,
Eye,
Edit2,
Users,
CheckCircle,
Printer,
Download,
Pencil,
X,
FileDown,
} from "lucide-react";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Badge } from "~/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { api } from "~/trpc/react";
import { toast } from "sonner";
interface Field {
id: string;
type: string;
label: string;
required: boolean;
options?: string[];
settings?: Record<string, any>;
}
const fieldTypes = [
{ value: "text", label: "Text (short)", icon: "📝" },
{ value: "textarea", label: "Text (long)", icon: "📄" },
{ value: "multiple_choice", label: "Multiple Choice", icon: "☑️" },
{ value: "checkbox", label: "Checkbox", icon: "✅" },
{ value: "rating", label: "Rating Scale", icon: "⭐" },
{ value: "yes_no", label: "Yes/No", icon: "✔️" },
{ value: "date", label: "Date", icon: "📅" },
{ value: "signature", label: "Signature", icon: "✍️" },
];
const formTypeIcons = {
consent: FileSignature,
survey: ClipboardList,
questionnaire: FileQuestion,
};
const statusColors = {
pending: "bg-yellow-100 text-yellow-700",
completed: "bg-green-100 text-green-700",
rejected: "bg-red-100 text-red-700",
};
interface FormViewPageProps {
params: Promise<{
id: string;
formId: string;
}>;
}
export default function FormViewPage({ params }: FormViewPageProps) {
const { data: session } = useSession();
const router = useRouter();
const utils = api.useUtils();
const [resolvedParams, setResolvedParams] = useState<{
id: string;
formId: string;
} | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [isEnteringData, setIsEnteringData] = useState(false);
const [selectedParticipantId, setSelectedParticipantId] =
useState<string>("");
const [formResponses, setFormResponses] = useState<Record<string, any>>({});
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [fields, setFields] = useState<Field[]>([]);
useEffect(() => {
const resolveParams = async () => {
const resolved = await params;
setResolvedParams(resolved);
};
void resolveParams();
}, [params]);
const { data: participants } = api.participants.list.useQuery(
{ studyId: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id && isEnteringData },
);
const { data: study } = api.studies.get.useQuery(
{ id: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id },
);
const { data: form, isLoading } = api.forms.get.useQuery(
{ id: resolvedParams?.formId ?? "" },
{ enabled: !!resolvedParams?.formId },
);
const { data: responsesData } = api.forms.getResponses.useQuery(
{ formId: resolvedParams?.formId ?? "", limit: 50 },
{ enabled: !!resolvedParams?.formId },
);
const userRole = (study as any)?.userRole;
const canManage = userRole === "owner" || userRole === "researcher";
const updateForm = api.forms.update.useMutation({
onSuccess: () => {
toast.success("Form updated successfully!");
setIsEditing(false);
void utils.forms.get.invalidate({ id: resolvedParams?.formId });
},
onError: (error) => {
toast.error("Failed to update form", { description: error.message });
},
});
const submitResponse = api.forms.submitResponse.useMutation({
onSuccess: () => {
toast.success("Response submitted successfully!");
setIsEnteringData(false);
setSelectedParticipantId("");
setFormResponses({});
void utils.forms.getResponses.invalidate({
formId: resolvedParams?.formId,
});
},
onError: (error) => {
toast.error("Failed to submit response", { description: error.message });
},
});
const exportCsv = api.forms.exportCsv.useQuery(
{ formId: resolvedParams?.formId ?? "" },
{ enabled: !!resolvedParams?.formId && canManage },
);
const handleExportCsv = () => {
if (exportCsv.data) {
const blob = new Blob([exportCsv.data.csv], { type: "text/csv" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = exportCsv.data.filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success("CSV exported successfully!");
}
};
const generatePdf = async () => {
if (!study || !form) return;
setIsGeneratingPdf(true);
const { downloadPdfFromHtml } = await import("~/lib/pdf-generator");
const fieldsHtml = fields
.map((field, index) => {
const requiredMark = field.required
? '<span style="color: red">*</span>'
: "";
let inputField = "";
switch (field.type) {
case "text":
inputField =
'<input type="text" style="width: 100%; padding: 8px; border: 1px solid #ccc; margin-top: 4px;" placeholder="________________________" />';
break;
case "textarea":
inputField =
'<textarea style="width: 100%; height: 80px; padding: 8px; border: 1px solid #ccc; margin-top: 4px;" placeholder=""></textarea>';
break;
case "multiple_choice":
inputField = `<div style="margin-top: 4px;">${field.options
?.map((opt) => `<div><input type="checkbox" /> ${opt}</div>`)
.join("")}</div>`;
break;
case "checkbox":
inputField =
'<div style="margin-top: 4px;"><input type="checkbox" /> Yes</div>';
break;
case "yes_no":
inputField =
'<div style="margin-top: 4px;"><input type="radio" name="yn" /> Yes &nbsp; <input type="radio" name="yn" /> No</div>';
break;
case "rating":
const scale = field.settings?.scale || 5;
inputField = `<div style="margin-top: 4px;">${Array.from(
{ length: scale },
(_, i) => `<input type="radio" name="rating" /> ${i + 1} `,
).join("")}</div>`;
break;
case "date":
inputField =
'<input type="text" style="padding: 8px; border: 1px solid #ccc; margin-top: 4px;" placeholder="MM/DD/YYYY" />';
break;
case "signature":
inputField =
'<div style="height: 60px; border: 1px solid #ccc; margin-top: 4px;"></div><div style="font-size: 12px; color: #666; margin-top: 4px;">Signature: _________________________ Date: ____________</div>';
break;
}
return `
<div style="margin-bottom: 16px;">
<p style="margin: 0; font-weight: 500;">${index + 1}. ${field.label} ${requiredMark}</p>
${inputField}
</div>
`;
})
.join(
"<hr style='border: none; border-top: 1px solid #eee; margin: 16px 0;' />",
);
const html = `
<div style="max-width: 800px; margin: 0 auto; padding: 20px;">
<h1 style="margin-bottom: 8px;">${title}</h1>
${description ? `<p style="color: #666; margin-bottom: 24px;">${description}</p>` : ""}
<p style="color: #666; font-size: 12px; margin-bottom: 24px;">
<strong>Study:</strong> ${study?.name || ""} &nbsp;|&nbsp;
<strong>Form Type:</strong> ${form?.type} &nbsp;|&nbsp;
<strong>Version:</strong> ${form?.version}
</p>
<hr style="border: none; border-top: 2px solid #333; margin-bottom: 24px;" />
${fieldsHtml}
<hr style="border: none; border-top: 2px solid #333; margin-top: 24px;" />
<p style="font-size: 10px; color: #999; margin-top: 24px;">
Generated by HRIStudio | ${new Date().toLocaleDateString()}
</p>
</div>
`;
await downloadPdfFromHtml(html, {
filename: `${title.replace(/\s+/g, "_")}_form.pdf`,
});
setIsGeneratingPdf(false);
};
const handleDataEntry = () => {
if (!selectedParticipantId || !form) {
toast.error("Please select a participant");
return;
}
const answers: Record<string, any> = {};
fields.forEach((field) => {
answers[field.id] = formResponses[field.id] ?? "";
});
submitResponse.mutate({
formId: form.id,
participantId: selectedParticipantId,
responses: answers,
});
};
useEffect(() => {
if (form) {
setTitle(form.title);
setDescription(form.description || "");
setFields((form.fields as Field[]) || []);
}
}, [form]);
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
{ label: study?.name ?? "Study", href: `/studies/${resolvedParams?.id}` },
{ label: "Forms", href: `/studies/${resolvedParams?.id}/forms` },
{ label: form?.title ?? "Form" },
]);
if (!session?.user) {
return notFound();
}
if (isLoading || !form) return <div>Loading...</div>;
const TypeIcon =
formTypeIcons[form.type as keyof typeof formTypeIcons] || FileText;
const responses = responsesData?.responses ?? [];
const addField = (type: string) => {
const newField: Field = {
id: crypto.randomUUID(),
type,
label: `New ${fieldTypes.find((f) => f.value === type)?.label || "Field"}`,
required: false,
options:
type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined,
};
setFields([...fields, newField]);
};
const removeField = (id: string) => {
setFields(fields.filter((f) => f.id !== id));
};
const updateField = (id: string, updates: Partial<Field>) => {
setFields(fields.map((f) => (f.id === id ? { ...f, ...updates } : f)));
};
const handleSave = () => {
updateForm.mutate({
id: form.id,
title,
description,
fields,
settings: form.settings as Record<string, any>,
});
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link href={`/studies/${resolvedParams?.id}/forms`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Link>
</Button>
<div>
<div className="flex items-center gap-2">
<TypeIcon className="text-muted-foreground h-5 w-5" />
<h1 className="text-2xl font-bold">{form.title}</h1>
{form.active && (
<Badge variant="default" className="text-xs">
Active
</Badge>
)}
</div>
<p className="text-muted-foreground text-sm capitalize">
{form.type} Version {form.version}
</p>
</div>
</div>
{canManage && (
<div className="flex gap-2">
{isEditing ? (
<>
<Button variant="outline" onClick={() => setIsEditing(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={updateForm.isPending}>
<Save className="mr-2 h-4 w-4" />
Save Changes
</Button>
</>
) : (
<>
<Button
variant="outline"
onClick={generatePdf}
disabled={isGeneratingPdf}
>
<Printer className="mr-2 h-4 w-4" />
{isGeneratingPdf ? "Generating..." : "Print PDF"}
</Button>
<Button onClick={() => setIsEditing(true)}>
<Edit2 className="mr-2 h-4 w-4" />
Edit Form
</Button>
</>
)}
</div>
)}
</div>
<Tabs defaultValue="fields" className="space-y-4">
<TabsList>
<TabsTrigger value="fields">Fields</TabsTrigger>
<TabsTrigger value="preview">Preview</TabsTrigger>
{canManage && (
<TabsTrigger value="data-entry">Data Entry</TabsTrigger>
)}
<TabsTrigger value="responses">
Responses ({responses.length})
</TabsTrigger>
</TabsList>
<TabsContent value="fields">
{isEditing ? (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Form Fields</CardTitle>
<Select onValueChange={addField}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Add field..." />
</SelectTrigger>
<SelectContent>
{fieldTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
<span className="mr-2">{type.icon}</span>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</CardHeader>
<CardContent>
{fields.length === 0 ? (
<div className="text-muted-foreground flex flex-col items-center justify-center py-8 text-center">
<FileText className="mb-2 h-8 w-8" />
<p>No fields added yet</p>
</div>
) : (
<div className="space-y-4">
{fields.map((field) => (
<div
key={field.id}
className="flex items-start gap-3 rounded-lg border p-4"
>
<div className="text-muted-foreground flex cursor-grab items-center">
<GripVertical className="h-5 w-5" />
</div>
<div className="flex-1 space-y-3">
<div className="flex items-center gap-3">
<Badge variant="outline" className="text-xs">
{
fieldTypes.find((f) => f.value === field.type)
?.icon
}{" "}
{
fieldTypes.find((f) => f.value === field.type)
?.label
}
</Badge>
<Input
value={field.label}
onChange={(e) =>
updateField(field.id, { label: e.target.value })
}
placeholder="Field label"
className="flex-1"
/>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={field.required}
onChange={(e) =>
updateField(field.id, {
required: e.target.checked,
})
}
className="rounded border-gray-300"
/>
Required
</label>
</div>
{field.type === "multiple_choice" && (
<div className="space-y-2">
<Label className="text-xs">Options</Label>
{field.options?.map((opt, i) => (
<div
key={i}
className="flex items-center gap-2"
>
<Input
value={opt}
onChange={(e) => {
const newOptions = [
...(field.options || []),
];
newOptions[i] = e.target.value;
updateField(field.id, {
options: newOptions,
});
}}
placeholder={`Option ${i + 1}`}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
const newOptions = field.options?.filter(
(_, idx) => idx !== i,
);
updateField(field.id, {
options: newOptions,
});
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const newOptions = [
...(field.options || []),
`Option ${(field.options?.length || 0) + 1}`,
];
updateField(field.id, {
options: newOptions,
});
}}
>
<Plus className="mr-1 h-4 w-4" />
Add Option
</Button>
</div>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeField(field.id)}
>
<Trash2 className="text-destructive h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle>Form Fields</CardTitle>
</CardHeader>
<CardContent>
{fields.length === 0 ? (
<p className="text-muted-foreground">No fields defined</p>
) : (
<div className="space-y-3">
{fields.map((field, index) => (
<div
key={field.id}
className="flex items-center gap-3 rounded-lg border p-3"
>
<span className="bg-muted flex h-6 w-6 items-center justify-center rounded-full text-xs">
{index + 1}
</span>
<div className="flex-1">
<p className="font-medium">{field.label}</p>
<p className="text-muted-foreground text-xs">
{
fieldTypes.find((f) => f.value === field.type)
?.label
}
{field.required && " • Required"}
{field.type === "multiple_choice" &&
`${field.options?.length} options`}
</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="preview">
<Card>
<CardHeader>
<CardTitle>Form Preview</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<h2 className="text-xl font-semibold">{title}</h2>
{description && (
<p className="text-muted-foreground">{description}</p>
)}
</div>
{fields.length === 0 ? (
<p className="text-muted-foreground">No fields to preview</p>
) : (
<div className="space-y-4">
{fields.map((field, index) => (
<div key={field.id} className="space-y-2">
<Label>
{index + 1}. {field.label}
{field.required && (
<span className="text-destructive"> *</span>
)}
</Label>
{field.type === "text" && (
<Input placeholder="Enter your response..." disabled />
)}
{field.type === "textarea" && (
<Textarea
placeholder="Enter your response..."
disabled
/>
)}
{field.type === "multiple_choice" && (
<div className="space-y-2">
{field.options?.map((opt, i) => (
<label key={i} className="flex items-center gap-2">
<input type="radio" disabled /> {opt}
</label>
))}
</div>
)}
{field.type === "checkbox" && (
<label className="flex items-center gap-2">
<input type="checkbox" disabled /> Yes
</label>
)}
{field.type === "yes_no" && (
<div className="flex gap-4">
<label className="flex items-center gap-2">
<input type="radio" disabled /> Yes
</label>
<label className="flex items-center gap-2">
<input type="radio" disabled /> No
</label>
</div>
)}
{field.type === "rating" && (
<div className="flex gap-2">
{Array.from(
{ length: field.settings?.scale || 5 },
(_, i) => (
<button
key={i}
type="button"
className="disabled h-8 w-8 rounded border"
disabled
>
{i + 1}
</button>
),
)}
</div>
)}
{field.type === "date" && <Input type="date" disabled />}
{field.type === "signature" && (
<div className="bg-muted/50 text-muted-foreground flex h-24 items-center justify-center rounded border">
Signature pad (disabled in preview)
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="data-entry">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Manual Data Entry</CardTitle>
<Button
variant="outline"
size="sm"
onClick={() => {
setIsEnteringData(!isEnteringData);
setSelectedParticipantId("");
setFormResponses({});
}}
>
{isEnteringData ? (
<>
<X className="mr-2 h-4 w-4" />
Cancel
</>
) : (
<>
<Pencil className="mr-2 h-4 w-4" />
Enter Data
</>
)}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{isEnteringData ? (
<>
<div className="space-y-2">
<Label>Select Participant</Label>
<Select
value={selectedParticipantId}
onValueChange={setSelectedParticipantId}
>
<SelectTrigger>
<SelectValue placeholder="Choose a participant..." />
</SelectTrigger>
<SelectContent>
{participants?.participants?.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name || p.participantCode || p.email || p.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedParticipantId && (
<div className="space-y-6 border-t pt-4">
<h3 className="font-semibold">Form Responses</h3>
{fields.map((field, index) => (
<div key={field.id} className="space-y-2">
<Label>
{index + 1}. {field.label}
{field.required && (
<span className="text-destructive"> *</span>
)}
</Label>
{field.type === "text" && (
<Input
value={formResponses[field.id] || ""}
onChange={(e) =>
setFormResponses({
...formResponses,
[field.id]: e.target.value,
})
}
placeholder="Enter response..."
/>
)}
{field.type === "textarea" && (
<Textarea
value={formResponses[field.id] || ""}
onChange={(e) =>
setFormResponses({
...formResponses,
[field.id]: e.target.value,
})
}
placeholder="Enter response..."
/>
)}
{field.type === "multiple_choice" && (
<Select
value={formResponses[field.id] || ""}
onValueChange={(val) =>
setFormResponses({
...formResponses,
[field.id]: val,
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select an option..." />
</SelectTrigger>
<SelectContent>
{field.options?.map((opt, i) => (
<SelectItem key={i} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{field.type === "checkbox" && (
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={formResponses[field.id] || false}
onChange={(e) =>
setFormResponses({
...formResponses,
[field.id]: e.target.checked,
})
}
className="h-4 w-4"
/>
<span>Yes</span>
</div>
)}
{field.type === "yes_no" && (
<Select
value={formResponses[field.id] || ""}
onValueChange={(val) =>
setFormResponses({
...formResponses,
[field.id]: val,
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="yes">Yes</SelectItem>
<SelectItem value="no">No</SelectItem>
</SelectContent>
</Select>
)}
{field.type === "rating" && (
<Select
value={String(formResponses[field.id] || "")}
onValueChange={(val) =>
setFormResponses({
...formResponses,
[field.id]: parseInt(val),
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select rating..." />
</SelectTrigger>
<SelectContent>
{Array.from(
{ length: field.settings?.scale || 5 },
(_, i) => (
<SelectItem key={i} value={String(i + 1)}>
{i + 1}
</SelectItem>
),
)}
</SelectContent>
</Select>
)}
{field.type === "date" && (
<Input
type="date"
value={formResponses[field.id] || ""}
onChange={(e) =>
setFormResponses({
...formResponses,
[field.id]: e.target.value,
})
}
/>
)}
{field.type === "signature" && (
<div className="space-y-2">
<Input
value={formResponses[field.id] || ""}
onChange={(e) =>
setFormResponses({
...formResponses,
[field.id]: e.target.value,
})
}
placeholder="Type name as signature..."
/>
<p className="text-muted-foreground text-xs">
By entering your name above, you confirm that
the information provided is accurate.
</p>
</div>
)}
</div>
))}
<div className="flex justify-end gap-2 border-t pt-4">
<Button
variant="outline"
onClick={() => {
setIsEnteringData(false);
setSelectedParticipantId("");
setFormResponses({});
}}
>
Cancel
</Button>
<Button
onClick={handleDataEntry}
disabled={submitResponse.isPending}
>
<Save className="mr-2 h-4 w-4" />
{submitResponse.isPending
? "Saving..."
: "Save Response"}
</Button>
</div>
</div>
)}
</>
) : (
<div className="text-muted-foreground flex flex-col items-center justify-center py-8 text-center">
<Pencil className="mb-2 h-8 w-8" />
<p>Manual data entry</p>
<p className="text-sm">
Enter responses directly for participants who completed the
form on paper
</p>
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="responses">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Form Responses</CardTitle>
{canManage && responses.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={handleExportCsv}
disabled={exportCsv.isFetching}
>
<FileDown className="mr-2 h-4 w-4" />
{exportCsv.isFetching ? "Exporting..." : "Export CSV"}
</Button>
)}
</CardHeader>
<CardContent>
{responses.length === 0 ? (
<div className="text-muted-foreground flex flex-col items-center justify-center py-8 text-center">
<Users className="mb-2 h-8 w-8" />
<p>No responses yet</p>
</div>
) : (
<div className="space-y-4">
{responses.map((response) => (
<div key={response.id} className="rounded-lg border p-4">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="text-muted-foreground h-4 w-4" />
<span className="font-medium">
{response.participant?.name ||
response.participant?.participantCode ||
"Unknown"}
</span>
</div>
<Badge
className={`text-xs ${statusColors[response.status as keyof typeof statusColors]}`}
>
{response.status}
</Badge>
</div>
<div className="space-y-2 text-sm">
{Object.entries(
response.responses as Record<string, any>,
).map(([key, value]) => (
<div key={key} className="flex gap-2">
<span className="text-muted-foreground">
{key}:
</span>
<span>{String(value)}</span>
</div>
))}
</div>
{response.signedAt && (
<div className="text-muted-foreground mt-2 border-t pt-2 text-xs">
Signed: {new Date(response.signedAt).toLocaleString()}
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,410 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useSession } from "~/lib/auth-client";
import { notFound } from "next/navigation";
import Link from "next/link";
import {
FileText,
ArrowLeft,
Plus,
Trash2,
GripVertical,
FileSignature,
ClipboardList,
FileQuestion,
Save,
Copy,
LayoutTemplate,
} from "lucide-react";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Badge } from "~/components/ui/badge";
import { api } from "~/trpc/react";
import { toast } from "sonner";
interface Field {
id: string;
type: string;
label: string;
required: boolean;
options?: string[];
settings?: Record<string, any>;
}
const fieldTypes = [
{ value: "text", label: "Text (short)", icon: "📝" },
{ value: "textarea", label: "Text (long)", icon: "📄" },
{ value: "multiple_choice", label: "Multiple Choice", icon: "☑️" },
{ value: "checkbox", label: "Checkbox", icon: "✅" },
{ value: "rating", label: "Rating Scale", icon: "⭐" },
{ value: "yes_no", label: "Yes/No", icon: "✔️" },
{ value: "date", label: "Date", icon: "📅" },
{ value: "signature", label: "Signature", icon: "✍️" },
];
const formTypes = [
{ value: "consent", label: "Consent Form", icon: FileSignature, description: "Legal/IRB consent documents" },
{ value: "survey", label: "Survey", icon: ClipboardList, description: "Multi-question questionnaires" },
{ value: "questionnaire", label: "Questionnaire", icon: FileQuestion, description: "Custom data collection forms" },
];
export default function NewFormPage() {
const params = useParams();
const router = useRouter();
const { data: session } = useSession();
const utils = api.useUtils();
const studyId = typeof params.id === "string" ? params.id : "";
const [formType, setFormType] = useState<string>("");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [fields, setFields] = useState<Field[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const { data: study } = api.studies.get.useQuery(
{ id: studyId },
{ enabled: !!studyId },
);
const { data: templates } = api.forms.listTemplates.useQuery();
const createFromTemplate = api.forms.createFromTemplate.useMutation({
onSuccess: (data) => {
toast.success("Form created from template!");
router.push(`/studies/${studyId}/forms/${data.id}`);
},
onError: (error) => {
toast.error("Failed to create from template", { description: error.message });
},
});
const createForm = api.forms.create.useMutation({
onSuccess: (data) => {
toast.success("Form created successfully!");
router.push(`/studies/${studyId}/forms/${data.id}`);
},
onError: (error) => {
toast.error("Failed to create form", { description: error.message });
setIsSubmitting(false);
},
});
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
{ label: study?.name ?? "Study", href: `/studies/${studyId}` },
{ label: "Forms", href: `/studies/${studyId}/forms` },
{ label: "Create Form" },
]);
if (!session?.user) {
return notFound();
}
const addField = (type: string) => {
const newField: Field = {
id: crypto.randomUUID(),
type,
label: `New ${fieldTypes.find(f => f.value === type)?.label || "Field"}`,
required: false,
options: type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined,
};
setFields([...fields, newField]);
};
const removeField = (id: string) => {
setFields(fields.filter(f => f.id !== id));
};
const updateField = (id: string, updates: Partial<Field>) => {
setFields(fields.map(f => f.id === id ? { ...f, ...updates } : f));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formType || !title) {
toast.error("Please select a form type and enter a title");
return;
}
setIsSubmitting(true);
createForm.mutate({
studyId,
type: formType as "consent" | "survey" | "questionnaire",
title,
description,
fields,
settings: {},
});
};
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link href={`/studies/${studyId}/forms`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Link>
</Button>
</div>
<div>
<h1 className="text-2xl font-bold">Create New Form</h1>
<p className="text-muted-foreground">Design a consent form, survey, or questionnaire</p>
</div>
{templates && templates.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<LayoutTemplate className="h-5 w-5" />
Start from Template
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-3">
{templates.map((template) => {
const TypeIcon = formTypes.find(t => t.value === template.type)?.icon || FileText;
return (
<button
key={template.id}
type="button"
onClick={() => {
createFromTemplate.mutate({
studyId,
templateId: template.id,
});
}}
disabled={createFromTemplate.isPending}
className="flex flex-col items-start rounded-lg border p-4 text-left transition-all hover:bg-muted/50 disabled:opacity-50"
>
<TypeIcon className="mb-2 h-5 w-5 text-muted-foreground" />
<span className="font-medium">{template.templateName}</span>
<span className="text-muted-foreground text-xs capitalize">{template.type}</span>
<span className="text-muted-foreground text-xs mt-1 line-clamp-2">
{template.description}
</span>
</button>
);
})}
</div>
<div className="mt-4 text-center text-sm text-muted-foreground">
Or design from scratch below
</div>
</CardContent>
</Card>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Form Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Form Type</Label>
<div className="grid gap-3 sm:grid-cols-3">
{formTypes.map((type) => (
<button
key={type.value}
type="button"
onClick={() => setFormType(type.value)}
className={`flex flex-col items-start rounded-lg border p-4 text-left transition-all hover:bg-muted/50 ${
formType === type.value
? "border-primary bg-primary/5 ring-1 ring-primary"
: "border-border"
}`}
>
<type.icon className={`mb-2 h-5 w-5 ${formType === type.value ? "text-primary" : "text-muted-foreground"}`} />
<span className="font-medium">{type.label}</span>
<span className="text-muted-foreground text-xs">{type.description}</span>
</button>
))}
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter form title"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description (optional)</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description"
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Form Fields</CardTitle>
<Select onValueChange={addField}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Add field..." />
</SelectTrigger>
<SelectContent>
{fieldTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
<span className="mr-2">{type.icon}</span>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</CardHeader>
<CardContent>
{fields.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
<FileText className="mb-2 h-8 w-8" />
<p>No fields added yet</p>
<p className="text-sm">Use the dropdown above to add fields</p>
</div>
) : (
<div className="space-y-4">
{fields.map((field, index) => (
<div
key={field.id}
className="flex items-start gap-3 rounded-lg border p-4"
>
<div className="flex cursor-grab items-center text-muted-foreground">
<GripVertical className="h-5 w-5" />
</div>
<div className="flex-1 space-y-3">
<div className="flex items-center gap-3">
<Badge variant="outline" className="text-xs">
{fieldTypes.find(f => f.value === field.type)?.icon}{" "}
{fieldTypes.find(f => f.value === field.type)?.label}
</Badge>
<Input
value={field.label}
onChange={(e) => updateField(field.id, { label: e.target.value })}
placeholder="Field label"
className="flex-1"
/>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={field.required}
onChange={(e) => updateField(field.id, { required: e.target.checked })}
className="rounded border-gray-300"
/>
Required
</label>
</div>
{field.type === "multiple_choice" && (
<div className="space-y-2">
<Label className="text-xs">Options</Label>
{field.options?.map((opt, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={opt}
onChange={(e) => {
const newOptions = [...(field.options || [])];
newOptions[i] = e.target.value;
updateField(field.id, { options: newOptions });
}}
placeholder={`Option ${i + 1}`}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
const newOptions = field.options?.filter((_, idx) => idx !== i);
updateField(field.id, { options: newOptions });
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const newOptions = [...(field.options || []), `Option ${(field.options?.length || 0) + 1}`];
updateField(field.id, { options: newOptions });
}}
>
<Plus className="mr-1 h-4 w-4" />
Add Option
</Button>
</div>
)}
{field.type === "rating" && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Scale:</span>
<Select
value={field.settings?.scale?.toString() || "5"}
onValueChange={(val) => updateField(field.id, { settings: { scale: parseInt(val) } })}
>
<SelectTrigger className="w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">1-5</SelectItem>
<SelectItem value="7">1-7</SelectItem>
<SelectItem value="10">1-10</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeField(field.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
<div className="flex justify-end gap-3">
<Button variant="outline" asChild>
<Link href={`/studies/${studyId}/forms`}>Cancel</Link>
</Button>
<Button type="submit" disabled={isSubmitting || !formType || !title}>
<Save className="mr-2 h-4 w-4" />
{isSubmitting ? "Creating..." : "Create Form"}
</Button>
</div>
</form>
</div>
);
}

View File

@@ -1,131 +1,49 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useSession } from "~/lib/auth-client"; import { useSession } from "~/lib/auth-client";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link";
import { import {
FileText, FileText,
Loader2,
Plus, Plus,
Download, Search,
Edit2, ClipboardList,
FileQuestion,
FileSignature,
MoreHorizontal,
Trash2,
Eye, Eye,
Save, CheckCircle,
} from "lucide-react"; } from "lucide-react";
import {
EntityView,
EntityViewHeader,
EntityViewSection,
EmptyState,
} from "~/components/ui/entity-view";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider"; import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
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 { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { toast } from "sonner"; import { toast } from "sonner";
import { PageHeader } from "~/components/ui/page-header"; import { PageHeader } from "~/components/ui/page-header";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "tiptap-markdown";
import { Table } from "@tiptap/extension-table";
import { TableRow } from "@tiptap/extension-table-row";
import { TableCell } from "@tiptap/extension-table-cell";
import { TableHeader } from "@tiptap/extension-table-header";
import {
Bold,
Italic,
List,
ListOrdered,
Heading1,
Heading2,
Quote,
Table as TableIcon,
} from "lucide-react";
import { downloadPdfFromHtml } from "~/lib/pdf-generator";
const Toolbar = ({ editor }: { editor: any }) => { const formTypeIcons = {
if (!editor) { consent: FileSignature,
return null; survey: ClipboardList,
} questionnaire: FileQuestion,
};
return ( const formTypeColors = {
<div className="border-input flex flex-wrap items-center gap-1 rounded-tl-md rounded-tr-md border bg-transparent p-1"> consent:
<Button "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400",
variant="ghost" survey: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
size="sm" questionnaire:
onClick={() => editor.chain().focus().toggleBold().run()} "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400",
disabled={!editor.can().chain().focus().toggleBold().run()}
className={editor.isActive("bold") ? "bg-muted" : ""}
>
<Bold className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleItalic().run()}
disabled={!editor.can().chain().focus().toggleItalic().run()}
className={editor.isActive("italic") ? "bg-muted" : ""}
>
<Italic className="h-4 w-4" />
</Button>
<div className="bg-border mx-1 h-6 w-[1px]" />
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={editor.isActive("heading", { level: 1 }) ? "bg-muted" : ""}
>
<Heading1 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={editor.isActive("heading", { level: 2 }) ? "bg-muted" : ""}
>
<Heading2 className="h-4 w-4" />
</Button>
<div className="bg-border mx-1 h-6 w-[1px]" />
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={editor.isActive("bulletList") ? "bg-muted" : ""}
>
<List className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={editor.isActive("orderedList") ? "bg-muted" : ""}
>
<ListOrdered className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={editor.isActive("blockquote") ? "bg-muted" : ""}
>
<Quote className="h-4 w-4" />
</Button>
<div className="bg-border mx-1 h-6 w-[1px]" />
<Button
variant="ghost"
size="sm"
onClick={() =>
editor
.chain()
.focus()
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run()
}
>
<TableIcon className="h-4 w-4" />
</Button>
</div>
);
}; };
interface StudyFormsPageProps { interface StudyFormsPageProps {
@@ -136,11 +54,12 @@ interface StudyFormsPageProps {
export default function StudyFormsPage({ params }: StudyFormsPageProps) { export default function StudyFormsPage({ params }: StudyFormsPageProps) {
const { data: session } = useSession(); const { data: session } = useSession();
const router = useRouter();
const utils = api.useUtils(); const utils = api.useUtils();
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>( const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(
null, null,
); );
const [editorTarget, setEditorTarget] = useState<string>(""); const [search, setSearch] = useState("");
useEffect(() => { useEffect(() => {
const resolveParams = async () => { const resolveParams = async () => {
@@ -155,91 +74,33 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
{ enabled: !!resolvedParams?.id }, { enabled: !!resolvedParams?.id },
); );
const { data: activeConsentForm, refetch: refetchConsentForm } = const { data: formsData, isLoading } = api.forms.list.useQuery(
api.studies.getActiveConsentForm.useQuery( { studyId: resolvedParams?.id ?? "", search: search || undefined },
{ studyId: resolvedParams?.id ?? "" }, { enabled: !!resolvedParams?.id },
{ enabled: !!resolvedParams?.id }, );
);
// Only sync once when form loads to avoid resetting user edits const userRole = (study as any)?.userRole;
useEffect(() => { const canManage = userRole === "owner" || userRole === "researcher";
if (activeConsentForm && !editorTarget) {
setEditorTarget(activeConsentForm.content);
}
}, [activeConsentForm, editorTarget]);
const editor = useEditor({ const deleteMutation = api.forms.delete.useMutation({
extensions: [
StarterKit,
Table.configure({
resizable: true,
}),
TableRow,
TableHeader,
TableCell,
Markdown.configure({
transformPastedText: true,
}),
],
content: editorTarget || "",
immediatelyRender: false,
onUpdate: ({ editor }) => {
// @ts-ignore
setEditorTarget(editor.storage.markdown.getMarkdown());
},
});
// Sync Tiptap when editorTarget is set (e.g., from DB) but make sure not to overwrite active edits
useEffect(() => {
if (editor && editorTarget && editor.isEmpty) {
editor.commands.setContent(editorTarget);
}
}, [editorTarget, editor]);
const generateConsentMutation = api.studies.generateConsentForm.useMutation({
onSuccess: (data) => {
toast.success("Default Consent Form Generated!");
setEditorTarget(data.content);
editor?.commands.setContent(data.content);
void refetchConsentForm();
void utils.studies.getActivity.invalidate({
studyId: resolvedParams?.id ?? "",
});
},
onError: (error) => {
toast.error("Error generating consent form", {
description: error.message,
});
},
});
const updateConsentMutation = api.studies.updateConsentForm.useMutation({
onSuccess: () => { onSuccess: () => {
toast.success("Consent Form Saved Successfully!"); toast.success("Form deleted successfully");
void refetchConsentForm(); void utils.forms.list.invalidate({ studyId: resolvedParams?.id });
void utils.studies.getActivity.invalidate({
studyId: resolvedParams?.id ?? "",
});
}, },
onError: (error) => { onError: (error) => {
toast.error("Error saving consent form", { description: error.message }); toast.error("Failed to delete form", { description: error.message });
}, },
}); });
const handleDownloadConsent = async () => { const setActiveMutation = api.forms.setActive.useMutation({
if (!activeConsentForm || !study || !editor) return; onSuccess: () => {
toast.success("Form set as active");
try { void utils.forms.list.invalidate({ studyId: resolvedParams?.id });
toast.loading("Generating Document...", { id: "pdf-gen" }); },
await downloadPdfFromHtml(editor.getHTML(), { onError: (error) => {
filename: `Consent_Form_${study.name.replace(/\s+/g, "_")}_v${activeConsentForm.version}.pdf`, toast.error("Failed to set active", { description: error.message });
}); },
toast.success("Document Downloaded Successfully!", { id: "pdf-gen" }); });
} catch (error) {
toast.error("Error generating PDF", { id: "pdf-gen" });
console.error(error);
}
};
useBreadcrumbsEffect([ useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" }, { label: "Dashboard", href: "/dashboard" },
@@ -254,116 +115,156 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
if (!study) return <div>Loading...</div>; if (!study) return <div>Loading...</div>;
const forms = formsData?.forms ?? [];
return ( return (
<EntityView> <div className="space-y-6">
<PageHeader <PageHeader
title="Study Forms" title="Forms"
description="Manage consent forms and future questionnaires for this study" description="Manage consent forms, surveys, and questionnaires for this study"
icon={FileText} icon={FileText}
actions={
canManage && (
<Button asChild>
<Link href={`/studies/${resolvedParams?.id}/forms/new`}>
<Plus className="mr-2 h-4 w-4" />
Create Form
</Link>
</Button>
)
}
/> />
<div className="grid grid-cols-1 gap-8"> {forms.length === 0 && !isLoading ? (
<EntityViewSection <div className="flex flex-col items-center justify-center py-12 text-center">
title="Consent Document" <FileText className="text-muted-foreground mb-4 h-12 w-12" />
icon="FileText" <h3 className="mb-2 text-lg font-semibold">No Forms Yet</h3>
description="Design and manage the consent form that participants must sign before participating in your trials." <p className="text-muted-foreground mb-4">
actions={ Create consent forms, surveys, or questionnaires to collect data
<div className="flex gap-2"> from participants
<Button </p>
variant="outline" {canManage && (
size="sm" <Button asChild>
onClick={() => <Link href={`/studies/${resolvedParams?.id}/forms/new`}>
generateConsentMutation.mutate({ studyId: study.id }) <Plus className="mr-2 h-4 w-4" />
} Create Your First Form
disabled={ </Link>
generateConsentMutation.isPending || </Button>
updateConsentMutation.isPending
}
>
{generateConsentMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
Generate Default Template
</Button>
{activeConsentForm && (
<Button
size="sm"
onClick={() =>
updateConsentMutation.mutate({
studyId: study.id,
content: editorTarget,
})
}
disabled={
updateConsentMutation.isPending ||
editorTarget === activeConsentForm.content
}
>
{updateConsentMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Changes
</Button>
)}
</div>
}
>
{activeConsentForm ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm leading-none font-medium">
{activeConsentForm.title}
</p>
<p className="text-muted-foreground text-sm">
v{activeConsentForm.version} Status: Active
</p>
</div>
<div className="flex items-center gap-3">
<Button
size="sm"
variant="ghost"
onClick={handleDownloadConsent}
>
<Download className="mr-2 h-4 w-4" />
Download PDF
</Button>
<Badge
variant="outline"
className="bg-green-50 text-green-700 hover:bg-green-50"
>
Active
</Badge>
</div>
</div>
<div className="bg-muted/30 border-border flex w-full justify-center overflow-hidden rounded-md border p-8">
<div className="dark:bg-card ring-border flex w-full max-w-4xl flex-col rounded-sm bg-white shadow-xl ring-1">
<div className="border-border bg-muted/50 dark:bg-muted/10 border-b">
<Toolbar editor={editor} />
</div>
<div className="editor-container dark:bg-card min-h-[850px] bg-white px-16 py-20 text-sm">
<EditorContent
editor={editor}
className="prose prose-sm dark:prose-invert h-full max-w-none outline-none focus:outline-none focus-visible:outline-none"
/>
</div>
</div>
</div>
</div>
) : (
<EmptyState
icon="FileText"
title="No Consent Form"
description="Generate a boilerplate consent form for this study to download and collect signatures."
/>
)} )}
</EntityViewSection> </div>
</div> ) : (
</EntityView> <div className="space-y-4">
<div className="flex items-center gap-4">
<div className="relative max-w-sm flex-1">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="Search forms..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{forms.map((form) => {
const TypeIcon =
formTypeIcons[form.type as keyof typeof formTypeIcons] ||
FileText;
const typeColor =
formTypeColors[form.type as keyof typeof formTypeColors] ||
"bg-gray-100";
const isActive = form.active;
return (
<Card key={form.id} className="overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className={`rounded-md p-2 ${typeColor}`}>
<TypeIcon className="h-4 w-4" />
</div>
<div>
<CardTitle className="text-base">
{form.title}
</CardTitle>
<p className="text-muted-foreground text-xs capitalize">
{form.type}
</p>
</div>
</div>
{isActive && (
<Badge variant="default" className="text-xs">
Active
</Badge>
)}
</div>
</CardHeader>
<CardContent className="pb-3">
{form.description && (
<p className="text-muted-foreground mb-3 line-clamp-2 text-sm">
{form.description}
</p>
)}
<div className="text-muted-foreground flex items-center justify-between text-xs">
<span>v{form.version}</span>
<span>
{(form as any)._count?.responses ?? 0} responses
</span>
</div>
</CardContent>
<div className="bg-muted/30 flex items-center justify-between border-t px-4 py-2">
<Button asChild variant="ghost" size="sm">
<Link
href={`/studies/${resolvedParams?.id}/forms/${form.id}`}
>
<Eye className="mr-1 h-3 w-3" />
View
</Link>
</Button>
{canManage && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!isActive && (
<DropdownMenuItem
onClick={() =>
setActiveMutation.mutate({ id: form.id })
}
>
<CheckCircle className="mr-2 h-4 w-4" />
Set Active
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => {
if (
confirm(
"Are you sure you want to delete this form?",
)
) {
deleteMutation.mutate({ id: form.id });
}
}}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</Card>
);
})}
</div>
</div>
)}
</div>
); );
} }

View File

@@ -20,6 +20,7 @@ import { PageHeader } from "~/components/ui/page-header";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider"; import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { useSession } from "~/lib/auth-client"; import { useSession } from "~/lib/auth-client";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { AddMemberDialog } from "~/components/studies/add-member-dialog";
interface StudyDetailPageProps { interface StudyDetailPageProps {
params: Promise<{ params: Promise<{
@@ -59,6 +60,7 @@ type Study = {
irbProtocol: string | null; irbProtocol: string | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
userRole?: string;
}; };
type Member = { type Member = {
@@ -156,6 +158,10 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
).length; ).length;
const totalTrials = trials.length; const totalTrials = trials.length;
const userRole = (studyData as any)?.userRole;
const canManage = userRole === "owner" || userRole === "researcher";
const canRunTrials = userRole === "owner" || userRole === "researcher" || userRole === "wizard";
const stats = { const stats = {
experiments: experiments.length, experiments: experiments.length,
totalTrials: totalTrials, totalTrials: totalTrials,
@@ -181,18 +187,22 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
]} ]}
actions={ actions={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button asChild variant="outline"> {canManage && (
<Link href={`/studies/${study.id}/edit`}> <Button asChild variant="outline">
<Settings className="mr-2 h-4 w-4" /> <Link href={`/studies/${study.id}/edit`}>
Edit Study <Settings className="mr-2 h-4 w-4" />
</Link> Edit Study
</Button> </Link>
<Button asChild> </Button>
<Link href={`/studies/${study.id}/experiments/new`}> )}
<Plus className="mr-2 h-4 w-4" /> {canManage && (
New Experiment <Button asChild>
</Link> <Link href={`/studies/${study.id}/experiments/new`}>
</Button> <Plus className="mr-2 h-4 w-4" />
New Experiment
</Link>
</Button>
)}
</div> </div>
} }
/> />
@@ -234,12 +244,14 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
icon="FlaskConical" icon="FlaskConical"
description="Design and manage experimental protocols for this study" description="Design and manage experimental protocols for this study"
actions={ actions={
<Button asChild variant="outline" size="sm"> canManage ? (
<Link href={`/studies/${study.id}/experiments/new`}> <Button asChild variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" /> <Link href={`/studies/${study.id}/experiments/new`}>
Add Experiment <Plus className="mr-2 h-4 w-4" />
</Link> Add Experiment
</Button> </Link>
</Button>
) : null
} }
> >
{experiments.length === 0 ? ( {experiments.length === 0 ? (
@@ -391,10 +403,14 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
icon="Users" icon="Users"
description={`${members.length} team member${members.length !== 1 ? "s" : ""}`} description={`${members.length} team member${members.length !== 1 ? "s" : ""}`}
actions={ actions={
<Button variant="outline" size="sm"> canManage ? (
<Plus className="mr-2 h-4 w-4" /> <AddMemberDialog studyId={study.id}>
Invite <Button variant="outline" size="sm">
</Button> <Plus className="mr-2 h-4 w-4" />
Manage
</Button>
</AddMemberDialog>
) : null
} }
> >
<div className="space-y-3"> <div className="space-y-3">

View File

@@ -31,6 +31,8 @@ export default function StudyParticipantsPage() {
} }
}, [studyId, selectedStudyId, setSelectedStudyId]); }, [studyId, selectedStudyId, setSelectedStudyId]);
const canManage = study?.userRole === "owner" || study?.userRole === "researcher";
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <PageHeader
@@ -38,12 +40,14 @@ export default function StudyParticipantsPage() {
description="Manage participant registration, consent, and trial assignments for this study" description="Manage participant registration, consent, and trial assignments for this study"
icon={Users} icon={Users}
actions={ actions={
<Button asChild> canManage ? (
<a href={`/studies/${studyId}/participants/new`}> <Button asChild>
<Plus className="mr-2 h-4 w-4" /> <a href={`/studies/${studyId}/participants/new`}>
Add Participant <Plus className="mr-2 h-4 w-4" />
</a> Add Participant
</Button> </a>
</Button>
) : null
} }
/> />

View File

@@ -32,6 +32,8 @@ export default function StudyTrialsPage() {
} }
}, [studyId, selectedStudyId, setSelectedStudyId]); }, [studyId, selectedStudyId, setSelectedStudyId]);
const canRun = ["owner", "researcher", "wizard"].includes(study?.userRole ?? "");
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <PageHeader
@@ -39,12 +41,14 @@ export default function StudyTrialsPage() {
description="Manage trial execution, scheduling, and data collection for this study" description="Manage trial execution, scheduling, and data collection for this study"
icon={TestTube} icon={TestTube}
actions={ actions={
<Button asChild> canRun ? (
<Link href={`/studies/${studyId}/trials/new`}> <Button asChild>
<Plus className="mr-2 h-4 w-4" /> <Link href={`/studies/${studyId}/trials/new`}>
Schedule Trial <Plus className="mr-2 h-4 w-4" />
</Link> Schedule Trial
</Button> </Link>
</Button>
) : null
} }
/> />

View File

@@ -0,0 +1,430 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import {
FileText,
FileSignature,
ClipboardList,
FileQuestion,
CheckCircle,
AlertCircle,
Loader2,
} from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import { api } from "~/trpc/react";
import { toast } from "sonner";
interface Field {
id: string;
type: string;
label: string;
required: boolean;
options?: string[];
settings?: Record<string, any>;
}
const formTypeIcons = {
consent: FileSignature,
survey: ClipboardList,
questionnaire: FileQuestion,
};
export default function ParticipantFormPage() {
const params = useParams();
const searchParams = useSearchParams();
const formId = params.formId as string;
const [participantCode, setParticipantCode] = useState("");
const [formResponses, setFormResponses] = useState<Record<string, any>>({});
const [hasSubmitted, setHasSubmitted] = useState(false);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const { data: form, isLoading: formLoading } = api.forms.getPublic.useQuery(
{ id: formId },
{ enabled: !!formId },
);
const submitResponse = api.forms.submitPublic.useMutation({
onSuccess: () => {
toast.success("Response submitted successfully!");
setHasSubmitted(true);
},
onError: (error: { message: string }) => {
toast.error("Submission failed", { description: error.message });
},
});
useEffect(() => {
const code = searchParams.get("code");
if (code) {
setParticipantCode(code);
}
}, [searchParams]);
if (formLoading) {
return (
<div className="bg-background flex min-h-[60vh] items-center justify-center">
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
</div>
);
}
if (!form) {
return (
<div className="bg-background flex min-h-[60vh] flex-col items-center justify-center text-center">
<AlertCircle className="text-destructive mb-4 h-12 w-12" />
<h1 className="text-2xl font-bold">Form Not Found</h1>
<p className="text-muted-foreground mt-2">
This form may have been removed or the link is invalid.
</p>
</div>
);
}
if (hasSubmitted) {
return (
<div className="bg-background flex min-h-[60vh] flex-col items-center justify-center text-center">
<div className="mb-4 rounded-full bg-green-100 p-4">
<CheckCircle className="h-12 w-12 text-green-600" />
</div>
<h1 className="text-2xl font-bold text-green-600">Thank You!</h1>
<p className="text-muted-foreground mt-2 max-w-md">
Your response has been submitted successfully.
{form.type === "consent" && " Please proceed with your session."}
</p>
<Button variant="outline" className="mt-6" asChild>
<Link href="/">Return Home</Link>
</Button>
</div>
);
}
const TypeIcon =
formTypeIcons[form.type as keyof typeof formTypeIcons] || FileText;
const fields = (form.fields as Field[]) || [];
const validateForm = (): boolean => {
const errors: Record<string, string> = {};
let isValid = true;
fields.forEach((field) => {
if (field.required) {
const value = formResponses[field.id];
if (
value === undefined ||
value === null ||
value === "" ||
(typeof value === "string" && value.trim() === "")
) {
errors[field.id] = "This field is required";
isValid = false;
}
}
});
setFieldErrors(errors);
return isValid;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!participantCode.trim()) {
toast.error("Please enter your participant code");
return;
}
if (!validateForm()) {
toast.error("Please fill in all required fields");
return;
}
submitResponse.mutate({
formId,
participantCode: participantCode.trim(),
responses: formResponses,
});
};
const updateResponse = (fieldId: string, value: any) => {
setFormResponses({ ...formResponses, [fieldId]: value });
if (fieldErrors[fieldId]) {
const newErrors = { ...fieldErrors };
delete newErrors[fieldId];
setFieldErrors(newErrors);
}
};
return (
<div className="bg-background min-h-screen py-8">
<div className="mx-auto max-w-2xl px-4">
<div className="mb-8 text-center">
<div className="bg-primary/10 mb-4 inline-flex rounded-full p-3">
<TypeIcon className="text-primary h-8 w-8" />
</div>
<h1 className="text-3xl font-bold">{form.title}</h1>
{form.description && (
<p className="text-muted-foreground mt-3 text-lg">
{form.description}
</p>
)}
</div>
<Card>
<CardHeader>
<CardTitle className="text-lg">
{form.type === "consent"
? "Consent Form"
: form.type === "survey"
? "Survey"
: "Questionnaire"}
</CardTitle>
<CardDescription>
Fields marked with <span className="text-destructive">*</span> are
required
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="participantCode">
Participant Code <span className="text-destructive">*</span>
</Label>
<Input
id="participantCode"
value={participantCode}
onChange={(e) => setParticipantCode(e.target.value)}
placeholder="Enter your participant code (e.g., P001)"
required
/>
<p className="text-muted-foreground text-xs">
Enter the participant code provided by the researcher
</p>
</div>
<div className="border-t pt-6">
{fields.map((field, index) => (
<div key={field.id} className="mb-6 last:mb-0">
<Label
htmlFor={field.id}
className={
fieldErrors[field.id] ? "text-destructive" : ""
}
>
{index + 1}. {field.label}
{field.required && (
<span className="text-destructive"> *</span>
)}
</Label>
<div className="mt-2">
{field.type === "text" && (
<Input
id={field.id}
value={formResponses[field.id] || ""}
onChange={(e) =>
updateResponse(field.id, e.target.value)
}
placeholder="Enter your response..."
className={
fieldErrors[field.id] ? "border-destructive" : ""
}
/>
)}
{field.type === "textarea" && (
<Textarea
id={field.id}
value={formResponses[field.id] || ""}
onChange={(e) =>
updateResponse(field.id, e.target.value)
}
placeholder="Enter your response..."
className={
fieldErrors[field.id] ? "border-destructive" : ""
}
/>
)}
{field.type === "multiple_choice" && (
<div
className={`mt-2 space-y-2 ${fieldErrors[field.id] ? "border-destructive rounded-md border p-2" : ""}`}
>
{field.options?.map((opt, i) => (
<label
key={i}
className="flex cursor-pointer items-center gap-2"
>
<input
type="radio"
name={field.id}
value={opt}
checked={formResponses[field.id] === opt}
onChange={() => updateResponse(field.id, opt)}
className="h-4 w-4"
/>
<span className="text-sm">{opt}</span>
</label>
))}
</div>
)}
{field.type === "checkbox" && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id={field.id}
checked={formResponses[field.id] || false}
onChange={(e) =>
updateResponse(field.id, e.target.checked)
}
className="h-4 w-4 rounded border-gray-300"
/>
<Label
htmlFor={field.id}
className="cursor-pointer font-normal"
>
Yes, I agree
</Label>
</div>
)}
{field.type === "yes_no" && (
<div className="mt-2 flex gap-4">
<label className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name={field.id}
value="yes"
checked={formResponses[field.id] === "yes"}
onChange={() => updateResponse(field.id, "yes")}
className="h-4 w-4"
/>
<span className="text-sm">Yes</span>
</label>
<label className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name={field.id}
value="no"
checked={formResponses[field.id] === "no"}
onChange={() => updateResponse(field.id, "no")}
className="h-4 w-4"
/>
<span className="text-sm">No</span>
</label>
</div>
)}
{field.type === "rating" && (
<div className="mt-2 flex flex-wrap gap-2">
{Array.from(
{ length: field.settings?.scale || 5 },
(_, i) => (
<label key={i} className="cursor-pointer">
<input
type="radio"
name={field.id}
value={String(i + 1)}
checked={formResponses[field.id] === i + 1}
onChange={() =>
updateResponse(field.id, i + 1)
}
className="peer sr-only"
/>
<span className="hover:bg-muted peer-checked:bg-primary peer-checked:text-primary-foreground flex h-10 w-10 items-center justify-center rounded-full border text-sm font-medium transition-colors">
{i + 1}
</span>
</label>
),
)}
</div>
)}
{field.type === "date" && (
<Input
type="date"
id={field.id}
value={formResponses[field.id] || ""}
onChange={(e) =>
updateResponse(field.id, e.target.value)
}
className={
fieldErrors[field.id] ? "border-destructive" : ""
}
/>
)}
{field.type === "signature" && (
<div className="space-y-2">
<Input
id={field.id}
value={formResponses[field.id] || ""}
onChange={(e) =>
updateResponse(field.id, e.target.value)
}
placeholder="Type your full name as signature"
className={
fieldErrors[field.id] ? "border-destructive" : ""
}
/>
<p className="text-muted-foreground text-xs">
By entering your name above, you confirm that the
information provided is accurate.
</p>
</div>
)}
</div>
{fieldErrors[field.id] && (
<p className="text-destructive mt-1 text-sm">
{fieldErrors[field.id]}
</p>
)}
</div>
))}
</div>
<div className="border-t pt-6">
<Button
type="submit"
size="lg"
className="w-full"
disabled={submitResponse.isPending}
>
{submitResponse.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Submitting...
</>
) : (
<>
<CheckCircle className="mr-2 h-4 w-4" />
Submit Response
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
<p className="text-muted-foreground mt-6 text-center text-sm">
Powered by HRIStudio
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,118 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "~/lib/auth";
import { db } from "~/server/db";
import { studyMembers } from "~/server/db/schema";
import { and, eq } from "drizzle-orm";
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
export async function POST(request: NextRequest) {
try {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const { action, studyId, robotId, parameters } = body;
// Verify user has access to the study
const membership = await db.query.studyMembers.findFirst({
where: and(
eq(studyMembers.studyId, studyId),
eq(studyMembers.userId, session.user.id),
),
});
if (!membership || !["owner", "researcher"].includes(membership.role)) {
return NextResponse.json(
{ error: "Insufficient permissions" },
{ status: 403 },
);
}
const robotIp =
process.env.NAO_ROBOT_IP || process.env.NAO_IP || "134.82.159.168";
const password = process.env.NAO_PASSWORD || "robolab";
switch (action) {
case "initialize": {
console.log(`[Robots API] Initializing robot at ${robotIp}`);
const disableAlCmd = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "python2 -c \\"import sys; sys.path.append('/opt/aldebaran/lib/python2.7/site-packages'); import naoqi; al = naoqi.ALProxy('ALAutonomousLife', '127.0.0.1', 9559); al.setState('disabled')\\""`;
const wakeUpCmd = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "python2 -c \\"import sys; sys.path.append('/opt/aldebaran/lib/python2.7/site-packages'); import naoqi; m = naoqi.ALProxy('ALMotion', '127.0.0.1', 9559); m.wakeUp()\\""`;
await execAsync(disableAlCmd).catch((e) =>
console.warn("AL disable failed (non-critical/already disabled):", e),
);
await execAsync(wakeUpCmd);
return NextResponse.json({ success: true });
}
case "executeSystemAction": {
const { id, parameters: actionParams } = parameters ?? {};
console.log(`[Robots API] Executing system action ${id}`);
let command = "";
switch (id) {
case "say_with_emotion":
case "say_text_with_emotion": {
const text = String(actionParams?.text || "Hello");
const emotion = String(actionParams?.emotion || "happy");
const tag =
emotion === "happy"
? "^joyful"
: emotion === "sad"
? "^sad"
: emotion === "thinking"
? "^thoughtful"
: "^joyful";
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "python2 -c \\"import sys; sys.path.append('/opt/aldebaran/lib/python2.7/site-packages'); import naoqi; s = naoqi.ALProxy('ALAnimatedSpeech', '127.0.0.1', 9559); s.say('${tag} ${text.replace(/'/g, "\\'")}')\\""`;
break;
}
case "wake_up":
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "python2 -c \\"import sys; sys.path.append('/opt/aldebaran/lib/python2.7/site-packages'); import naoqi; m = naoqi.ALProxy('ALMotion', '127.0.0.1', 9559); m.wakeUp()\\""`;
break;
case "rest":
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "python2 -c \\"import sys; sys.path.append('/opt/aldebaran/lib/python2.7/site-packages'); import naoqi; m = naoqi.ALProxy('ALMotion', '127.0.0.1', 9559); m.rest()\\""`;
break;
default:
return NextResponse.json(
{ error: `System action ${id} not implemented` },
{ status: 400 },
);
}
await execAsync(command);
return NextResponse.json({ success: true });
}
default:
return NextResponse.json(
{ error: `Unknown action: ${action}` },
{ status: 400 },
);
}
} catch (error) {
console.error("[Robots API] Error:", error);
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Internal server error",
},
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,135 @@
import { NextRequest } from "next/server";
import { headers } from "next/headers";
import { wsManager } from "~/server/services/websocket-manager";
import { auth } from "~/lib/auth";
const clientConnections = new Map<
string,
{ socket: WebSocket; clientId: string }
>();
function generateClientId(): string {
return `ws_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
export const runtime = "edge";
export const dynamic = "force-dynamic";
export async function GET(request: NextRequest) {
const url = new URL(request.url);
const trialId = url.searchParams.get("trialId");
const token = url.searchParams.get("token");
if (!trialId) {
return new Response("Missing trialId parameter", { status: 400 });
}
let userId: string | null = null;
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (session?.user?.id) {
userId = session.user.id;
}
} catch {
if (!token) {
return new Response("Authentication required", { status: 401 });
}
try {
const tokenData = JSON.parse(atob(token));
userId = tokenData.userId;
} catch {
return new Response("Invalid token", { status: 401 });
}
}
const pair = new WebSocketPair();
const clientId = generateClientId();
const serverWebSocket = Object.values(pair)[0] as WebSocket;
clientConnections.set(clientId, { socket: serverWebSocket, clientId });
await wsManager.subscribe(clientId, serverWebSocket, trialId, userId);
serverWebSocket.accept();
serverWebSocket.addEventListener("message", async (event) => {
try {
const message = JSON.parse(event.data as string);
switch (message.type) {
case "heartbeat":
wsManager.sendToClient(clientId, {
type: "heartbeat_response",
data: { timestamp: Date.now() },
});
break;
case "request_trial_status": {
const status = await wsManager.getTrialStatus(trialId);
wsManager.sendToClient(clientId, {
type: "trial_status",
data: {
trial: status?.trial ?? null,
current_step_index: status?.currentStepIndex ?? 0,
timestamp: Date.now(),
},
});
break;
}
case "request_trial_events": {
const events = await wsManager.getTrialEvents(
trialId,
message.data?.limit ?? 100,
);
wsManager.sendToClient(clientId, {
type: "trial_events_snapshot",
data: { events, timestamp: Date.now() },
});
break;
}
case "ping":
wsManager.sendToClient(clientId, {
type: "pong",
data: { timestamp: Date.now() },
});
break;
default:
console.log(
`[WS] Unknown message type from client ${clientId}:`,
message.type,
);
}
} catch (error) {
console.error(`[WS] Error processing message from ${clientId}:`, error);
}
});
serverWebSocket.addEventListener("close", () => {
wsManager.unsubscribe(clientId);
clientConnections.delete(clientId);
});
serverWebSocket.addEventListener("error", (error) => {
console.error(`[WS] Error for client ${clientId}:`, error);
wsManager.unsubscribe(clientId);
clientConnections.delete(clientId);
});
return new Response(null, {
status: 101,
webSocket: serverWebSocket,
} as ResponseInit);
}
declare global {
interface WebSocket {
accept(): void;
}
}

View File

@@ -2,484 +2,317 @@
import * as React from "react"; import * as React from "react";
import Link from "next/link"; import Link from "next/link";
import { format } from "date-fns"; import { useSession } from "~/lib/auth-client";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { import {
Activity,
ArrowRight,
Calendar,
CheckCircle,
CheckCircle2,
Clock,
FlaskConical,
HelpCircle,
LayoutDashboard,
MoreHorizontal,
Play, Play,
PlayCircle,
Plus, Plus,
Search, Activity,
Settings, Clock,
CheckCircle2,
Users, Users,
Radio, FlaskConical,
Gamepad2, ChevronRight,
AlertTriangle,
Bot, Bot,
User, Radio,
MessageSquare, Building,
} from "lucide-react"; } from "lucide-react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { Progress } from "~/components/ui/progress";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { ScrollArea } from "~/components/ui/scroll-area"; import { ScrollArea } from "~/components/ui/scroll-area";
import { PageHeader } from "~/components/ui/page-layout";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { useTour } from "~/components/onboarding/TourProvider";
import { useSession } from "~/lib/auth-client";
export default function DashboardPage() { export default function DashboardPage() {
const { startTour } = useTour();
const { data: session } = useSession(); const { data: session } = useSession();
const [studyFilter, setStudyFilter] = React.useState<string | null>(null); const userName = session?.user?.name ?? "Researcher";
// --- Data Fetching --- const { data: userStudies } = api.studies.list.useQuery({
const { data: userStudiesData } = api.studies.list.useQuery({
memberOnly: true, memberOnly: true,
limit: 100, limit: 10,
}); });
const userStudies = userStudiesData?.studies ?? [];
const { data: stats } = api.dashboard.getStats.useQuery({ const { data: recentTrials } = api.trials.list.useQuery({
studyId: studyFilter ?? undefined, limit: 5,
}); });
const { data: liveTrials } = api.dashboard.getLiveTrials.useQuery( const { data: liveTrials } = api.dashboard.getLiveTrials.useQuery(
{ studyId: studyFilter ?? undefined }, {},
{ refetchInterval: 5000 }, { refetchInterval: 5000 },
); );
const { data: recentActivity } = api.dashboard.getRecentActivity.useQuery({ const { data: stats } = api.dashboard.getStats.useQuery({});
limit: 15,
studyId: studyFilter ?? undefined,
});
const { data: studyProgress } = api.dashboard.getStudyProgress.useQuery({ const greeting = (() => {
limit: 5,
studyId: studyFilter ?? undefined,
});
const userName = session?.user?.name ?? "Researcher";
const getWelcomeMessage = () => {
const hour = new Date().getHours(); const hour = new Date().getHours();
let greeting = "Good evening"; if (hour < 12) return "Good morning";
if (hour < 12) greeting = "Good morning"; if (hour < 18) return "Good afternoon";
else if (hour < 18) greeting = "Good afternoon"; return "Good evening";
})();
return `${greeting}, ${userName.split(" ")[0]}`; const firstStudy = userStudies?.studies?.[0];
};
return ( return (
<div className="animate-in fade-in space-y-8 duration-500"> <div className="space-y-6">
{/* Header Section */} {/* Header */}
<div <PageHeader
id="dashboard-header" title={`${greeting}, ${userName.split(" ")[0]}`}
className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between" description="Ready to run your next session?"
> icon={Bot}
<div> actions={
<h1 className="text-foreground text-3xl font-bold tracking-tight"> <div className="flex gap-2">
{getWelcomeMessage()} <Button variant="outline" asChild>
</h1> <Link href="/studies/new">
<p className="text-muted-foreground"> <Plus className="mr-2 h-4 w-4" />
Here's what's happening with your research today. New Study
</p> </Link>
</div> </Button>
<Button asChild className="glow-teal">
<Link href={firstStudy?.id ? `/studies/${firstStudy.id}/trials/new` : "/studies/new"}>
<Play className="mr-2 h-4 w-4" />
Start Trial
</Link>
</Button>
</div>
}
/>
<div className="flex items-center gap-2"> {/* Live Trials Banner */}
<Button {liveTrials && liveTrials.length > 0 && (
variant="ghost" <div className="bg-destructive/10 border-border flex items-center gap-4 rounded-lg border p-4">
size="icon" <div className="relative">
onClick={() => startTour("dashboard")} <Radio className="h-8 w-8 text-destructive animate-pulse" />
title="Start Tour" </div>
> <div className="flex-1">
<HelpCircle className="h-5 w-5" /> <p className="font-semibold">
</Button> {liveTrials.length} Active Session{liveTrials.length > 1 ? "s" : ""}
<Select </p>
value={studyFilter ?? "all"} <p className="text-muted-foreground text-sm">
onValueChange={(value) => {liveTrials.map((t) => t.participantCode).join(", ")}
setStudyFilter(value === "all" ? null : value) </p>
} </div>
> <Button size="sm" variant="secondary" asChild>
<SelectTrigger className="bg-background w-[200px]"> <Link href={`/studies/${liveTrials?.[0]?.studyId}/trials/${liveTrials?.[0]?.id}/wizard`}>
<SelectValue placeholder="All Studies" /> View <ChevronRight className="ml-1 h-4 w-4" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Studies</SelectItem>
{userStudies.map((study) => (
<SelectItem key={study.id} value={study.id}>
{study.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button id="tour-new-study" asChild>
<Link href="/studies/new">
<Plus className="mr-2 h-4 w-4" /> New Study
</Link> </Link>
</Button> </Button>
</div> </div>
</div> )}
{/* Main Stats Grid */} {/* Stats Row */}
<div <div className="grid gap-4 md:grid-cols-4">
id="tour-dashboard-stats" <StatCard
className="grid gap-4 md:grid-cols-2 lg:grid-cols-4" label="Active Trials"
>
<StatsCard
title="Active Trials"
value={stats?.activeTrials ?? 0} value={stats?.activeTrials ?? 0}
icon={Activity} icon={Activity}
description="Currently running sessions" color="teal"
iconColor="text-emerald-500"
/> />
<StatsCard <StatCard
title="Completed Today" label="Completed Today"
value={stats?.completedToday ?? 0} value={stats?.completedToday ?? 0}
icon={CheckCircle} icon={CheckCircle2}
description="Successful completions" color="emerald"
iconColor="text-blue-500"
/> />
<StatsCard <StatCard
title="Scheduled" label="Total Studies"
value={userStudies?.studies?.length ?? 0}
icon={FlaskConical}
color="blue"
/>
<StatCard
label="Scheduled"
value={stats?.scheduledTrials ?? 0} value={stats?.scheduledTrials ?? 0}
icon={Calendar} icon={Clock}
description="Upcoming sessions" color="violet"
iconColor="text-violet-500"
/>
<StatsCard
title="Total Interventions"
value={stats?.totalInterventions ?? 0}
icon={Gamepad2}
description="Wizard manual overrides"
iconColor="text-orange-500"
/> />
</div> </div>
{/* Action Center & Recent Activity */} {/* Main Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7"> <div className="grid gap-6 lg:grid-cols-3">
{/* Quick Actions Card */} {/* Studies List */}
<Card className="from-primary/5 to-background border-primary/20 col-span-3 h-fit bg-gradient-to-br"> <div className="bg-card rounded-lg border lg:col-span-2">
<CardHeader> <div className="flex items-center justify-between border-b px-6 py-4">
<CardTitle>Quick Actions</CardTitle> <div className="flex items-center gap-3">
<CardDescription>Common tasks to get you started</CardDescription> <FlaskConical className="text-primary h-5 w-5" />
</CardHeader> <h2 className="font-semibold">Your Studies</h2>
<CardContent className="grid gap-4"> </div>
<Button <Button variant="ghost" size="sm" asChild>
variant="outline"
className="border-primary/20 hover:border-primary/50 hover:bg-primary/5 group h-auto justify-start px-4 py-4"
asChild
>
<Link href="/studies/new">
<div className="bg-primary/10 group-hover:bg-primary/20 mr-4 rounded-full p-2 transition-colors">
<FlaskConical className="text-primary h-5 w-5" />
</div>
<div className="text-left">
<div className="font-semibold">Create New Study</div>
<div className="text-muted-foreground text-xs font-normal">
Design a new experiment protocol
</div>
</div>
<ArrowRight className="text-muted-foreground group-hover:text-primary ml-auto h-4 w-4 opacity-0 transition-all group-hover:opacity-100" />
</Link>
</Button>
<Button
variant="outline"
className="group h-auto justify-start px-4 py-4"
asChild
>
<Link href="/studies"> <Link href="/studies">
<div className="bg-secondary mr-4 rounded-full p-2"> View all <ChevronRight className="ml-1 h-4 w-4" />
<Search className="text-foreground h-5 w-5" />
</div>
<div className="text-left">
<div className="font-semibold">Browse Studies</div>
<div className="text-muted-foreground text-xs font-normal">
Find and manage existing studies
</div>
</div>
</Link> </Link>
</Button> </Button>
</div>
<Button <div className="p-6">
variant="outline" <div className="space-y-3">
className="group h-auto justify-start px-4 py-4" {userStudies?.studies?.slice(0, 5).map((study) => (
asChild <Link
> key={study.id}
<Link href="/trials"> href={`/studies/${study.id}`}
<div className="mr-4 rounded-full bg-emerald-500/10 p-2"> className="hover:bg-accent/50 group flex items-center justify-between rounded-lg border p-4 transition-colors"
<Activity className="h-5 w-5 text-emerald-600" /> >
</div> <div className="flex items-center gap-4">
<div className="text-left"> <div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<div className="font-semibold">Monitor Active Trials</div> <Bot className="h-6 w-6 text-primary" />
<div className="text-muted-foreground text-xs font-normal"> </div>
Jump into the Wizard Interface <div>
</div> <p className="font-semibold group-hover:text-primary">
</div> {study.name}
</Link> </p>
</Button> <p className="text-muted-foreground text-sm">
</CardContent> {study.status === "active" ? (
</Card> <span className="text-emerald-600 dark:text-emerald-400">Active</span>
) : (
{/* Recent Activity Card */} <span>{study.status}</span>
<Card )}
id="tour-recent-activity" </p>
className="border-muted/40 col-span-4 shadow-sm"
>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
<CardDescription>
Your latest interactions across the platform
</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-[400px] pr-4">
<div className="space-y-4">
{recentActivity?.map((activity) => {
let eventColor = "bg-primary/30 ring-background";
let Icon = Activity;
if (activity.type === "trial_started") {
eventColor = "bg-blue-500 ring-blue-100 dark:ring-blue-900";
Icon = PlayCircle;
} else if (activity.type === "trial_completed") {
eventColor =
"bg-green-500 ring-green-100 dark:ring-green-900";
Icon = CheckCircle;
} else if (activity.type === "error") {
eventColor = "bg-red-500 ring-red-100 dark:ring-red-900";
Icon = AlertTriangle;
} else if (activity.type === "intervention") {
eventColor =
"bg-orange-500 ring-orange-100 dark:ring-orange-900";
Icon = Gamepad2;
} else if (activity.type === "annotation") {
eventColor =
"bg-yellow-500 ring-yellow-100 dark:ring-yellow-900";
Icon = MessageSquare;
}
return (
<div
key={activity.id}
className="border-muted-foreground/20 relative border-l pb-4 pl-6 last:border-0"
>
<span
className={`absolute top-0 left-[-9px] flex h-4 w-4 items-center justify-center rounded-full ring-4 ${eventColor}`}
>
<Icon className="h-2.5 w-2.5 text-white" />
</span>
<div className="mb-0.5 text-sm leading-none font-medium">
{activity.title}
</div>
<div className="text-muted-foreground mb-1 text-xs">
{activity.description}
</div>
<div className="text-muted-foreground/70 font-mono text-[10px] uppercase">
{formatDistanceToNow(new Date(activity.time), {
addSuffix: true,
})}
</div>
</div> </div>
);
})}
{!recentActivity?.length && (
<div className="text-muted-foreground flex flex-col items-center justify-center py-8 text-center">
<Clock className="mb-3 h-10 w-10 opacity-20" />
<p>No recent activity recorded.</p>
<p className="mt-1 text-xs">
Start a trial to see experiment events stream here.
</p>
</div> </div>
)} <ChevronRight className="h-5 w-5 text-muted-foreground group-hover:text-primary" />
</div> </Link>
</ScrollArea> ))}
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7"> {!userStudies?.studies?.length && (
{/* Live Trials */} <div className="flex flex-col items-center justify-center py-12 text-center">
<Card <Bot className="text-muted-foreground/50 mb-4 h-16 w-16" />
id="tour-live-trials" <p className="font-medium">No studies yet</p>
className={`${liveTrials && liveTrials.length > 0 ? "border-primary bg-primary/5 shadow-sm" : "border-muted/40"} col-span-4 transition-colors duration-500`} <p className="text-muted-foreground mb-4 text-sm">
> Create your first study to get started
<CardHeader> </p>
<div className="flex items-center justify-between"> <Button asChild>
<div> <Link href="/studies/new">
<CardTitle className="flex items-center gap-2"> <Plus className="mr-2 h-4 w-4" />
Live Sessions Create Study
{liveTrials && liveTrials.length > 0 && ( </Link>
<span className="relative flex h-3 w-3"> </Button>
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75"></span> </div>
<span className="relative inline-flex h-3 w-3 rounded-full bg-red-500"></span> )}
</span> </div>
)} </div>
</CardTitle> </div>
<CardDescription>
Currently running trials in the Wizard interface {/* Quick Links & Recent */}
</CardDescription> <div className="space-y-6">
</div> {/* Quick Actions */}
<Button variant="ghost" size="sm" asChild> <div className="bg-card rounded-lg border">
<Link href="/trials"> <div className="border-b px-6 py-4">
View All <ArrowRight className="ml-2 h-4 w-4" /> <h2 className="font-semibold">Quick Actions</h2>
</div>
<div className="space-y-2 p-4">
<Button variant="outline" className="w-full justify-start" asChild>
<Link href="/studies/new">
<Plus className="mr-3 h-4 w-4" />
Create New Study
</Link>
</Button>
<Button variant="outline" className="w-full justify-start" asChild>
<Link href={firstStudy?.id ? `/studies/${firstStudy.id}/experiments/new` : "/studies"}>
<FlaskConical className="mr-3 h-4 w-4" />
Design Experiment
</Link>
</Button>
<Button variant="outline" className="w-full justify-start" asChild>
<Link href={firstStudy?.id ? `/studies/${firstStudy.id}/participants/new` : "/studies"}>
<Users className="mr-3 h-4 w-4" />
Add Participant
</Link>
</Button>
<Button variant="outline" className="w-full justify-start" asChild>
<Link href={firstStudy?.id ? `/studies/${firstStudy.id}/trials/new` : "/studies"}>
<Play className="mr-3 h-4 w-4" />
Start Trial
</Link> </Link>
</Button> </Button>
</div> </div>
</CardHeader> </div>
<CardContent>
{!liveTrials?.length ? (
<div className="border-muted-foreground/30 animate-in fade-in-50 bg-background/50 flex h-[150px] flex-col items-center justify-center rounded-md border border-dashed text-center">
<Radio className="text-muted-foreground/50 mb-2 h-8 w-8" />
<p className="text-muted-foreground text-sm">
No trials are currently running.
</p>
<Button variant="link" size="sm" asChild className="mt-1">
<Link href="/trials">Start a Trial</Link>
</Button>
</div>
) : (
<div className="space-y-4">
{liveTrials.map((trial) => (
<div
key={trial.id}
className="border-primary/20 bg-background flex items-center justify-between rounded-lg border p-3 shadow-sm transition-all duration-200 hover:shadow"
>
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-100 text-red-600 dark:bg-red-900/40 dark:text-red-400">
<Radio className="h-5 w-5 animate-pulse" />
</div>
<div>
<p className="text-sm font-medium">
{trial.participantCode}
<span className="text-muted-foreground ml-2 text-xs font-normal">
{trial.experimentName}
</span>
</p>
<div className="text-muted-foreground flex items-center gap-2 text-xs">
<Clock className="h-3 w-3" />
Started{" "}
{trial.startedAt
? formatDistanceToNow(new Date(trial.startedAt), {
addSuffix: true,
})
: "just now"}
</div>
</div>
</div>
<Button
size="sm"
className="bg-primary hover:bg-primary/90 gap-2"
asChild
>
<Link href={`/wizard/${trial.id}`}>
<Play className="h-3.5 w-3.5" /> Spectate / Jump In
</Link>
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Study Progress */} {/* Recent Trials */}
<Card className="border-muted/40 col-span-3 shadow-sm"> <div className="bg-card rounded-lg border">
<CardHeader> <div className="flex items-center justify-between border-b px-6 py-4">
<CardTitle>Study Progress</CardTitle> <div className="flex items-center gap-2">
<CardDescription> <Clock className="h-4 w-4 text-muted-foreground" />
Completion tracking for active studies <h2 className="font-semibold">Recent Trials</h2>
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{studyProgress?.map((study) => (
<div key={study.id} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="font-medium">{study.name}</div>
<div className="text-muted-foreground">
{study.participants} / {study.totalParticipants}{" "}
Participants
</div>
</div>
<Progress value={study.progress} className="h-2" />
</div> </div>
))} </div>
{!studyProgress?.length && ( <div className="p-4">
<p className="text-muted-foreground py-4 text-center text-sm"> <ScrollArea className="h-[200px]">
No active studies to track. <div className="space-y-3">
</p> {recentTrials?.slice(0, 5).map((trial) => (
)} <Link
</CardContent> key={trial.id}
</Card> href={`/studies/${trial.experiment.studyId}/trials/${trial.id}`}
className="hover:bg-accent/50 block rounded-md border p-3 transition-colors"
>
<div className="flex items-center justify-between">
<span className="font-medium">{trial.participant.participantCode}</span>
<Badge
variant={
trial.status === "completed"
? "default"
: trial.status === "in_progress"
? "secondary"
: "outline"
}
className="text-xs"
>
{trial.status.replace("_", " ")}
</Badge>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{trial.experiment.name}
</p>
{trial.completedAt && (
<p className="text-muted-foreground mt-1 text-xs">
{formatDistanceToNow(new Date(trial.completedAt), { addSuffix: true })}
</p>
)}
</Link>
))}
{!recentTrials?.length && (
<p className="text-muted-foreground py-4 text-center text-sm">
No trials yet
</p>
)}
</div>
</ScrollArea>
</div>
</div>
</div>
</div> </div>
</div> </div>
); );
} }
function StatsCard({ function StatCard({
title, label,
value, value,
icon: Icon, icon: Icon,
description, color,
trend,
iconColor,
}: { }: {
title: string; label: string;
value: string | number; value: number;
icon: React.ElementType; icon: React.ElementType;
description: string; color: "teal" | "emerald" | "blue" | "violet";
trend?: string;
iconColor?: string;
}) { }) {
const colorClasses = {
teal: "bg-primary/10 text-primary",
emerald: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
blue: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
violet: "bg-violet-500/10 text-violet-600 dark:text-violet-400",
};
return ( return (
<Card className="border-muted/40 hover:border-primary/20 shadow-sm transition-all duration-200 hover:shadow-md"> <div className="bg-card rounded-lg border p-6">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <div className="flex items-center gap-4">
<CardTitle className="text-sm font-medium">{title}</CardTitle> <div className={`rounded-full p-3 ${colorClasses[color]}`}>
<Icon className={`h-4 w-4 ${iconColor || "text-muted-foreground"}`} /> <Icon className="h-6 w-6" />
</CardHeader> </div>
<CardContent> <div>
<div className="text-2xl font-bold">{value}</div> <p className="text-3xl font-bold">{value}</p>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-sm">{label}</p>
{description} </div>
{trend && ( </div>
<span className="ml-1 font-medium text-green-600 dark:text-green-400"> </div>
{trend}
</span>
)}
</p>
</CardContent>
</Card>
); );
} }

View File

@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import Image from "next/image";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
@@ -8,18 +9,41 @@ import { Logo } from "~/components/ui/logo";
import { auth } from "~/lib/auth"; import { auth } from "~/lib/auth";
import { import {
ArrowRight, ArrowRight,
Beaker,
Bot, Bot,
Database, Database,
LayoutTemplate, LayoutTemplate,
Lock, Lock,
Network, Network,
PlayCircle,
Settings2, Settings2,
Share2, Share2,
Sparkles, Sparkles,
Users,
Beaker,
FileText,
PlayCircle,
} from "lucide-react"; } from "lucide-react";
const screenshots = [
{
src: "/images/screenshots/experiment-designer.png",
alt: "Visual Experiment Designer",
label: "Design",
className: "md:col-span-2 md:row-span-2",
},
{
src: "/images/screenshots/wizard-interface.png",
alt: "Wizard Execution Interface",
label: "Execute",
className: "",
},
{
src: "/images/screenshots/dashboard.png",
alt: "Study Dashboard",
label: "Dashboard",
className: "",
},
];
export default async function Home() { export default async function Home() {
const session = await auth.api.getSession({ const session = await auth.api.getSession({
headers: await headers(), headers: await headers(),
@@ -40,7 +64,7 @@ export default async function Home() {
<Link href="#features">Features</Link> <Link href="#features">Features</Link>
</Button> </Button>
<Button variant="ghost" asChild className="hidden sm:inline-flex"> <Button variant="ghost" asChild className="hidden sm:inline-flex">
<Link href="#architecture">Architecture</Link> <Link href="#how-it-works">How It Works</Link>
</Button> </Button>
<div className="bg-border hidden h-6 w-px sm:block" /> <div className="bg-border hidden h-6 w-px sm:block" />
<Button variant="ghost" asChild> <Button variant="ghost" asChild>
@@ -55,8 +79,7 @@ export default async function Home() {
<main className="flex-1"> <main className="flex-1">
{/* Hero Section */} {/* Hero Section */}
<section className="relative overflow-hidden pt-20 pb-32 md:pt-32"> <section className="relative overflow-hidden pt-20 pb-24 md:pt-32">
{/* Background Gradients */}
<div className="bg-primary/20 absolute top-0 left-1/2 -z-10 h-[500px] w-[1000px] -translate-x-1/2 rounded-full opacity-30 blur-3xl dark:opacity-20" /> <div className="bg-primary/20 absolute top-0 left-1/2 -z-10 h-[500px] w-[1000px] -translate-x-1/2 rounded-full opacity-30 blur-3xl dark:opacity-20" />
<div className="container mx-auto flex flex-col items-center px-4 text-center"> <div className="container mx-auto flex flex-col items-center px-4 text-center">
@@ -65,26 +88,27 @@ export default async function Home() {
className="mb-6 rounded-full px-4 py-1.5 text-sm font-medium" className="mb-6 rounded-full px-4 py-1.5 text-sm font-medium"
> >
<Sparkles className="mr-2 h-4 w-4 text-yellow-500" /> <Sparkles className="mr-2 h-4 w-4 text-yellow-500" />
The Modern Standard for HRI Research Open Source WoZ Platform
</Badge> </Badge>
<h1 className="max-w-4xl text-5xl font-extrabold tracking-tight sm:text-6xl md:text-7xl"> <h1 className="max-w-4xl text-5xl font-extrabold tracking-tight sm:text-6xl md:text-7xl">
Reproducible WoZ Studies <br className="hidden md:block" /> Wizard-of-Oz Studies <br className="hidden md:block" />
<span className="bg-gradient-to-r from-blue-600 to-violet-600 bg-clip-text text-transparent dark:from-blue-400 dark:to-violet-400"> <span className="bg-gradient-to-r from-cyan-500 to-blue-600 bg-clip-text text-transparent">
Made Simple Made Scientific
</span> </span>
</h1> </h1>
<p className="text-muted-foreground mt-6 max-w-2xl text-lg md:text-xl"> <p className="text-muted-foreground mt-6 max-w-2xl text-lg md:text-xl">
HRIStudio is the open-source platform that bridges the gap between HRIStudio is the open-source platform that makes human-robot
ease of use and scientific rigor. Design, execute, and analyze interaction research reproducible, accessible, and collaborative.
human-robot interaction experiments with zero friction. Design experiments, control robots, and analyze results all in
one place.
</p> </p>
<div className="mt-10 flex flex-col gap-4 sm:flex-row sm:justify-center"> <div className="mt-10 flex flex-col gap-4 sm:flex-row sm:justify-center">
<Button size="lg" className="h-12 px-8 text-base" asChild> <Button size="lg" className="h-12 px-8 text-base" asChild>
<Link href="/auth/signup"> <Link href="/auth/signup">
Start Researching Start Your Research
<ArrowRight className="ml-2 h-4 w-4" /> <ArrowRight className="ml-2 h-4 w-4" />
</Link> </Link>
</Button> </Button>
@@ -102,127 +126,160 @@ export default async function Home() {
</Link> </Link>
</Button> </Button>
</div> </div>
</div>
</section>
{/* Mockup / Visual Interest */} {/* Screenshots Section */}
<div className="bg-background/50 relative mt-20 w-full max-w-5xl rounded-xl border p-2 shadow-2xl backdrop-blur-sm lg:rounded-2xl lg:p-4"> <section id="screenshots" className="container mx-auto px-4 py-12">
<div className="via-foreground/20 absolute inset-x-0 -top-px mx-auto h-px w-3/4 bg-gradient-to-r from-transparent to-transparent" /> <div className="grid gap-4 md:grid-cols-3">
<div className="bg-muted/50 relative flex aspect-[16/9] w-full items-center justify-center overflow-hidden rounded-lg border"> {screenshots.map((screenshot, index) => (
{/* Placeholder for actual app screenshot */} <div
<div className="absolute inset-0 bg-gradient-to-tr from-blue-500/10 to-violet-500/10" /> key={index}
<div className="p-8 text-center"> className={`group bg-muted/50 relative overflow-hidden rounded-xl border ${screenshot.className}`}
<LayoutTemplate className="text-muted-foreground/50 mx-auto mb-4 h-16 w-16" /> >
<p className="text-muted-foreground font-medium"> {/* Placeholder - replace src with actual screenshot */}
Interactive Experiment Designer <div className="from-muted to-muted/50 absolute inset-0 flex flex-col items-center justify-center bg-gradient-to-br">
<div className="bg-background/80 mb-4 rounded-lg px-4 py-2 shadow-sm">
<span className="text-muted-foreground text-xs font-medium tracking-wider uppercase">
{screenshot.label}
</span>
</div>
<FileText className="text-muted-foreground/30 h-16 w-16" />
<p className="text-muted-foreground/50 mt-4 text-sm">
Screenshot: {screenshot.alt}
</p>
<p className="text-muted-foreground/30 mt-1 text-xs">
Replace with actual image
</p> </p>
</div> </div>
{/* Uncomment when you have real screenshots:
<Image
src={screenshot.src}
alt={screenshot.alt}
fill
className="object-cover transition-transform group-hover:scale-105"
/>
*/}
</div> </div>
))}
</div>
<p className="text-muted-foreground mt-4 text-center text-sm">
Add screenshots to{" "}
<code className="bg-muted rounded px-2 py-1">
public/images/screenshots/
</code>
</p>
</section>
{/* Features Section */}
<section id="features" className="bg-muted/30 border-t py-24">
<div className="container mx-auto px-4">
<div className="mb-16 text-center">
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">
Built for Scientific Rigor
</h2>
<p className="text-muted-foreground mx-auto mt-4 max-w-2xl text-lg">
Everything you need to conduct reproducible Wizard-of-Oz
studies, from experiment design to data analysis.
</p>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<FeatureCard
icon={LayoutTemplate}
title="Visual Experiment Designer"
description="Build complex branching narratives with drag-and-drop blocks. No coding required — just drag, configure, and run."
color="blue"
/>
<FeatureCard
icon={PlayCircle}
title="Guided Wizard Interface"
description="Step-by-step protocol execution keeps wizards on track. Every action is logged with timestamps."
color="violet"
/>
<FeatureCard
icon={Bot}
title="Robot Agnostic"
description="Design experiments once, run on any robot. NAO, Pepper, TurtleBot — your logic stays the same."
color="green"
/>
<FeatureCard
icon={Users}
title="Role-Based Collaboration"
description="Invite PIs, wizards, and observers. Each role sees exactly what they need — nothing more."
color="orange"
/>
<FeatureCard
icon={Database}
title="Automatic Data Logging"
description="Every action, timestamp, and sensor reading is captured. Export to CSV for analysis."
color="rose"
/>
<FeatureCard
icon={Lock}
title="Built-in Reproducibility"
description="Protocol/trial separation, deviation logging, and comprehensive audit trails make replication trivial."
color="cyan"
/>
</div> </div>
</div> </div>
</section> </section>
{/* Features Bento Grid */} {/* How It Works */}
<section id="features" className="container mx-auto px-4 py-24"> <section id="how-it-works" className="container mx-auto px-4 py-24">
<div className="mb-12 text-center"> <div className="mb-16 text-center">
<h2 className="text-3xl font-bold tracking-tight md:text-4xl"> <h2 className="text-3xl font-bold tracking-tight md:text-4xl">
Everything You Need How It Works
</h2> </h2>
<p className="text-muted-foreground mt-4 text-lg"> <p className="text-muted-foreground mt-4 text-lg">
Built for the specific needs of HRI researchers and wizards. From design to publication in one unified workflow.
</p> </p>
</div> </div>
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-4 lg:grid-rows-2"> <div className="relative">
{/* Visual Designer - Large Item */} {/* Connection line */}
<Card className="col-span-1 row-span-2 flex flex-col overflow-hidden bg-gradient-to-br from-blue-500/5 to-violet-500/5 md:col-span-2 lg:col-span-2 dark:from-blue-900/10 dark:to-violet-900/10"> <div className="bg-border absolute top-0 left-1/2 hidden h-full w-px -translate-x-1/2 lg:block" />
<CardHeader>
<CardTitle className="flex items-center gap-2">
<LayoutTemplate className="h-5 w-5 text-blue-500" />
Visual Experiment Designer
</CardTitle>
</CardHeader>
<CardContent className="flex-1">
<p className="text-muted-foreground mb-6">
Construct complex branching narratives without writing a
single line of code. Our node-based editor handles logic,
timing, and robot actions automatically.
</p>
<div className="bg-background/50 flex h-full min-h-[200px] items-center justify-center rounded-lg border p-4 shadow-inner">
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<span className="bg-accent rounded p-2">Start</span>
<ArrowRight className="h-4 w-4" />
<span className="bg-primary/10 border-primary/20 text-primary rounded border p-2 font-medium">
Robot: Greet
</span>
<ArrowRight className="h-4 w-4" />
<span className="bg-accent rounded p-2">Wait: 5s</span>
</div>
</div>
</CardContent>
</Card>
{/* Robot Agnostic */} <div className="space-y-12 lg:space-y-0">
<Card className="col-span-1 md:col-span-1 lg:col-span-2"> <WorkflowStep
<CardHeader> number={1}
<CardTitle className="flex items-center gap-2"> title="Design"
<Bot className="h-5 w-5 text-green-500" /> description="Use the visual editor to build your experiment protocol with drag-and-drop blocks. Add speech, gestures, conditions, and branching logic — no code required."
Robot Agnostic icon={LayoutTemplate}
</CardTitle> />
</CardHeader> <WorkflowStep
<CardContent> number={2}
<p className="text-muted-foreground"> title="Configure"
Switch between robots instantly. Whether it's a NAO, Pepper, description="Set up your study, invite team members with appropriate roles, and configure your robot platform."
or a custom ROS2 bot, your experiment logic remains strictly icon={Settings2}
separated from hardware implementation. />
</p> <WorkflowStep
</CardContent> number={3}
</Card> title="Execute"
description="Run trials with the wizard interface. Real-time updates keep everyone in sync. Every action is automatically logged."
{/* Role Based */} icon={PlayCircle}
<Card className="bg-muted/30 col-span-1 md:col-span-1 lg:col-span-1"> />
<CardHeader> <WorkflowStep
<CardTitle className="flex items-center gap-2 text-base"> number={4}
<Lock className="h-4 w-4 text-orange-500" /> title="Analyze"
Role-Based Access description="Review trial data, export responses, and compare across participants. Everything is timestamped and synchronized."
</CardTitle> icon={Share2}
</CardHeader> />
<CardContent> </div>
<p className="text-muted-foreground text-sm">
Granular permissions for Principal Investigators, Wizards, and
Observers.
</p>
</CardContent>
</Card>
{/* Data Logging */}
<Card className="bg-muted/30 col-span-1 md:col-span-1 lg:col-span-1">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Database className="h-4 w-4 text-rose-500" />
Full Traceability
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
Every wizard action, automated response, and sensor reading is
time-stamped and logged.
</p>
</CardContent>
</Card>
</div> </div>
</section> </section>
{/* Architecture Section */} {/* Architecture Section */}
<section id="architecture" className="bg-muted/30 border-t py-24"> <section id="architecture" className="bg-muted/30 border-t py-24">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className="grid items-center gap-12 lg:grid-cols-2 lg:gap-8"> <div className="grid items-center gap-12 lg:grid-cols-2">
<div> <div>
<h2 className="text-3xl font-bold tracking-tight"> <h2 className="text-3xl font-bold tracking-tight">
Enterprise-Grade Architecture Modern Architecture
</h2> </h2>
<p className="text-muted-foreground mt-4 text-lg"> <p className="text-muted-foreground mt-4 text-lg">
Designed for reliability and scale. HRIStudio uses a modern Built on proven technologies for reliability, type safety, and
stack to ensure your data is safe and your experiments run real-time collaboration.
smoothly.
</p> </p>
<div className="mt-8 space-y-4"> <div className="mt-8 space-y-4">
@@ -232,9 +289,9 @@ export default async function Home() {
</div> </div>
<div> <div>
<h3 className="font-semibold">3-Layer Design</h3> <h3 className="font-semibold">3-Layer Design</h3>
<p className="text-muted-foreground"> <p className="text-muted-foreground text-sm">
Clear separation between UI, Data, and Hardware layers UI, application logic, and hardware layers are strictly
for maximum stability. separated for stability.
</p> </p>
</div> </div>
</div> </div>
@@ -243,72 +300,74 @@ export default async function Home() {
<Share2 className="text-primary h-5 w-5" /> <Share2 className="text-primary h-5 w-5" />
</div> </div>
<div> <div>
<h3 className="font-semibold"> <h3 className="font-semibold">Real-Time Sync</h3>
Collaborative by Default <p className="text-muted-foreground text-sm">
</h3> WebSocket updates keep wizard and observer views
<p className="text-muted-foreground"> perfectly synchronized.
Real-time state synchronization allows multiple
researchers to monitor a single trial.
</p> </p>
</div> </div>
</div> </div>
<div className="flex gap-4"> <div className="flex gap-4">
<div className="bg-background flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border shadow-sm"> <div className="bg-background flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border shadow-sm">
<Settings2 className="text-primary h-5 w-5" /> <Beaker className="text-primary h-5 w-5" />
</div> </div>
<div> <div>
<h3 className="font-semibold">ROS2 Integration</h3> <h3 className="font-semibold">Plugin System</h3>
<p className="text-muted-foreground"> <p className="text-muted-foreground text-sm">
Native support for ROS2 nodes, topics, and actions right Extend with custom robot integrations and actions
out of the box. through a simple JSON configuration.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div className="relative mx-auto w-full max-w-[500px]"> <div className="relative space-y-4">
{/* Abstract representation of architecture */} <Card className="border-blue-500/20 bg-blue-500/5">
<div className="relative z-10 space-y-4"> <CardHeader className="pb-2">
<Card className="relative left-0 cursor-default border-blue-500/20 bg-blue-500/5 transition-all hover:left-2"> <CardTitle className="font-mono text-sm text-blue-600 dark:text-blue-400">
<CardHeader className="pb-2"> APP LAYER
<CardTitle className="font-mono text-sm text-blue-600 dark:text-blue-400"> </CardTitle>
APP LAYER </CardHeader>
</CardTitle> <CardContent>
</CardHeader> <p className="text-sm font-medium">
<CardContent> Next.js + React + tRPC
<p className="font-semibold"> </p>
Next.js Dashboard + Experiment Designer <p className="text-muted-foreground text-xs">
</p> Type-safe full-stack
</CardContent> </p>
</Card> </CardContent>
<Card className="relative left-4 cursor-default border-violet-500/20 bg-violet-500/5 transition-all hover:left-6"> </Card>
<CardHeader className="pb-2"> <Card className="border-violet-500/20 bg-violet-500/5">
<CardTitle className="font-mono text-sm text-violet-600 dark:text-violet-400"> <CardHeader className="pb-2">
DATA LAYER <CardTitle className="font-mono text-sm text-violet-600 dark:text-violet-400">
</CardTitle> DATA LAYER
</CardHeader> </CardTitle>
<CardContent> </CardHeader>
<p className="font-semibold"> <CardContent>
PostgreSQL + MinIO + TRPC API <p className="text-sm font-medium">
</p> PostgreSQL + MinIO + WebSocket
</CardContent> </p>
</Card> <p className="text-muted-foreground text-xs">
<Card className="relative left-8 cursor-default border-green-500/20 bg-green-500/5 transition-all hover:left-10"> Persistent storage + real-time
<CardHeader className="pb-2"> </p>
<CardTitle className="font-mono text-sm text-green-600 dark:text-green-400"> </CardContent>
HARDWARE LAYER </Card>
</CardTitle> <Card className="border-green-500/20 bg-green-500/5">
</CardHeader> <CardHeader className="pb-2">
<CardContent> <CardTitle className="font-mono text-sm text-green-600 dark:text-green-400">
<p className="font-semibold"> ROBOT LAYER
ROS2 Bridge + Robot Plugins </CardTitle>
</p> </CardHeader>
</CardContent> <CardContent>
</Card> <p className="text-sm font-medium">
</div> ROS2 Bridge + Plugin Config
{/* Decorative blobs */} </p>
<div className="bg-primary/10 absolute top-1/2 left-1/2 -z-10 h-[300px] w-[300px] -translate-x-1/2 -translate-y-1/2 rounded-full blur-3xl" /> <p className="text-muted-foreground text-xs">
Platform agnostic
</p>
</CardContent>
</Card>
</div> </div>
</div> </div>
</div> </div>
@@ -316,21 +375,33 @@ export default async function Home() {
{/* CTA Section */} {/* CTA Section */}
<section className="container mx-auto px-4 py-24 text-center"> <section className="container mx-auto px-4 py-24 text-center">
<h2 className="text-3xl font-bold tracking-tight md:text-4xl"> <div className="mx-auto max-w-2xl">
Ready to upgrade your lab? <h2 className="text-3xl font-bold tracking-tight md:text-4xl">
</h2> Ready to upgrade your research?
<p className="text-muted-foreground mx-auto mt-4 max-w-2xl text-lg"> </h2>
Join the community of researchers building the future of HRI with <p className="text-muted-foreground mt-4 text-lg">
reproducible, open-source tools. Join researchers building reproducible HRI studies with
</p> open-source tools.
<div className="mt-8"> </p>
<Button <div className="mt-8 flex flex-col gap-4 sm:flex-row sm:justify-center">
size="lg" <Button
className="shadow-primary/20 h-12 px-8 text-base shadow-lg" size="lg"
asChild className="shadow-primary/20 h-12 px-8 text-base shadow-lg"
> asChild
<Link href="/auth/signup">Get Started for Free</Link> >
</Button> <Link href="/auth/signup">Get Started Free</Link>
</Button>
<Button
size="lg"
variant="outline"
className="h-12 px-8 text-base"
asChild
>
<Link href="/docs" target="_blank">
Read the Docs
</Link>
</Button>
</div>
</div> </div>
</section> </section>
</main> </main>
@@ -340,25 +411,96 @@ export default async function Home() {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Logo iconSize="sm" showText={true} /> <Logo iconSize="sm" showText={true} />
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
&copy; {new Date().getFullYear()} HRIStudio. All rights reserved. &copy; {new Date().getFullYear()} HRIStudio. Open source under MIT
License.
</p> </p>
</div> </div>
<div className="text-muted-foreground flex gap-6 text-sm"> <div className="text-muted-foreground flex gap-6 text-sm">
<Link href="/docs" className="hover:text-foreground">
Docs
</Link>
<Link
href="https://github.com/robolab/hristudio"
className="hover:text-foreground"
target="_blank"
>
GitHub
</Link>
<Link href="#" className="hover:text-foreground"> <Link href="#" className="hover:text-foreground">
Privacy Privacy
</Link> </Link>
<Link href="#" className="hover:text-foreground"> <Link href="#" className="hover:text-foreground">
Terms Terms
</Link> </Link>
<Link href="#" className="hover:text-foreground">
GitHub
</Link>
<Link href="#" className="hover:text-foreground">
Documentation
</Link>
</div> </div>
</div> </div>
</footer> </footer>
</div> </div>
); );
} }
function FeatureCard({
icon: Icon,
title,
description,
color,
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
description: string;
color: "blue" | "violet" | "green" | "orange" | "rose" | "cyan";
}) {
const colors = {
blue: "text-blue-500 bg-blue-500/10",
violet: "text-violet-500 bg-violet-500/10",
green: "text-green-500 bg-green-500/10",
orange: "text-orange-500 bg-orange-500/10",
rose: "text-rose-500 bg-rose-500/10",
cyan: "text-cyan-500 bg-cyan-500/10",
};
return (
<Card>
<CardHeader>
<div
className={`mb-2 inline-flex h-10 w-10 items-center justify-center rounded-lg ${colors[color]}`}
>
<Icon className="h-5 w-5" />
</div>
<CardTitle className="text-lg">{title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">{description}</p>
</CardContent>
</Card>
);
}
function WorkflowStep({
number,
title,
description,
icon: Icon,
}: {
number: number;
title: string;
description: string;
icon: React.ComponentType<{ className?: string }>;
}) {
return (
<div className="relative flex flex-col items-center gap-4 lg:flex-row lg:gap-8">
<div className="border-primary bg-background text-primary z-10 flex h-12 w-12 shrink-0 items-center justify-center rounded-full border-2 font-bold">
{number}
</div>
<Card className="flex-1">
<CardHeader className="flex flex-row items-center gap-4 pb-2">
<Icon className="text-primary h-5 w-5" />
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">{description}</p>
</CardContent>
</Card>
</div>
);
}

View File

@@ -274,7 +274,7 @@ export function DesignerRoot({
}, },
}); });
const { data: studyPluginsRaw } = api.robots.plugins.getStudyPlugins.useQuery( const { data: studyPluginsRaw } = api.studies.getStudyPlugins.useQuery(
{ studyId: experiment?.studyId ?? "" }, { studyId: experiment?.studyId ?? "" },
{ enabled: !!experiment?.studyId }, { enabled: !!experiment?.studyId },
); );

View File

@@ -197,22 +197,21 @@ export function PluginStoreBrowse() {
) as { data: Array<{ id: string; url: string; name: string }> | undefined }; ) as { data: Array<{ id: string; url: string; name: string }> | undefined };
// Get installed plugins for current study // Get installed plugins for current study
const { data: installedPlugins } = const { data: installedPlugins } = api.studies.getStudyPlugins.useQuery(
api.robots.plugins.getStudyPlugins.useQuery( {
{ studyId: selectedStudyId!,
studyId: selectedStudyId!, },
}, {
{ enabled: !!selectedStudyId,
enabled: !!selectedStudyId, refetchOnWindowFocus: false,
refetchOnWindowFocus: false, },
}, );
);
const { const {
data: availablePlugins, data: availablePlugins,
isLoading, isLoading,
error, error,
} = api.robots.plugins.list.useQuery( } = api.plugins.list.useQuery(
{ {
status: status:
statusFilter === "all" statusFilter === "all"
@@ -228,12 +227,12 @@ export function PluginStoreBrowse() {
const utils = api.useUtils(); const utils = api.useUtils();
const installPluginMutation = api.robots.plugins.install.useMutation({ const installPluginMutation = api.plugins.install.useMutation({
onSuccess: () => { onSuccess: () => {
toast.success("Plugin installed successfully!"); toast.success("Plugin installed successfully!");
// Invalidate both plugin queries to refresh the UI // Invalidate both plugin queries to refresh the UI
void utils.robots.plugins.list.invalidate(); void utils.plugins.list.invalidate();
void utils.robots.plugins.getStudyPlugins.invalidate(); void utils.studies.getStudyPlugins.invalidate();
}, },
onError: (error) => { onError: (error) => {
toast.error(error.message || "Failed to install plugin"); toast.error(error.message || "Failed to install plugin");
@@ -430,7 +429,7 @@ export function PluginStoreBrowse() {
"An error occurred while loading the plugin store."} "An error occurred while loading the plugin store."}
</p> </p>
<Button <Button
onClick={() => void utils.robots.plugins.list.refetch()} onClick={() => void utils.plugins.list.refetch()}
variant="outline" variant="outline"
> >
Try Again Try Again

View File

@@ -90,12 +90,12 @@ function PluginActionsCell({ plugin }: { plugin: Plugin }) {
const { selectedStudyId } = useStudyContext(); const { selectedStudyId } = useStudyContext();
const utils = api.useUtils(); const utils = api.useUtils();
const uninstallMutation = api.robots.plugins.uninstall.useMutation({ const uninstallMutation = api.plugins.uninstall.useMutation({
onSuccess: () => { onSuccess: () => {
toast.success("Plugin uninstalled successfully"); toast.success("Plugin uninstalled successfully");
// Invalidate plugin queries to refresh the UI // Invalidate plugin queries to refresh the UI
void utils.robots.plugins.getStudyPlugins.invalidate(); void utils.studies.getStudyPlugins.invalidate();
void utils.robots.plugins.list.invalidate(); void utils.plugins.list.invalidate();
}, },
onError: (error) => { onError: (error) => {
toast.error(error.message || "Failed to uninstall plugin"); toast.error(error.message || "Failed to uninstall plugin");

View File

@@ -28,7 +28,7 @@ export function PluginsDataTable() {
isLoading, isLoading,
error, error,
refetch, refetch,
} = api.robots.plugins.getStudyPlugins.useQuery( } = api.studies.getStudyPlugins.useQuery(
{ {
studyId: selectedStudyId!, studyId: selectedStudyId!,
}, },

View File

@@ -0,0 +1,195 @@
"use client";
import * as React from "react";
import { useSession } from "~/lib/auth-client";
import { api } from "~/trpc/react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Plus, Loader2, Trash2, Shield, UserMinus } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
interface Member {
id: string;
userId: string;
role: string;
user: {
name: string | null;
email: string;
};
}
interface AddMemberDialogProps {
studyId: string;
children?: React.ReactNode;
}
export function AddMemberDialog({ studyId, children }: AddMemberDialogProps) {
const utils = api.useUtils();
const [open, setOpen] = React.useState(false);
const [email, setEmail] = React.useState("");
const [role, setRole] = React.useState<string>("researcher");
const { data: membersData } = api.studies.getMembers.useQuery({ studyId });
const addMember = api.studies.addMember.useMutation({
onSuccess: () => {
toast.success("Member added successfully");
void utils.studies.getMembers.invalidate();
setEmail("");
setRole("researcher");
setOpen(false);
},
onError: (error) => {
toast.error(error.message || "Failed to add member");
},
});
const removeMember = api.studies.removeMember.useMutation({
onSuccess: () => {
toast.success("Member removed");
void utils.studies.getMembers.invalidate();
},
onError: (error) => {
toast.error(error.message || "Failed to remove member");
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!email || !role) return;
addMember.mutate({ studyId, email, role: role as "researcher" | "wizard" | "observer" });
};
const handleRemove = (memberId: string, memberName: string | null) => {
if (confirm(`Remove ${memberName ?? memberId} from this study?`)) {
removeMember.mutate({ studyId, memberId });
}
};
const members = membersData ?? [];
const currentUser = members.find((m) => m.userId);
const isOwner = currentUser?.role === "owner";
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{children ?? (
<Button size="sm">
<Plus className="mr-2 h-4 w-4" />
Add Member
</Button>
)}
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Manage Team Members</DialogTitle>
<DialogDescription>
Add researchers, wizards, or observers to collaborate on this study.
</DialogDescription>
</DialogHeader>
{/* Current Members */}
<div className="max-h-[200px] space-y-2 overflow-y-auto">
{members.map((member) => (
<div
key={member.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
<span className="text-sm font-medium text-primary">
{(member.user.name ?? member.user.email).charAt(0).toUpperCase()}
</span>
</div>
<div>
<p className="text-sm font-medium">
{member.user.name ?? member.user.email}
{member.role === "owner" && (
<Shield className="ml-2 inline h-3 w-3 text-amber-500" />
)}
</p>
<p className="text-muted-foreground text-xs capitalize">{member.role}</p>
</div>
</div>
{isOwner && member.role !== "owner" && (
<Button
variant="ghost"
size="sm"
onClick={() => handleRemove(member.id, member.user.name)}
disabled={removeMember.isPending}
>
<UserMinus className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
))}
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
placeholder="researcher@university.edu"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select value={role} onValueChange={setRole}>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="researcher">Researcher</SelectItem>
<SelectItem value="wizard">Wizard</SelectItem>
<SelectItem value="observer">Observer</SelectItem>
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
Researchers can design experiments, Wizards execute trials, Observers have read-only access.
</p>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={addMember.isPending}>
{addMember.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Add Member
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -163,6 +163,11 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
const form = useForm<TrialFormData>({ const form = useForm<TrialFormData>({
resolver: zodResolver(trialSchema), resolver: zodResolver(trialSchema),
defaultValues: { defaultValues: {
experimentId: "" as any,
participantId: "" as any,
scheduledAt: new Date(),
wizardId: undefined,
notes: "",
sessionNumber: 1, sessionNumber: 1,
}, },
}); });
@@ -347,7 +352,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
<FormField> <FormField>
<Label htmlFor="experimentId">Experiment *</Label> <Label htmlFor="experimentId">Experiment *</Label>
<Select <Select
value={form.watch("experimentId")} value={form.watch("experimentId") ?? ""}
onValueChange={(value) => form.setValue("experimentId", value)} onValueChange={(value) => form.setValue("experimentId", value)}
disabled={experimentsLoading || mode === "edit"} disabled={experimentsLoading || mode === "edit"}
> >
@@ -387,7 +392,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
<FormField> <FormField>
<Label htmlFor="participantId">Participant *</Label> <Label htmlFor="participantId">Participant *</Label>
<Select <Select
value={form.watch("participantId")} value={form.watch("participantId") ?? ""}
onValueChange={(value) => form.setValue("participantId", value)} onValueChange={(value) => form.setValue("participantId", value)}
disabled={participantsLoading || mode === "edit"} disabled={participantsLoading || mode === "edit"}
> >

View File

@@ -182,7 +182,7 @@ export function RobotActionsPanel({
// Get installed plugins for the study // Get installed plugins for the study
const { data: plugins = [], isLoading } = const { data: plugins = [], isLoading } =
api.robots.plugins.getStudyPlugins.useQuery({ api.studies.getStudyPlugins.useQuery({
studyId, studyId,
}); });

View File

@@ -32,6 +32,7 @@ import { WebcamPanel } from "./panels/WebcamPanel";
import { TrialStatusBar } from "./panels/TrialStatusBar"; import { TrialStatusBar } from "./panels/TrialStatusBar";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { useWizardRos } from "~/hooks/useWizardRos"; import { useWizardRos } from "~/hooks/useWizardRos";
import { useTrialWebSocket, type TrialEvent } from "~/hooks/useWebSocket";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTour } from "~/components/onboarding/TourProvider"; import { useTour } from "~/components/onboarding/TourProvider";
@@ -166,19 +167,36 @@ export const WizardInterface = React.memo(function WizardInterface({
}, },
}); });
// Robot initialization mutation (for startup routine) const [isInitializing, setIsInitializing] = useState(false);
const initializeRobotMutation = api.robots.plugins.initialize.useMutation({
onSuccess: () => { const initializeRobot = async () => {
setIsInitializing(true);
try {
const response = await fetch("/api/robots/command", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "initialize",
studyId: trial?.experiment.studyId,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to initialize robot");
}
toast.success("Robot initialized", { toast.success("Robot initialized", {
description: "Autonomous Life disabled and robot awake.", description: "Autonomous Life disabled and robot awake.",
}); });
}, } catch (error: any) {
onError: (error: any) => {
toast.error("Robot initialization failed", { toast.error("Robot initialization failed", {
description: error.message, description: error.message,
}); });
}, } finally {
}); setIsInitializing(false);
}
};
// Log robot action mutation (for client-side execution) // Log robot action mutation (for client-side execution)
const logRobotActionMutation = api.trials.logRobotAction.useMutation({ const logRobotActionMutation = api.trials.logRobotAction.useMutation({
@@ -187,8 +205,34 @@ export const WizardInterface = React.memo(function WizardInterface({
}, },
}); });
const executeSystemActionMutation = const executeSystemAction = async (
api.robots.plugins.executeSystemAction.useMutation(); actionId: string,
params?: Record<string, unknown>,
) => {
setIsExecutingAction(true);
try {
const response = await fetch("/api/robots/command", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "executeSystemAction",
studyId: trial?.experiment.studyId,
parameters: { id: actionId, parameters: params },
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to execute action");
}
return { success: true };
} catch (error) {
throw error;
} finally {
setIsExecutingAction(false);
}
};
const [isCompleting, setIsCompleting] = useState(false); const [isCompleting, setIsCompleting] = useState(false);
// Map database step types to component step types // Map database step types to component step types
@@ -235,10 +279,7 @@ export const WizardInterface = React.memo(function WizardInterface({
autoConnect: true, autoConnect: true,
onSystemAction: async (actionId, parameters) => { onSystemAction: async (actionId, parameters) => {
console.log(`[Wizard] Executing system action: ${actionId}`, parameters); console.log(`[Wizard] Executing system action: ${actionId}`, parameters);
await executeSystemActionMutation.mutateAsync({ await executeSystemAction(actionId, parameters);
id: actionId,
parameters,
});
}, },
}); });
@@ -252,59 +293,65 @@ export const WizardInterface = React.memo(function WizardInterface({
[setAutonomousLifeRaw], [setAutonomousLifeRaw],
); );
// Use polling for trial status updates (no trial WebSocket server exists) // Trial WebSocket for real-time updates
const { data: pollingData } = api.trials.get.useQuery( const {
{ id: trial.id }, isConnected: wsConnected,
{ connectionError: wsError,
refetchInterval: trial.status === "in_progress" ? 5000 : 15000, trialEvents: wsTrialEvents,
staleTime: 2000, currentTrialStatus,
refetchOnWindowFocus: false, addLocalEvent,
} = useTrialWebSocket(trial.id, {
onStatusChange: (status) => {
// Update local trial state when WebSocket reports status changes
setTrial((prev) => ({
...prev,
status: status.status,
startedAt: status.startedAt
? new Date(status.startedAt)
: prev.startedAt,
completedAt: status.completedAt
? new Date(status.completedAt)
: prev.completedAt,
}));
}, },
); onTrialEvent: (event) => {
// Optionally show toast for new events
// Poll for trial events if (event.eventType === "trial_started") {
const { data: fetchedEvents } = api.trials.getEvents.useQuery( toast.info("Trial started");
{ trialId: trial.id, limit: 100 }, } else if (event.eventType === "trial_completed") {
{ toast.info("Trial completed");
refetchInterval: 3000, } else if (event.eventType === "trial_aborted") {
staleTime: 1000, toast.warning("Trial aborted");
},
);
// Update local trial state from polling only if changed
useEffect(() => {
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) => {
// Double check inside setter to be safe
if (
prev.status === pollingData.status &&
prev.startedAt?.getTime() === pollingData.startedAt?.getTime() &&
prev.completedAt?.getTime() === pollingData.completedAt?.getTime()
) {
return prev;
}
return {
...prev,
status: pollingData.status,
startedAt: pollingData.startedAt
? new Date(pollingData.startedAt)
: prev.startedAt,
completedAt: pollingData.completedAt
? new Date(pollingData.completedAt)
: prev.completedAt,
};
});
} }
},
});
// Update trial state from WebSocket status
useEffect(() => {
if (currentTrialStatus) {
setTrial((prev) => {
if (
prev.status === currentTrialStatus.status &&
prev.startedAt?.getTime() ===
new Date(currentTrialStatus.startedAt ?? "").getTime() &&
prev.completedAt?.getTime() ===
new Date(currentTrialStatus.completedAt ?? "").getTime()
) {
return prev;
}
return {
...prev,
status: currentTrialStatus.status,
startedAt: currentTrialStatus.startedAt
? new Date(currentTrialStatus.startedAt)
: prev.startedAt,
completedAt: currentTrialStatus.completedAt
? new Date(currentTrialStatus.completedAt)
: prev.completedAt,
};
});
} }
// eslint-disable-next-line react-hooks/exhaustive-deps }, [currentTrialStatus]);
}, [pollingData]);
// Auto-start trial on mount if scheduled // Auto-start trial on mount if scheduled
useEffect(() => { useEffect(() => {
@@ -313,7 +360,7 @@ export const WizardInterface = React.memo(function WizardInterface({
} }
}, []); // Run once on mount }, []); // Run once on mount
// Trial events from robot actions // Trial events from WebSocket (and initial load)
const trialEvents = useMemo< const trialEvents = useMemo<
Array<{ Array<{
type: string; type: string;
@@ -322,8 +369,8 @@ export const WizardInterface = React.memo(function WizardInterface({
message?: string; message?: string;
}> }>
>(() => { >(() => {
return (fetchedEvents ?? []) return (wsTrialEvents ?? [])
.map((event) => { .map((event: TrialEvent) => {
let message: string | undefined; let message: string | undefined;
const eventData = event.data as any; const eventData = event.data as any;
@@ -364,7 +411,7 @@ export const WizardInterface = React.memo(function WizardInterface({
}; };
}) })
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); // Newest first .sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); // Newest first
}, [fetchedEvents]); }, [wsTrialEvents]);
// Transform experiment steps to component format // Transform experiment steps to component format
const steps: StepData[] = useMemo( const steps: StepData[] = useMemo(
@@ -542,7 +589,7 @@ export const WizardInterface = React.memo(function WizardInterface({
"[WizardInterface] Triggering robot initialization:", "[WizardInterface] Triggering robot initialization:",
trial.experiment.robotId, trial.experiment.robotId,
); );
initializeRobotMutation.mutate({ id: trial.experiment.robotId }); await initializeRobot();
} }
toast.success("Trial started successfully"); toast.success("Trial started successfully");

View File

@@ -1,7 +1,5 @@
"use client"; "use client";
/* eslint-disable react-hooks/exhaustive-deps */
import { useSession } from "~/lib/auth-client"; import { useSession } from "~/lib/auth-client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
@@ -56,10 +54,42 @@ interface TrialActionExecutedMessage {
interface InterventionLoggedMessage { interface InterventionLoggedMessage {
type: "intervention_logged"; type: "intervention_logged";
data: { data: {
intervention?: unknown;
timestamp: number; timestamp: number;
} & Record<string, unknown>; } & Record<string, unknown>;
} }
interface TrialEventMessage {
type: "trial_event";
data: {
event: unknown;
timestamp: number;
};
}
interface TrialEventsSnapshotMessage {
type: "trial_events_snapshot";
data: {
events: unknown[];
timestamp: number;
};
}
interface AnnotationAddedMessage {
type: "annotation_added";
data: {
annotation: unknown;
timestamp: number;
};
}
interface PongMessage {
type: "pong";
data: {
timestamp: number;
};
}
interface StepChangedMessage { interface StepChangedMessage {
type: "step_changed"; type: "step_changed";
data: { data: {
@@ -83,6 +113,10 @@ type KnownInboundMessage =
| TrialStatusMessage | TrialStatusMessage
| TrialActionExecutedMessage | TrialActionExecutedMessage
| InterventionLoggedMessage | InterventionLoggedMessage
| TrialEventMessage
| TrialEventsSnapshotMessage
| AnnotationAddedMessage
| PongMessage
| StepChangedMessage | StepChangedMessage
| ErrorMessage; | ErrorMessage;
@@ -98,18 +132,247 @@ export interface OutgoingMessage {
data: Record<string, unknown>; data: Record<string, unknown>;
} }
export interface UseWebSocketOptions { interface Subscription {
trialId: string; trialId: string;
onMessage?: (message: WebSocketMessage) => void; onMessage?: (message: WebSocketMessage) => void;
onConnect?: () => void; onConnect?: () => void;
onDisconnect?: () => void; onDisconnect?: () => void;
onError?: (error: Event) => void; onError?: (error: Event) => void;
reconnectAttempts?: number;
reconnectInterval?: number;
heartbeatInterval?: number;
} }
export interface UseWebSocketReturn { interface GlobalWSState {
isConnected: boolean;
isConnecting: boolean;
connectionError: string | null;
lastMessage: WebSocketMessage | null;
}
type StateListener = (state: GlobalWSState) => void;
class GlobalWebSocketManager {
private ws: WebSocket | null = null;
private subscriptions: Map<string, Subscription> = new Map();
private stateListeners: Set<StateListener> = new Set();
private sessionRef: { user: { id: string } } | null = null;
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
private attemptCount = 0;
private maxAttempts = 5;
private state: GlobalWSState = {
isConnected: false,
isConnecting: false,
connectionError: null,
lastMessage: null,
};
private setState(partial: Partial<GlobalWSState>) {
this.state = { ...this.state, ...partial };
this.notifyListeners();
}
private notifyListeners() {
this.stateListeners.forEach((listener) => listener(this.state));
}
subscribe(
session: { user: { id: string } } | null,
subscription: Subscription,
) {
this.sessionRef = session;
this.subscriptions.set(subscription.trialId, subscription);
if (this.subscriptions.size === 1 && !this.ws) {
this.connect();
}
return () => {
this.subscriptions.delete(subscription.trialId);
// Don't auto-disconnect - keep global connection alive
};
}
sendMessage(message: OutgoingMessage) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
}
}
connect() {
if (
this.ws?.readyState === WebSocket.CONNECTING ||
this.ws?.readyState === WebSocket.OPEN
) {
return;
}
if (!this.sessionRef?.user) {
this.setState({ connectionError: "No session", isConnecting: false });
return;
}
this.setState({ isConnecting: true, connectionError: null });
const token = btoa(JSON.stringify({ userId: this.sessionRef.user.id }));
const wsPort = process.env.NEXT_PUBLIC_WS_PORT || "3001";
// Collect all trial IDs from subscriptions
const trialIds = Array.from(this.subscriptions.keys());
const trialIdParam = trialIds.length > 0 ? `&trialId=${trialIds[0]}` : "";
const url = `ws://${typeof window !== "undefined" ? window.location.hostname : "localhost"}:${wsPort}/api/websocket?token=${token}${trialIdParam}`;
try {
this.ws = new WebSocket(url);
this.ws.onopen = () => {
console.log("[GlobalWS] Connected");
this.setState({ isConnected: true, isConnecting: false });
this.attemptCount = 0;
this.startHeartbeat();
// Subscribe to all subscribed trials
this.subscriptions.forEach((sub) => {
this.ws?.send(
JSON.stringify({
type: "subscribe",
data: { trialId: sub.trialId },
}),
);
});
this.subscriptions.forEach((sub) => sub.onConnect?.());
};
this.ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data) as WebSocketMessage;
this.setState({ lastMessage: message });
if (message.type === "connection_established") {
const data = (message as ConnectionEstablishedMessage).data;
const sub = this.subscriptions.get(data.trialId);
if (sub) {
sub.onMessage?.(message);
}
} else if (
message.type === "trial_event" ||
message.type === "trial_status"
) {
const data = (message as TrialEventMessage).data;
const event = data.event as { trialId?: string };
if (event?.trialId) {
const sub = this.subscriptions.get(event.trialId);
sub?.onMessage?.(message);
}
} else {
// Broadcast to all subscriptions
this.subscriptions.forEach((sub) => sub.onMessage?.(message));
}
} catch (error) {
console.error("[GlobalWS] Failed to parse message:", error);
}
};
this.ws.onclose = (event) => {
console.log("[GlobalWS] Disconnected:", event.code);
this.setState({ isConnected: false, isConnecting: false });
this.stopHeartbeat();
this.subscriptions.forEach((sub) => sub.onDisconnect?.());
// Auto-reconnect if not intentionally closed
if (event.code !== 1000 && this.subscriptions.size > 0) {
this.scheduleReconnect();
}
};
this.ws.onerror = (error) => {
console.error("[GlobalWS] Error:", error);
this.setState({
connectionError: "Connection error",
isConnecting: false,
});
this.subscriptions.forEach((sub) =>
sub.onError?.(new Event("ws_error")),
);
};
} catch (error) {
console.error("[GlobalWS] Failed to create:", error);
this.setState({
connectionError: "Failed to create connection",
isConnecting: false,
});
}
}
disconnect() {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
this.stopHeartbeat();
if (this.ws) {
this.ws.close(1000, "Manual disconnect");
this.ws = null;
}
this.setState({ isConnected: false, isConnecting: false });
}
private startHeartbeat() {
this.heartbeatInterval = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: "heartbeat", data: {} }));
}
}, 30000);
}
private stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
private scheduleReconnect() {
if (this.attemptCount >= this.maxAttempts) {
this.setState({ connectionError: "Max reconnection attempts reached" });
return;
}
const delay = Math.min(30000, 1000 * Math.pow(1.5, this.attemptCount));
this.attemptCount++;
console.log(
`[GlobalWS] Reconnecting in ${delay}ms (attempt ${this.attemptCount})`,
);
this.reconnectTimeout = setTimeout(() => {
if (this.subscriptions.size > 0) {
this.connect();
}
}, delay);
}
getState(): GlobalWSState {
return this.state;
}
addListener(listener: StateListener) {
this.stateListeners.add(listener);
return () => this.stateListeners.delete(listener);
}
}
const globalWS = new GlobalWebSocketManager();
export interface UseGlobalWebSocketOptions {
trialId: string;
onMessage?: (message: WebSocketMessage) => void;
onConnect?: () => void;
onDisconnect?: () => void;
onError?: (error: Event) => void;
}
export interface UseGlobalWebSocketReturn {
isConnected: boolean; isConnected: boolean;
isConnecting: boolean; isConnecting: boolean;
connectionError: string | null; connectionError: string | null;
@@ -119,333 +382,66 @@ export interface UseWebSocketReturn {
lastMessage: WebSocketMessage | null; lastMessage: WebSocketMessage | null;
} }
export function useWebSocket({ export function useGlobalWebSocket({
trialId, trialId,
onMessage, onMessage,
onConnect, onConnect,
onDisconnect, onDisconnect,
onError, onError,
reconnectAttempts = 5, }: UseGlobalWebSocketOptions): UseGlobalWebSocketReturn {
reconnectInterval = 3000,
heartbeatInterval = 30000,
}: UseWebSocketOptions): UseWebSocketReturn {
const { data: session } = useSession(); const { data: session } = useSession();
const [isConnected, setIsConnected] = useState<boolean>(false); const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState<boolean>(false); const [isConnecting, setIsConnecting] = useState(false);
const [connectionError, setConnectionError] = useState<string | null>(null); const [connectionError, setConnectionError] = useState<string | null>(null);
const [hasAttemptedConnection, setHasAttemptedConnection] =
useState<boolean>(false);
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null); const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
const wsRef = useRef<WebSocket | null>(null); const onMessageRef = useRef(onMessage);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null); const onConnectRef = useRef(onConnect);
const heartbeatTimeoutRef = useRef<NodeJS.Timeout | null>(null); const onDisconnectRef = useRef(onDisconnect);
const attemptCountRef = useRef<number>(0); const onErrorRef = useRef(onError);
const mountedRef = useRef<boolean>(true);
const connectionStableTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Generate auth token (simplified - in production use proper JWT) onMessageRef.current = onMessage;
const getAuthToken = useCallback((): string | null => { onConnectRef.current = onConnect;
if (!session?.user) return null; onDisconnectRef.current = onDisconnect;
// In production, this would be a proper JWT token onErrorRef.current = onError;
return btoa(
JSON.stringify({ userId: session.user.id, timestamp: Date.now() }),
);
}, [session]);
const sendMessage = useCallback((message: OutgoingMessage): void => { useEffect(() => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { const unsubscribe = globalWS.subscribe(session, {
wsRef.current.send(JSON.stringify(message)); trialId,
} else { onMessage: (msg) => {
console.warn("WebSocket not connected, message not sent:", message); setLastMessage(msg);
} onMessageRef.current?.(msg);
}, []); },
onConnect: () => {
const sendHeartbeat = useCallback((): void => { setIsConnected(true);
sendMessage({ type: "heartbeat", data: {} });
}, [sendMessage]);
const scheduleHeartbeat = useCallback((): void => {
if (heartbeatTimeoutRef.current) {
clearTimeout(heartbeatTimeoutRef.current);
}
heartbeatTimeoutRef.current = setTimeout(() => {
if (isConnected && mountedRef.current) {
sendHeartbeat();
scheduleHeartbeat();
}
}, heartbeatInterval);
}, [isConnected, sendHeartbeat, heartbeatInterval]);
const handleMessage = useCallback(
(event: MessageEvent<string>): void => {
try {
const message = JSON.parse(event.data) as WebSocketMessage;
setLastMessage(message);
// Handle system messages
switch (message.type) {
case "connection_established": {
console.log(
"WebSocket connection established:",
(message as ConnectionEstablishedMessage).data,
);
setIsConnected(true);
setIsConnecting(false);
setConnectionError(null);
attemptCountRef.current = 0;
scheduleHeartbeat();
onConnect?.();
break;
}
case "heartbeat_response":
// Heartbeat acknowledged, connection is alive
break;
case "error": {
console.error("WebSocket server error:", message);
const msg =
(message as ErrorMessage).data?.message ?? "Server error";
setConnectionError(msg);
onError?.(new Event("server_error"));
break;
}
default:
// Pass to user-defined message handler
onMessage?.(message);
break;
}
} catch (error) {
console.error("Error parsing WebSocket message:", error);
setConnectionError("Failed to parse message");
}
},
[onMessage, onConnect, onError, scheduleHeartbeat],
);
const handleClose = useCallback(
(event: CloseEvent): void => {
console.log("WebSocket connection closed:", event.code, event.reason);
setIsConnected(false);
setIsConnecting(false);
if (heartbeatTimeoutRef.current) {
clearTimeout(heartbeatTimeoutRef.current);
}
onDisconnect?.();
// Attempt reconnection if not manually closed and component is still mounted
// In development, don't aggressively reconnect to prevent UI flashing
if (
event.code !== 1000 &&
mountedRef.current &&
attemptCountRef.current < reconnectAttempts &&
process.env.NODE_ENV !== "development"
) {
attemptCountRef.current++;
const delay =
reconnectInterval * Math.pow(1.5, attemptCountRef.current - 1); // Exponential backoff
console.log(
`Attempting reconnection ${attemptCountRef.current}/${reconnectAttempts} in ${delay}ms`,
);
setConnectionError(
`Connection lost. Reconnecting... (${attemptCountRef.current}/${reconnectAttempts})`,
);
reconnectTimeoutRef.current = setTimeout(() => {
if (mountedRef.current) {
attemptCountRef.current = 0;
setIsConnecting(true);
setConnectionError(null);
}
}, delay);
} else if (attemptCountRef.current >= reconnectAttempts) {
setConnectionError("Failed to reconnect after maximum attempts");
} else if (
process.env.NODE_ENV === "development" &&
event.code !== 1000
) {
// In development, set a stable error message without reconnection attempts
setConnectionError("WebSocket unavailable - using polling mode");
}
},
[onDisconnect, reconnectAttempts, reconnectInterval],
);
const handleError = useCallback(
(event: Event): void => {
// In development, WebSocket failures are expected with Edge Runtime
if (process.env.NODE_ENV === "development") {
// Only set error state after the first failed attempt to prevent flashing
if (!hasAttemptedConnection) {
setHasAttemptedConnection(true);
// Debounce the error state to prevent UI flashing
if (connectionStableTimeoutRef.current) {
clearTimeout(connectionStableTimeoutRef.current);
}
connectionStableTimeoutRef.current = setTimeout(() => {
setConnectionError("WebSocket unavailable - using polling mode");
setIsConnecting(false);
}, 1000);
}
} else {
console.error("WebSocket error:", event);
setConnectionError("Connection error");
setIsConnecting(false); setIsConnecting(false);
} setConnectionError(null);
onError?.(event); onConnectRef.current?.();
}, },
[onError, hasAttemptedConnection], onDisconnect: () => {
); setIsConnected(false);
onDisconnectRef.current?.();
},
onError: (err) => {
setConnectionError("Connection error");
onErrorRef.current?.(err);
},
});
const connectInternal = useCallback((): void => { return unsubscribe;
if (!session?.user || !trialId) { }, [trialId, session]);
if (!hasAttemptedConnection) {
setConnectionError("Missing authentication or trial ID");
setHasAttemptedConnection(true);
}
return;
}
if ( const sendMessage = useCallback((message: OutgoingMessage) => {
wsRef.current && globalWS.sendMessage(message);
(wsRef.current.readyState === WebSocket.CONNECTING ||
wsRef.current.readyState === WebSocket.OPEN)
) {
return; // Already connecting or connected
}
const token = getAuthToken();
if (!token) {
if (!hasAttemptedConnection) {
setConnectionError("Failed to generate auth token");
setHasAttemptedConnection(true);
}
return;
}
// Only show connecting state for the first attempt or if we've been stable
if (!hasAttemptedConnection || isConnected) {
setIsConnecting(true);
}
// Clear any pending error updates
if (connectionStableTimeoutRef.current) {
clearTimeout(connectionStableTimeoutRef.current);
}
setConnectionError(null);
try {
// Use appropriate WebSocket URL based on environment
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/api/websocket?trialId=${trialId}&token=${token}`;
wsRef.current = new WebSocket(wsUrl);
wsRef.current.onmessage = handleMessage;
wsRef.current.onclose = handleClose;
wsRef.current.onerror = handleError;
wsRef.current.onopen = () => {
console.log("WebSocket connection opened");
// Connection establishment is handled in handleMessage
};
} catch (error) {
console.error("Failed to create WebSocket connection:", error);
if (!hasAttemptedConnection) {
setConnectionError("Failed to create connection");
setHasAttemptedConnection(true);
}
setIsConnecting(false);
}
}, [
session,
trialId,
getAuthToken,
handleMessage,
handleClose,
handleError,
hasAttemptedConnection,
isConnected,
]);
const disconnect = useCallback((): void => {
mountedRef.current = false;
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (heartbeatTimeoutRef.current) {
clearTimeout(heartbeatTimeoutRef.current);
}
if (connectionStableTimeoutRef.current) {
clearTimeout(connectionStableTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close(1000, "Manual disconnect");
wsRef.current = null;
}
setIsConnected(false);
setIsConnecting(false);
setConnectionError(null);
setHasAttemptedConnection(false);
attemptCountRef.current = 0;
}, []); }, []);
const reconnect = useCallback((): void => { const disconnect = useCallback(() => {
disconnect(); globalWS.disconnect();
mountedRef.current = true; }, []);
attemptCountRef.current = 0;
setHasAttemptedConnection(false);
setTimeout(() => {
if (mountedRef.current) {
void connectInternal();
}
}, 100); // Small delay to ensure cleanup
}, [disconnect, connectInternal]);
// Effect to establish initial connection const reconnect = useCallback(() => {
useEffect(() => { globalWS.connect();
if (session?.user?.id && trialId) { }, []);
// In development, only attempt connection once to prevent flashing
if (process.env.NODE_ENV === "development" && hasAttemptedConnection) {
return;
}
// Trigger reconnection if timeout was set
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
void connectInternal();
} else {
void connectInternal();
}
}
return () => {
mountedRef.current = false;
disconnect();
};
}, [session?.user?.id, trialId, hasAttemptedConnection]);
// Cleanup on unmount
useEffect(() => {
return () => {
mountedRef.current = false;
if (connectionStableTimeoutRef.current) {
clearTimeout(connectionStableTimeoutRef.current);
}
disconnect();
};
}, [disconnect]);
return { return {
isConnected, isConnected,
@@ -458,115 +454,180 @@ export function useWebSocket({
}; };
} }
// Hook for trial-specific WebSocket events // Legacy alias
export function useTrialWebSocket(trialId: string) { export const useWebSocket = useGlobalWebSocket;
const [trialEvents, setTrialEvents] = useState<WebSocketMessage[]>([]);
const [currentTrialStatus, setCurrentTrialStatus] =
useState<TrialSnapshot | null>(null);
const [wizardActions, setWizardActions] = useState<WebSocketMessage[]>([]);
const handleMessage = useCallback((message: WebSocketMessage): void => { // Trial-specific hook
// Add to events log export interface TrialEvent {
setTrialEvents((prev) => [...prev, message].slice(-100)); // Keep last 100 events id: string;
trialId: string;
eventType: string;
data: Record<string, unknown> | null;
timestamp: Date;
createdBy?: string | null;
}
switch (message.type) { export interface TrialWebSocketState {
case "trial_status": { trialEvents: TrialEvent[];
const data = (message as TrialStatusMessage).data; currentTrialStatus: TrialSnapshot | null;
setCurrentTrialStatus(data.trial); wizardActions: WebSocketMessage[];
break; }
export function useTrialWebSocket(
trialId: string,
options?: {
onTrialEvent?: (event: TrialEvent) => void;
onStatusChange?: (status: TrialSnapshot) => void;
initialEvents?: TrialEvent[];
initialStatus?: TrialSnapshot | null;
},
) {
const [state, setState] = useState<TrialWebSocketState>({
trialEvents: options?.initialEvents ?? [],
currentTrialStatus: options?.initialStatus ?? null,
wizardActions: [],
});
const handleMessage = useCallback(
(message: WebSocketMessage): void => {
switch (message.type) {
case "trial_status": {
const data = (message as TrialStatusMessage).data;
const status = data.trial as TrialSnapshot;
setState((prev) => ({
...prev,
currentTrialStatus: status,
}));
options?.onStatusChange?.(status);
break;
}
case "trial_events_snapshot": {
const data = (message as TrialEventsSnapshotMessage).data;
const events = (
data.events as Array<{
id: string;
trialId: string;
eventType: string;
data: Record<string, unknown> | null;
timestamp: Date | string;
createdBy?: string | null;
}>
).map((e) => ({
...e,
timestamp:
typeof e.timestamp === "string"
? new Date(e.timestamp)
: e.timestamp,
}));
setState((prev) => ({
...prev,
trialEvents: events,
}));
break;
}
case "trial_event": {
const data = (message as TrialEventMessage).data;
const event = data.event as {
id: string;
trialId: string;
eventType: string;
data: Record<string, unknown> | null;
timestamp: Date | string;
createdBy?: string | null;
};
const newEvent: TrialEvent = {
...event,
timestamp:
typeof event.timestamp === "string"
? new Date(event.timestamp)
: event.timestamp,
};
setState((prev) => ({
...prev,
trialEvents: [...prev.trialEvents, newEvent].slice(-500),
}));
options?.onTrialEvent?.(newEvent);
break;
}
case "trial_action_executed":
case "intervention_logged":
case "annotation_added":
case "step_changed": {
setState((prev) => ({
...prev,
wizardActions: [...prev.wizardActions, message].slice(-100),
}));
break;
}
case "pong":
break;
default:
if (process.env.NODE_ENV === "development") {
console.log(`[WS] Unknown message type: ${message.type}`);
}
} }
},
[options],
);
case "trial_action_executed": const webSocket = useGlobalWebSocket({
case "intervention_logged":
case "step_changed":
setWizardActions((prev) => [...prev, message].slice(-50)); // Keep last 50 actions
break;
case "step_changed":
// Handle step transitions (optional logging)
console.log("Step changed:", (message as StepChangedMessage).data);
break;
default:
// Handle other trial-specific messages
break;
}
}, []);
const webSocket = useWebSocket({
trialId, trialId,
onMessage: handleMessage, onMessage: handleMessage,
onConnect: () => { onConnect: () => {
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
console.log(`Connected to trial ${trialId} WebSocket`); console.log(`[WS] Connected to trial ${trialId}`);
} }
}, },
onDisconnect: () => { onDisconnect: () => {
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
console.log(`Disconnected from trial ${trialId} WebSocket`); console.log(`[WS] Disconnected from trial ${trialId}`);
} }
}, },
onError: () => { onError: () => {
// Suppress noisy WebSocket errors in development
if (process.env.NODE_ENV !== "development") { if (process.env.NODE_ENV !== "development") {
console.error(`Trial ${trialId} WebSocket connection failed`); console.error(`[WS] Trial ${trialId} WebSocket connection failed`);
} }
}, },
}); });
// Request trial status after connection is established // Request initial data after connection is established
useEffect(() => { useEffect(() => {
if (webSocket.isConnected) { if (webSocket.isConnected) {
webSocket.sendMessage({ type: "request_trial_status", data: {} }); webSocket.sendMessage({ type: "request_trial_status", data: {} });
webSocket.sendMessage({
type: "request_trial_events",
data: { limit: 500 },
});
} }
}, [webSocket.isConnected, webSocket]); }, [webSocket.isConnected]);
// Trial-specific actions // Helper to add an event locally (for optimistic updates)
const executeTrialAction = useCallback( const addLocalEvent = useCallback((event: TrialEvent) => {
(actionType: string, actionData: Record<string, unknown>): void => { setState((prev) => ({
webSocket.sendMessage({ ...prev,
type: "trial_action", trialEvents: [...prev.trialEvents, event].slice(-500),
data: { }));
actionType, }, []);
...actionData,
},
});
},
[webSocket],
);
const logWizardIntervention = useCallback( // Helper to update trial status locally
(interventionData: Record<string, unknown>): void => { const updateLocalStatus = useCallback((status: TrialSnapshot) => {
webSocket.sendMessage({ setState((prev) => ({
type: "wizard_intervention", ...prev,
data: interventionData, currentTrialStatus: status,
}); }));
}, }, []);
[webSocket],
);
const transitionStep = useCallback(
(stepData: {
from_step?: number;
to_step: number;
step_name?: string;
[k: string]: unknown;
}): void => {
webSocket.sendMessage({
type: "step_transition",
data: stepData,
});
},
[webSocket],
);
return { return {
...webSocket, ...webSocket,
trialEvents, trialEvents: state.trialEvents,
currentTrialStatus, currentTrialStatus: state.currentTrialStatus,
wizardActions, wizardActions: state.wizardActions,
executeTrialAction, addLocalEvent,
logWizardIntervention, updateLocalStatus,
transitionStep,
}; };
} }

View File

@@ -19,7 +19,7 @@ const s3Client = new S3Client({
forcePathStyle: true, // Required for MinIO forcePathStyle: true, // Required for MinIO
}); });
const BUCKET_NAME = env.MINIO_BUCKET_NAME ?? "hristudio"; const BUCKET_NAME = env.MINIO_BUCKET_NAME ?? "hristudio-data";
const PRESIGNED_URL_EXPIRY = 3600; // 1 hour in seconds const PRESIGNED_URL_EXPIRY = 3600; // 1 hour in seconds
export interface UploadParams { export interface UploadParams {

View File

@@ -5,9 +5,10 @@ import { collaborationRouter } from "~/server/api/routers/collaboration";
import { dashboardRouter } from "~/server/api/routers/dashboard"; import { dashboardRouter } from "~/server/api/routers/dashboard";
import { experimentsRouter } from "~/server/api/routers/experiments"; import { experimentsRouter } from "~/server/api/routers/experiments";
import { filesRouter } from "~/server/api/routers/files"; import { filesRouter } from "~/server/api/routers/files";
import { formsRouter } from "~/server/api/routers/forms";
import { mediaRouter } from "~/server/api/routers/media"; import { mediaRouter } from "~/server/api/routers/media";
import { participantsRouter } from "~/server/api/routers/participants"; import { participantsRouter } from "~/server/api/routers/participants";
import { robotsRouter } from "~/server/api/routers/robots"; import { pluginsRouter } from "~/server/api/routers/plugins";
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";
@@ -26,14 +27,15 @@ export const appRouter = createTRPCRouter({
experiments: experimentsRouter, experiments: experimentsRouter,
participants: participantsRouter, participants: participantsRouter,
trials: trialsRouter, trials: trialsRouter,
robots: robotsRouter,
files: filesRouter, files: filesRouter,
media: mediaRouter, media: mediaRouter,
plugins: pluginsRouter,
analytics: analyticsRouter, analytics: analyticsRouter,
collaboration: collaborationRouter, collaboration: collaborationRouter,
admin: adminRouter, admin: adminRouter,
dashboard: dashboardRouter, dashboard: dashboardRouter,
storage: storageRouter, storage: storageRouter,
forms: formsRouter,
}); });
// export type definition of API // export type definition of API

View File

@@ -143,6 +143,7 @@ export const dashboardRouter = createTRPCRouter({
const live = await ctx.db const live = await ctx.db
.select({ .select({
id: trials.id, id: trials.id,
studyId: studies.id,
startedAt: trials.startedAt, startedAt: trials.startedAt,
experimentName: experiments.name, experimentName: experiments.name,
participantCode: participants.participantCode, participantCode: participants.participantCode,

View File

@@ -18,7 +18,7 @@ const minioClient = new Minio.Client({
secretKey: env.MINIO_SECRET_KEY ?? "minioadmin", secretKey: env.MINIO_SECRET_KEY ?? "minioadmin",
}); });
const BUCKET_NAME = env.MINIO_BUCKET_NAME ?? "hristudio-assets"; const BUCKET_NAME = env.MINIO_BUCKET_NAME ?? "hristudio";
// Ensure bucket exists on startup (best effort) // Ensure bucket exists on startup (best effort)
const ensureBucket = async () => { const ensureBucket = async () => {

View File

@@ -0,0 +1,863 @@
import { TRPCError } from "@trpc/server";
import { and, count, desc, eq, ilike, or } from "drizzle-orm";
import { z } from "zod";
import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
} from "~/server/api/trpc";
import {
activityLogs,
formResponses,
formTypeEnum,
forms,
formFieldTypeEnum,
participants,
studyMembers,
studies,
userSystemRoles,
} from "~/server/db/schema";
const formTypes = formTypeEnum.enumValues;
const fieldTypes = formFieldTypeEnum.enumValues;
async function checkStudyAccess(
db: typeof import("~/server/db").db,
userId: string,
studyId: string,
requiredRole?: string[],
) {
const adminRole = await db.query.userSystemRoles.findFirst({
where: and(
eq(userSystemRoles.userId, userId),
eq(userSystemRoles.role, "administrator"),
),
});
if (adminRole) {
return { role: "administrator", studyId, userId, joinedAt: new Date() };
}
const membership = await db.query.studyMembers.findFirst({
where: and(
eq(studyMembers.studyId, studyId),
eq(studyMembers.userId, userId),
),
});
if (!membership) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have access to this study",
});
}
if (requiredRole && !requiredRole.includes(membership.role)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have permission to perform this action",
});
}
return membership;
}
export const formsRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
studyId: z.string().uuid(),
type: z.enum(formTypes).optional(),
search: z.string().optional(),
page: z.number().min(1).default(1),
limit: z.number().min(1).max(100).default(20),
}),
)
.query(async ({ ctx, input }) => {
const { studyId, type, search, page, limit } = input;
const offset = (page - 1) * limit;
await checkStudyAccess(ctx.db, ctx.session.user.id, studyId);
const conditions = [eq(forms.studyId, studyId)];
if (type) {
conditions.push(eq(forms.type, type));
}
if (search) {
conditions.push(
or(
ilike(forms.title, `%${search}%`),
ilike(forms.description, `%${search}%`),
)!,
);
}
const [formsList, totalCount] = await Promise.all([
ctx.db.query.forms.findMany({
where: and(...conditions),
with: {
createdBy: {
columns: {
id: true,
name: true,
email: true,
},
},
},
orderBy: [desc(forms.updatedAt)],
limit,
offset,
}),
ctx.db
.select({ count: count() })
.from(forms)
.where(and(...conditions)),
]);
const formsWithCounts = await Promise.all(
formsList.map(async (form) => {
const responseCount = await ctx.db
.select({ count: count() })
.from(formResponses)
.where(eq(formResponses.formId, form.id));
return {
...form,
_count: { responses: responseCount[0]?.count ?? 0 },
};
}),
);
return {
forms: formsWithCounts,
pagination: {
page,
limit,
total: totalCount[0]?.count ?? 0,
pages: Math.ceil((totalCount[0]?.count ?? 0) / limit),
},
};
}),
get: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const form = await ctx.db.query.forms.findFirst({
where: eq(forms.id, input.id),
with: {
createdBy: {
columns: {
id: true,
name: true,
email: true,
},
},
responses: {
with: {
participant: {
columns: {
id: true,
participantCode: true,
name: true,
},
},
},
orderBy: [desc(formResponses.submittedAt)],
},
},
});
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form not found",
});
}
await checkStudyAccess(ctx.db, ctx.session.user.id, form.studyId);
return form;
}),
create: protectedProcedure
.input(
z.object({
studyId: z.string().uuid(),
type: z.enum(formTypes),
title: z.string().min(1).max(255),
description: z.string().optional(),
fields: z
.array(
z.object({
id: z.string(),
type: z.string(),
label: z.string(),
required: z.boolean().default(false),
options: z.array(z.string()).optional(),
settings: z.record(z.string(), z.any()).optional(),
}),
)
.default([]),
settings: z.record(z.string(), z.any()).optional(),
isTemplate: z.boolean().optional(),
templateName: z.string().max(100).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const { isTemplate, templateName, ...formData } = input;
if (isTemplate && !templateName) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Template name is required when creating a template",
});
}
await checkStudyAccess(ctx.db, ctx.session.user.id, input.studyId, [
"owner",
"researcher",
]);
const [newForm] = await ctx.db
.insert(forms)
.values({
studyId: formData.studyId,
type: formData.type,
title: formData.title,
description: formData.description,
fields: formData.fields,
settings: formData.settings ?? {},
isTemplate: isTemplate ?? false,
templateName: templateName,
createdBy: ctx.session.user.id,
})
.returning();
if (!newForm) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create form",
});
}
await ctx.db.insert(activityLogs).values({
studyId: input.studyId,
userId: ctx.session.user.id,
action: "form_created",
description: `Created form "${newForm.title}"`,
resourceType: "form",
resourceId: newForm.id,
});
return newForm;
}),
update: protectedProcedure
.input(
z.object({
id: z.string().uuid(),
title: z.string().min(1).max(255).optional(),
description: z.string().optional(),
fields: z
.array(
z.object({
id: z.string(),
type: z.string(),
label: z.string(),
required: z.boolean().default(false),
options: z.array(z.string()).optional(),
settings: z.record(z.string(), z.any()).optional(),
}),
)
.optional(),
settings: z.record(z.string(), z.any()).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const { id, ...updateData } = input;
const existingForm = await ctx.db.query.forms.findFirst({
where: eq(forms.id, id),
});
if (!existingForm) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form not found",
});
}
await checkStudyAccess(
ctx.db,
ctx.session.user.id,
existingForm.studyId,
["owner", "researcher"],
);
const [updatedForm] = await ctx.db
.update(forms)
.set({
...updateData,
updatedAt: new Date(),
})
.where(eq(forms.id, id))
.returning();
if (!updatedForm) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to update form",
});
}
await ctx.db.insert(activityLogs).values({
studyId: existingForm.studyId,
userId: ctx.session.user.id,
action: "form_updated",
description: `Updated form "${updatedForm.title}"`,
resourceType: "form",
resourceId: id,
});
return updatedForm;
}),
delete: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const form = await ctx.db.query.forms.findFirst({
where: eq(forms.id, input.id),
});
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form not found",
});
}
await checkStudyAccess(ctx.db, ctx.session.user.id, form.studyId, [
"owner",
"researcher",
]);
await ctx.db.delete(forms).where(eq(forms.id, input.id));
await ctx.db.insert(activityLogs).values({
studyId: form.studyId,
userId: ctx.session.user.id,
action: "form_deleted",
description: `Deleted form "${form.title}"`,
resourceType: "form",
resourceId: input.id,
});
return { success: true };
}),
setActive: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const form = await ctx.db.query.forms.findFirst({
where: eq(forms.id, input.id),
});
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form not found",
});
}
await checkStudyAccess(ctx.db, ctx.session.user.id, form.studyId, [
"owner",
"researcher",
]);
await ctx.db
.update(forms)
.set({ active: false })
.where(eq(forms.studyId, form.studyId));
const [updatedForm] = await ctx.db
.update(forms)
.set({ active: true })
.where(eq(forms.id, input.id))
.returning();
return updatedForm;
}),
createVersion: protectedProcedure
.input(
z.object({
id: z.string().uuid(),
title: z.string().min(1).max(255).optional(),
description: z.string().optional(),
fields: z.array(
z.object({
id: z.string(),
type: z.string(),
label: z.string(),
required: z.boolean().default(false),
options: z.array(z.string()).optional(),
settings: z.record(z.string(), z.any()).optional(),
}),
),
settings: z.record(z.string(), z.any()).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const { id, ...updateData } = input;
const existingForm = await ctx.db.query.forms.findFirst({
where: eq(forms.id, id),
});
if (!existingForm) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form not found",
});
}
await checkStudyAccess(
ctx.db,
ctx.session.user.id,
existingForm.studyId,
["owner", "researcher"],
);
const latestForm = await ctx.db.query.forms.findFirst({
where: eq(forms.studyId, existingForm.studyId),
orderBy: [desc(forms.version)],
});
const newVersion = (latestForm?.version ?? 0) + 1;
const [newForm] = await ctx.db
.insert(forms)
.values({
studyId: existingForm.studyId,
type: existingForm.type,
title: updateData.title ?? existingForm.title,
description: updateData.description ?? existingForm.description,
fields: updateData.fields ?? existingForm.fields,
settings: updateData.settings ?? existingForm.settings,
version: newVersion,
active: false,
createdBy: ctx.session.user.id,
})
.returning();
if (!newForm) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create form version",
});
}
await ctx.db.insert(activityLogs).values({
studyId: existingForm.studyId,
userId: ctx.session.user.id,
action: "form_version_created",
description: `Created version ${newVersion} of form "${newForm.title}"`,
resourceType: "form",
resourceId: newForm.id,
});
return newForm;
}),
getResponses: protectedProcedure
.input(
z.object({
formId: z.string().uuid(),
page: z.number().min(1).default(1),
limit: z.number().min(1).max(100).default(20),
status: z.enum(["pending", "completed", "rejected"]).optional(),
}),
)
.query(async ({ ctx, input }) => {
const { formId, page, limit, status } = input;
const offset = (page - 1) * limit;
const form = await ctx.db.query.forms.findFirst({
where: eq(forms.id, formId),
});
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form not found",
});
}
await checkStudyAccess(ctx.db, ctx.session.user.id, form.studyId);
const conditions = [eq(formResponses.formId, formId)];
if (status) {
conditions.push(eq(formResponses.status, status));
}
const [responses, totalCount] = await Promise.all([
ctx.db.query.formResponses.findMany({
where: and(...conditions),
with: {
participant: {
columns: {
id: true,
participantCode: true,
name: true,
email: true,
},
},
},
orderBy: [desc(formResponses.submittedAt)],
limit,
offset,
}),
ctx.db
.select({ count: count() })
.from(formResponses)
.where(and(...conditions)),
]);
return {
responses,
pagination: {
page,
limit,
total: totalCount[0]?.count ?? 0,
pages: Math.ceil((totalCount[0]?.count ?? 0) / limit),
},
};
}),
exportCsv: protectedProcedure
.input(z.object({ formId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const form = await ctx.db.query.forms.findFirst({
where: eq(forms.id, input.formId),
});
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form not found",
});
}
await checkStudyAccess(ctx.db, ctx.session.user.id, form.studyId);
const responses = await ctx.db.query.formResponses.findMany({
where: eq(formResponses.formId, input.formId),
with: {
participant: {
columns: {
id: true,
participantCode: true,
name: true,
email: true,
},
},
},
orderBy: [desc(formResponses.submittedAt)],
});
const fields = form.fields as Array<{
id: string;
label: string;
type: string;
}>;
const headers = [
"Participant Code",
"Name",
"Email",
"Status",
"Submitted At",
...fields.map((f) => f.label),
];
const rows = responses.map((r) => {
const participantResponses = r.responses as Record<string, any>;
return [
r.participant?.participantCode ?? "",
r.participant?.name ?? "",
r.participant?.email ?? "",
r.status,
r.submittedAt?.toISOString() ?? "",
...fields.map((f) => {
const val = participantResponses[f.id];
if (val === undefined || val === null) return "";
if (typeof val === "boolean") return val ? "Yes" : "No";
return String(val);
}),
];
});
const escape = (s: string | null | undefined) =>
`"${String(s ?? "").replace(/"/g, '""')}"`;
const csv = [
headers.map((h) => escape(h)).join(","),
...rows.map((row) => row.map((cell) => escape(cell)).join(",")),
].join("\n");
return {
csv,
filename: `${form.title.replace(/\s+/g, "_")}_responses.csv`,
};
}),
submitResponse: protectedProcedure
.input(
z.object({
formId: z.string().uuid(),
participantId: z.string().uuid(),
responses: z.record(z.string(), z.any()),
signatureData: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const { formId, participantId, responses, signatureData } = input;
const form = await ctx.db.query.forms.findFirst({
where: eq(forms.id, formId),
});
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form not found",
});
}
await checkStudyAccess(ctx.db, ctx.session.user.id, form.studyId);
const existingResponse = await ctx.db.query.formResponses.findFirst({
where: and(
eq(formResponses.formId, formId),
eq(formResponses.participantId, participantId),
),
});
if (existingResponse) {
throw new TRPCError({
code: "CONFLICT",
message: "Participant has already submitted this form",
});
}
const [newResponse] = await ctx.db
.insert(formResponses)
.values({
formId,
participantId,
responses,
signatureData,
status: signatureData ? "completed" : "pending",
signedAt: signatureData ? new Date() : null,
})
.returning();
return newResponse;
}),
listVersions: protectedProcedure
.input(z.object({ studyId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
await checkStudyAccess(ctx.db, ctx.session.user.id, input.studyId);
const formsList = await ctx.db.query.forms.findMany({
where: eq(forms.studyId, input.studyId),
with: {
createdBy: {
columns: {
id: true,
name: true,
email: true,
},
},
},
orderBy: [desc(forms.version)],
});
const formsWithCounts = await Promise.all(
formsList.map(async (form) => {
const responseCount = await ctx.db
.select({ count: count() })
.from(formResponses)
.where(eq(formResponses.formId, form.id));
return {
...form,
_count: { responses: responseCount[0]?.count ?? 0 },
};
}),
);
return formsWithCounts;
}),
listTemplates: protectedProcedure.query(async ({ ctx }) => {
const templates = await ctx.db.query.forms.findMany({
where: eq(forms.isTemplate, true),
orderBy: [desc(forms.updatedAt)],
});
return templates;
}),
createFromTemplate: protectedProcedure
.input(
z.object({
studyId: z.string().uuid(),
templateId: z.string().uuid(),
title: z.string().min(1).max(255).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
await checkStudyAccess(ctx.db, ctx.session.user.id, input.studyId, [
"owner",
"researcher",
]);
const template = await ctx.db.query.forms.findFirst({
where: and(eq(forms.id, input.templateId), eq(forms.isTemplate, true)),
});
if (!template) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Template not found",
});
}
const [newForm] = await ctx.db
.insert(forms)
.values({
studyId: input.studyId,
type: template.type,
title: input.title ?? `${template.title} (Copy)`,
description: template.description,
fields: template.fields,
settings: template.settings,
isTemplate: false,
createdBy: ctx.session.user.id,
})
.returning();
if (!newForm) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create form from template",
});
}
await ctx.db.insert(activityLogs).values({
studyId: input.studyId,
userId: ctx.session.user.id,
action: "form_created_from_template",
description: `Created form "${newForm.title}" from template "${template.title}"`,
resourceType: "form",
resourceId: newForm.id,
});
return newForm;
}),
getPublic: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const form = await ctx.db.query.forms.findFirst({
where: and(eq(forms.id, input.id), eq(forms.active, true)),
columns: {
id: true,
studyId: true,
type: true,
title: true,
description: true,
version: true,
fields: true,
settings: true,
},
});
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form not found or not active",
});
}
const study = await ctx.db.query.studies.findFirst({
where: eq(studies.id, form.studyId),
columns: {
name: true,
},
});
return { ...form, studyName: study?.name };
}),
submitPublic: publicProcedure
.input(
z.object({
formId: z.string().uuid(),
participantCode: z.string().min(1).max(100),
responses: z.record(z.string(), z.any()),
}),
)
.mutation(async ({ ctx, input }) => {
const { formId, participantCode, responses } = input;
const form = await ctx.db.query.forms.findFirst({
where: and(eq(forms.id, formId), eq(forms.active, true)),
});
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form not found or not active",
});
}
const participant = await ctx.db.query.participants.findFirst({
where: and(
eq(participants.studyId, form.studyId),
eq(participants.participantCode, participantCode),
),
});
if (!participant) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Invalid participant code",
});
}
const existingResponse = await ctx.db.query.formResponses.findFirst({
where: and(
eq(formResponses.formId, formId),
eq(formResponses.participantId, participant.id),
),
});
if (existingResponse) {
throw new TRPCError({
code: "CONFLICT",
message: "You have already submitted this form",
});
}
const [newResponse] = await ctx.db
.insert(formResponses)
.values({
formId,
participantId: participant.id,
responses,
status: "completed",
})
.returning();
return newResponse;
}),
});

View File

@@ -0,0 +1,235 @@
import { TRPCError } from "@trpc/server";
import { and, desc, eq, type SQL } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import {
pluginStatusEnum,
plugins,
studyMembers,
studyPlugins,
} from "~/server/db/schema";
export const pluginsRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
robotId: z.string().optional(),
status: z.enum(pluginStatusEnum.enumValues).optional(),
limit: z.number().min(1).max(100).default(50),
offset: z.number().min(0).default(0),
}),
)
.query(async ({ ctx, input }) => {
const conditions: SQL[] = [];
if (input.robotId) {
conditions.push(eq(plugins.robotId, input.robotId));
}
if (input.status) {
conditions.push(eq(plugins.status, input.status));
}
const query = ctx.db
.select({
id: plugins.id,
robotId: plugins.robotId,
name: plugins.name,
version: plugins.version,
description: plugins.description,
author: plugins.author,
repositoryUrl: plugins.repositoryUrl,
trustLevel: plugins.trustLevel,
status: plugins.status,
createdAt: plugins.createdAt,
updatedAt: plugins.updatedAt,
metadata: plugins.metadata,
})
.from(plugins);
const results = await (
conditions.length > 0 ? query.where(and(...conditions)) : query
)
.orderBy(desc(plugins.updatedAt))
.limit(input.limit)
.offset(input.offset);
return results;
}),
get: protectedProcedure
.input(
z.object({
id: z.string(),
}),
)
.query(async ({ ctx, input }) => {
const pluginResults = await ctx.db
.select()
.from(plugins)
.where(eq(plugins.id, input.id))
.limit(1);
const plugin = pluginResults[0];
if (!plugin) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Plugin not found",
});
}
return plugin;
}),
install: protectedProcedure
.input(
z.object({
studyId: z.string(),
pluginId: z.string(),
configuration: z.any().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
// Check if user has appropriate access
const membership = await ctx.db.query.studyMembers.findFirst({
where: and(
eq(studyMembers.studyId, input.studyId),
eq(studyMembers.userId, userId),
),
});
if (!membership || !["owner", "researcher"].includes(membership.role)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Insufficient permissions to install plugins",
});
}
// Check if plugin exists
const plugin = await ctx.db
.select()
.from(plugins)
.where(eq(plugins.id, input.pluginId))
.limit(1);
if (!plugin[0]) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Plugin not found",
});
}
// Check if plugin is already installed
const existing = await ctx.db
.select()
.from(studyPlugins)
.where(
and(
eq(studyPlugins.studyId, input.studyId),
eq(studyPlugins.pluginId, input.pluginId),
),
)
.limit(1);
if (existing[0]) {
throw new TRPCError({
code: "CONFLICT",
message: "Plugin already installed for this study",
});
}
const installations = await ctx.db
.insert(studyPlugins)
.values({
studyId: input.studyId,
pluginId: input.pluginId,
configuration: input.configuration ?? {},
installedBy: userId,
})
.returning();
const installation = installations[0];
if (!installation) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to install plugin",
});
}
return installation;
}),
uninstall: protectedProcedure
.input(
z.object({
studyId: z.string(),
pluginId: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
// Check if user has appropriate access
const membership = await ctx.db.query.studyMembers.findFirst({
where: and(
eq(studyMembers.studyId, input.studyId),
eq(studyMembers.userId, userId),
),
});
if (!membership || !["owner", "researcher"].includes(membership.role)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Insufficient permissions to uninstall plugins",
});
}
const result = await ctx.db
.delete(studyPlugins)
.where(
and(
eq(studyPlugins.studyId, input.studyId),
eq(studyPlugins.pluginId, input.pluginId),
),
)
.returning();
if (!result[0]) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Plugin installation not found",
});
}
return { success: true };
}),
getActions: protectedProcedure
.input(
z.object({
pluginId: z.string(),
}),
)
.query(async ({ ctx, input }) => {
const plugin = await ctx.db
.select({
id: plugins.id,
actionDefinitions: plugins.actionDefinitions,
})
.from(plugins)
.where(eq(plugins.id, input.pluginId))
.limit(1);
if (!plugin[0]) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Plugin not found",
});
}
return plugin[0].actionDefinitions ?? [];
}),
});

View File

@@ -841,6 +841,63 @@ export const studiesRouter = createTRPCRouter({
}; };
}), }),
getStudyPlugins: protectedProcedure
.input(
z.object({
studyId: z.string().uuid(),
}),
)
.query(async ({ ctx, input }) => {
const { studyId } = input;
const userId = ctx.session.user.id;
// Check if user has access to this study (any role)
const membership = await ctx.db.query.studyMembers.findFirst({
where: and(
eq(studyMembers.studyId, studyId),
eq(studyMembers.userId, userId),
),
});
if (!membership) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have access to this study",
});
}
const installedPlugins = await ctx.db
.select({
plugin: {
id: plugins.id,
robotId: plugins.robotId,
name: plugins.name,
version: plugins.version,
description: plugins.description,
author: plugins.author,
repositoryUrl: plugins.repositoryUrl,
trustLevel: plugins.trustLevel,
status: plugins.status,
actionDefinitions: plugins.actionDefinitions,
createdAt: plugins.createdAt,
updatedAt: plugins.updatedAt,
metadata: plugins.metadata,
},
installation: {
id: studyPlugins.id,
configuration: studyPlugins.configuration,
installedAt: studyPlugins.installedAt,
installedBy: studyPlugins.installedBy,
},
})
.from(studyPlugins)
.innerJoin(plugins, eq(studyPlugins.pluginId, plugins.id))
.where(eq(studyPlugins.studyId, studyId))
.orderBy(desc(studyPlugins.installedAt));
return installedPlugins;
}),
// Plugin configuration management // Plugin configuration management
getPluginConfiguration: protectedProcedure getPluginConfiguration: protectedProcedure
.input( .input(
@@ -944,4 +1001,20 @@ export const studiesRouter = createTRPCRouter({
return updatedPlugin; return updatedPlugin;
}), }),
getMyMemberships: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.id;
const memberships = await ctx.db.query.studyMembers.findMany({
where: eq(studyMembers.userId, userId),
columns: {
studyId: true,
role: true,
joinedAt: true,
},
orderBy: [desc(studyMembers.joinedAt)],
});
return memberships;
}),
}); });

View File

@@ -35,6 +35,7 @@ import { GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { env } from "~/env"; import { env } from "~/env";
import { uploadFile } from "~/lib/storage/minio"; import { uploadFile } from "~/lib/storage/minio";
import { wsManager } from "~/server/services/websocket-manager";
// 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(
@@ -591,6 +592,16 @@ export const trialsRouter = createTRPCRouter({
data: { userId }, data: { userId },
}); });
// Broadcast trial status update
await wsManager.broadcast(input.id, {
type: "trial_status",
data: {
trial: trial[0],
current_step_index: 0,
timestamp: Date.now(),
},
});
return trial[0]; return trial[0];
}), }),
@@ -643,6 +654,16 @@ export const trialsRouter = createTRPCRouter({
data: { userId, notes: input.notes }, data: { userId, notes: input.notes },
}); });
// Broadcast trial status update
await wsManager.broadcast(input.id, {
type: "trial_status",
data: {
trial,
current_step_index: 0,
timestamp: Date.now(),
},
});
return trial; return trial;
}), }),
@@ -696,6 +717,16 @@ export const trialsRouter = createTRPCRouter({
data: { userId, reason: input.reason }, data: { userId, reason: input.reason },
}); });
// Broadcast trial status update
await wsManager.broadcast(input.id, {
type: "trial_status",
data: {
trial: trial[0],
current_step_index: 0,
timestamp: Date.now(),
},
});
return trial[0]; return trial[0];
}), }),
@@ -846,6 +877,15 @@ export const trialsRouter = createTRPCRouter({
}) })
.returning(); .returning();
// Broadcast new event to all subscribers
await wsManager.broadcast(input.trialId, {
type: "trial_event",
data: {
event,
timestamp: Date.now(),
},
});
return event; return event;
}), }),
@@ -881,6 +921,15 @@ export const trialsRouter = createTRPCRouter({
}) })
.returning(); .returning();
// Broadcast intervention to all subscribers
await wsManager.broadcast(input.trialId, {
type: "intervention_logged",
data: {
intervention,
timestamp: Date.now(),
},
});
return intervention; return intervention;
}), }),
@@ -936,6 +985,15 @@ export const trialsRouter = createTRPCRouter({
}); });
} }
// Broadcast annotation to all subscribers
await wsManager.broadcast(input.trialId, {
type: "annotation_added",
data: {
annotation,
timestamp: Date.now(),
},
});
return annotation; return annotation;
}), }),
@@ -1302,20 +1360,33 @@ export const trialsRouter = createTRPCRouter({
} }
// Log the manual robot action execution // Log the manual robot action execution
await db.insert(trialEvents).values({ const [event] = await db
trialId: input.trialId, .insert(trialEvents)
eventType: "manual_robot_action", .values({
actionId: null, // Ad-hoc action, not linked to a protocol action definition trialId: input.trialId,
eventType: "manual_robot_action",
actionId: null,
data: {
userId,
pluginName: input.pluginName,
actionId: input.actionId,
parameters: input.parameters,
result: result.data,
duration: result.duration,
},
timestamp: new Date(),
createdBy: userId,
})
.returning();
// Broadcast robot action to all subscribers
await wsManager.broadcast(input.trialId, {
type: "trial_action_executed",
data: { data: {
userId, action_type: `${input.pluginName}.${input.actionId}`,
pluginName: input.pluginName, event,
actionId: input.actionId, timestamp: Date.now(),
parameters: input.parameters,
result: result.data,
duration: result.duration,
}, },
timestamp: new Date(),
createdBy: userId,
}); });
return { return {
@@ -1347,21 +1418,34 @@ export const trialsRouter = createTRPCRouter({
"wizard", "wizard",
]); ]);
await db.insert(trialEvents).values({ const [event] = await db
trialId: input.trialId, .insert(trialEvents)
eventType: "manual_robot_action", .values({
trialId: input.trialId,
eventType: "manual_robot_action",
data: {
userId,
pluginName: input.pluginName,
actionId: input.actionId,
parameters: input.parameters,
result: input.result,
duration: input.duration,
error: input.error,
executionMode: "websocket_client",
},
timestamp: new Date(),
createdBy: userId,
})
.returning();
// Broadcast robot action to all subscribers
await wsManager.broadcast(input.trialId, {
type: "trial_action_executed",
data: { data: {
userId, action_type: `${input.pluginName}.${input.actionId}`,
pluginName: input.pluginName, event,
actionId: input.actionId, timestamp: Date.now(),
parameters: input.parameters,
result: input.result,
duration: input.duration,
error: input.error,
executionMode: "websocket_client",
}, },
timestamp: new Date(),
createdBy: userId,
}); });
return { success: true }; return { success: true };

View File

@@ -68,6 +68,29 @@ export const stepTypeEnum = pgEnum("step_type", [
"conditional", "conditional",
]); ]);
export const formTypeEnum = pgEnum("form_type", [
"consent",
"survey",
"questionnaire",
]);
export const formFieldTypeEnum = pgEnum("form_field_type", [
"text",
"textarea",
"multiple_choice",
"checkbox",
"rating",
"yes_no",
"date",
"signature",
]);
export const formResponseStatusEnum = pgEnum("form_response_status", [
"pending",
"completed",
"rejected",
]);
export const communicationProtocolEnum = pgEnum("communication_protocol", [ export const communicationProtocolEnum = pgEnum("communication_protocol", [
"rest", "rest",
"ros2", "ros2",
@@ -485,6 +508,25 @@ export const trials = createTable("trial", {
metadata: jsonb("metadata").default({}), metadata: jsonb("metadata").default({}),
}); });
export const wsConnections = createTable("ws_connection", {
id: uuid("id").notNull().primaryKey().defaultRandom(),
trialId: uuid("trial_id")
.notNull()
.references(() => trials.id, { onDelete: "cascade" }),
clientId: text("client_id").notNull().unique(),
userId: text("user_id"),
connectedAt: timestamp("connected_at", { withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
});
export const wsConnectionsRelations = relations(wsConnections, ({ one }) => ({
trial: one(trials, {
fields: [wsConnections.trialId],
references: [trials.id],
}),
}));
export const steps = createTable( export const steps = createTable(
"step", "step",
{ {
@@ -575,6 +617,66 @@ export const consentForms = createTable(
}), }),
); );
// New unified forms table
export const forms = createTable(
"form",
{
id: uuid("id").notNull().primaryKey().defaultRandom(),
studyId: uuid("study_id")
.notNull()
.references(() => studies.id, { onDelete: "cascade" }),
type: formTypeEnum("type").notNull(),
title: varchar("title", { length: 255 }).notNull(),
description: text("description"),
version: integer("version").default(1).notNull(),
active: boolean("active").default(true).notNull(),
isTemplate: boolean("is_template").default(false).notNull(),
templateName: varchar("template_name", { length: 100 }),
fields: jsonb("fields").notNull().default([]),
settings: jsonb("settings").default({}),
createdBy: text("created_by")
.notNull()
.references(() => users.id),
createdAt: timestamp("created_at", { withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
},
(table) => ({
studyVersionUnique: unique().on(table.studyId, table.version),
}),
);
// Form responses/submissions
export const formResponses = createTable(
"form_response",
{
id: uuid("id").notNull().primaryKey().defaultRandom(),
formId: uuid("form_id")
.notNull()
.references(() => forms.id, { onDelete: "cascade" }),
participantId: uuid("participant_id")
.notNull()
.references(() => participants.id, { onDelete: "cascade" }),
responses: jsonb("responses").notNull().default({}),
status: formResponseStatusEnum("status").default("pending"),
signatureData: text("signature_data"),
signedAt: timestamp("signed_at", { withTimezone: true }),
ipAddress: inet("ip_address"),
submittedAt: timestamp("submitted_at", { withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
},
(table) => ({
formParticipantUnique: unique().on(table.formId, table.participantId),
}),
);
export const participantConsents = createTable( export const participantConsents = createTable(
"participant_consent", "participant_consent",
{ {
@@ -1099,6 +1201,29 @@ export const participantConsentsRelations = relations(
}), }),
); );
export const formsRelations = relations(forms, ({ one, many }) => ({
study: one(studies, {
fields: [forms.studyId],
references: [studies.id],
}),
createdBy: one(users, {
fields: [forms.createdBy],
references: [users.id],
}),
responses: many(formResponses),
}));
export const formResponsesRelations = relations(formResponses, ({ one }) => ({
form: one(forms, {
fields: [formResponses.formId],
references: [forms.id],
}),
participant: one(participants, {
fields: [formResponses.participantId],
references: [participants.id],
}),
}));
export const robotsRelations = relations(robots, ({ many }) => ({ export const robotsRelations = relations(robots, ({ many }) => ({
experiments: many(experiments), experiments: many(experiments),
plugins: many(plugins), plugins: many(plugins),

View File

@@ -0,0 +1,272 @@
import { db } from "~/server/db";
import {
trials,
trialEvents,
wsConnections,
experiments,
} from "~/server/db/schema";
import { eq, sql } from "drizzle-orm";
interface ClientConnection {
socket: WebSocket;
trialId: string;
userId: string | null;
connectedAt: number;
}
type OutgoingMessage = {
type: string;
data: Record<string, unknown>;
};
class WebSocketManager {
private clients: Map<string, ClientConnection> = new Map();
private heartbeatIntervals: Map<string, ReturnType<typeof setInterval>> =
new Map();
private getTrialRoomClients(trialId: string): ClientConnection[] {
const clients: ClientConnection[] = [];
for (const [, client] of this.clients) {
if (client.trialId === trialId) {
clients.push(client);
}
}
return clients;
}
addClient(clientId: string, connection: ClientConnection): void {
this.clients.set(clientId, connection);
console.log(
`[WS] Client ${clientId} added for trial ${connection.trialId}. Total: ${this.clients.size}`,
);
}
removeClient(clientId: string): void {
const client = this.clients.get(clientId);
if (client) {
console.log(
`[WS] Client ${clientId} removed from trial ${client.trialId}`,
);
}
const heartbeatInterval = this.heartbeatIntervals.get(clientId);
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
this.heartbeatIntervals.delete(clientId);
}
this.clients.delete(clientId);
}
async subscribe(
clientId: string,
socket: WebSocket,
trialId: string,
userId: string | null,
): Promise<void> {
const client: ClientConnection = {
socket,
trialId,
userId,
connectedAt: Date.now(),
};
this.clients.set(clientId, client);
const heartbeatInterval = setInterval(() => {
this.sendToClient(clientId, { type: "heartbeat", data: {} });
}, 30000);
this.heartbeatIntervals.set(clientId, heartbeatInterval);
console.log(
`[WS] Client ${clientId} subscribed to trial ${trialId}. Total clients: ${this.clients.size}`,
);
}
unsubscribe(clientId: string): void {
const client = this.clients.get(clientId);
if (client) {
console.log(
`[WS] Client ${clientId} unsubscribed from trial ${client.trialId}`,
);
}
const heartbeatInterval = this.heartbeatIntervals.get(clientId);
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
this.heartbeatIntervals.delete(clientId);
}
this.clients.delete(clientId);
}
sendToClient(clientId: string, message: OutgoingMessage): void {
const client = this.clients.get(clientId);
if (client?.socket.readyState === 1) {
try {
client.socket.send(JSON.stringify(message));
} catch (error) {
console.error(`[WS] Error sending to client ${clientId}:`, error);
this.unsubscribe(clientId);
}
}
}
async broadcast(trialId: string, message: OutgoingMessage): Promise<void> {
const clients = this.getTrialRoomClients(trialId);
if (clients.length === 0) {
return;
}
const messageStr = JSON.stringify(message);
const disconnectedClients: string[] = [];
for (const [clientId, client] of this.clients) {
if (client.trialId === trialId && client.socket.readyState === 1) {
try {
client.socket.send(messageStr);
} catch (error) {
console.error(
`[WS] Error broadcasting to client ${clientId}:`,
error,
);
disconnectedClients.push(clientId);
}
}
}
for (const clientId of disconnectedClients) {
this.unsubscribe(clientId);
}
console.log(
`[WS] Broadcast to ${clients.length} clients for trial ${trialId}: ${message.type}`,
);
}
async broadcastToAll(message: OutgoingMessage): Promise<void> {
const messageStr = JSON.stringify(message);
const disconnectedClients: string[] = [];
for (const [clientId, client] of this.clients) {
if (client.socket.readyState === 1) {
try {
client.socket.send(messageStr);
} catch (error) {
console.error(
`[WS] Error broadcasting to client ${clientId}:`,
error,
);
disconnectedClients.push(clientId);
}
}
}
for (const clientId of disconnectedClients) {
this.unsubscribe(clientId);
}
}
async getTrialStatus(trialId: string): Promise<{
trial: {
id: string;
status: string;
startedAt: Date | null;
completedAt: Date | null;
};
currentStepIndex: number;
} | null> {
const [trial] = await db
.select({
id: trials.id,
status: trials.status,
startedAt: trials.startedAt,
completedAt: trials.completedAt,
})
.from(trials)
.where(eq(trials.id, trialId))
.limit(1);
if (!trial) {
return null;
}
return {
trial: {
id: trial.id,
status: trial.status,
startedAt: trial.startedAt,
completedAt: trial.completedAt,
},
currentStepIndex: 0,
};
}
async getTrialEvents(
trialId: string,
limit: number = 100,
): Promise<unknown[]> {
const events = await db
.select()
.from(trialEvents)
.where(eq(trialEvents.trialId, trialId))
.orderBy(trialEvents.timestamp)
.limit(limit);
return events;
}
getTrialStatusSync(trialId: string): {
trial: {
id: string;
status: string;
startedAt: Date | null;
completedAt: Date | null;
};
currentStepIndex: number;
} | null {
return null;
}
getTrialEventsSync(trialId: string, limit: number = 100): unknown[] {
return [];
}
getConnectionCount(trialId?: string): number {
if (trialId) {
return this.getTrialRoomClients(trialId).length;
}
return this.clients.size;
}
getConnectedTrialIds(): string[] {
const trialIds = new Set<string>();
for (const [, client] of this.clients) {
trialIds.add(client.trialId);
}
return Array.from(trialIds);
}
async getTrialsWithActiveConnections(studyIds?: string[]): Promise<string[]> {
const conditions =
studyIds && studyIds.length > 0
? sql`${wsConnections.trialId} IN (
SELECT ${trials.id} FROM ${trials}
WHERE ${trials.experimentId} IN (
SELECT ${experiments.id} FROM ${experiments}
WHERE ${experiments.studyId} IN (${sql.raw(studyIds.map((id) => `'${id}'`).join(","))})
)
)`
: undefined;
const connections = await db
.selectDistinct({ trialId: wsConnections.trialId })
.from(wsConnections);
return connections.map((c) => c.trialId);
}
}
export const wsManager = new WebSocketManager();

View File

@@ -7,6 +7,7 @@
@theme { @theme {
--font-sans: var(--font-sans), ui-sans-serif, system-ui, sans-serif, --font-sans: var(--font-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";
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
} }
@theme inline { @theme inline {
@@ -14,6 +15,7 @@
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);
@@ -84,48 +86,49 @@
} }
:root { :root {
/* Light Mode (Inverted: White BG, gray Cards) */ /* Light Mode - Teal/Cyan Sci-Fi Theme */
--radius: 0.5rem; --radius: 0.75rem;
--background: hsl(0 0% 100%); /* More rounded */
/* Pure White Background */ --background: hsl(190 40% 98%);
/* Very Light Cyan Background */
--foreground: hsl(240 10% 3.9%); --foreground: hsl(240 10% 3.9%);
--card: hsl(240 4.8% 95.9%); --card: hsl(180 20% 97%);
/* Light Gray Card */ /* Light Teal Card */
--card-foreground: hsl(240 10% 3.9%); --card-foreground: hsl(240 10% 3.9%);
--popover: hsl(0 0% 100%); --popover: hsl(0 0% 100%);
--popover-foreground: hsl(240 10% 3.9%); --popover-foreground: hsl(240 10% 3.9%);
--primary: hsl(221.2 83.2% 53.3%); /* Teal Primary - Robot/Sci-fi feel */
/* Indigo-600 */ --primary: hsl(173 80% 40%);
--primary-foreground: hsl(210 40% 98%); /* Teal-500: #14b8a6 */
--secondary: hsl(210 40% 96.1%); --primary-foreground: hsl(180 50% 10%);
--secondary-foreground: hsl(222.2 47.4% 11.2%); /* Dark teal text on teal */
--muted: hsl(210 40% 96.1%); --secondary: hsl(180 30% 92%);
--muted-foreground: hsl(215.4 16.3% 46.9%); --secondary-foreground: hsl(173 60% 20%);
--accent: hsl(210 40% 96.1%); --muted: hsl(180 20% 94%);
--accent-foreground: hsl(222.2 47.4% 11.2%); --muted-foreground: hsl(180 10% 40%);
--accent: hsl(180 40% 90%);
--accent-foreground: hsl(173 60% 20%);
/* Red for destructive, Cyan for highlights */
--destructive: hsl(0 84.2% 60.2%); --destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(210 40% 98%); --destructive-foreground: hsl(0 0% 98%);
--border: hsl(214.3 31.8% 91.4%); --border: hsl(180 20% 80%);
--input: hsl(214.3 31.8% 91.4%); /* Slightly more visible border */
--ring: hsl(221.2 83.2% 53.3%); --input: hsl(180 20% 80%);
--chart-1: hsl(221.2 83.2% 53.3%); --ring: hsl(173 80% 40%);
--chart-2: hsl(173 58% 39%); /* Chart colors - teal/cyan themed */
--chart-3: hsl(197 37% 24%); --chart-1: hsl(173 80% 40%);
--chart-4: hsl(43 74% 66%); --chart-2: hsl(190 80% 55%);
--chart-5: hsl(27 87% 67%); --chart-3: hsl(200 70% 50%);
--sidebar: hsl(240 4.8% 95.9%); --chart-4: hsl(160 70% 45%);
/* Zinc-100: Distinct contrast against white BG */ --chart-5: hsl(210 70% 55%);
--sidebar: hsl(180 15% 94%);
--sidebar-foreground: hsl(240 10% 3.9%); --sidebar-foreground: hsl(240 10% 3.9%);
/* Dark Text */ --sidebar-primary: hsl(173 80% 40%);
--sidebar-primary: hsl(221.2 83.2% 53.3%); --sidebar-primary-foreground: hsl(180 50% 10%);
/* Indigo Accent */ --sidebar-accent: hsl(180 25% 88%);
--sidebar-primary-foreground: hsl(0 0% 98%); --sidebar-accent-foreground: hsl(173 60% 20%);
--sidebar-accent: hsl(240 5.9% 90%); --sidebar-border: hsl(180 20% 85%);
/* Zinc-200: Slightly darker for hover */ --sidebar-ring: hsl(173 80% 50%);
--sidebar-accent-foreground: hsl(240 5.9% 10%);
--sidebar-border: hsl(240 5.9% 90%);
/* Zinc-200 Border */
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--shadow-color: hsl(0 0% 0%); --shadow-color: hsl(0 0% 0%);
--shadow-opacity: 0; --shadow-opacity: 0;
@@ -152,16 +155,43 @@
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--background: hsl(240 10% 3.9%); /* Dark Mode - Teal/Cyan Sci-Fi Theme */
--foreground: hsl(0 0% 98%); --background: hsl(200 20% 8%);
/* Distinct Card Background for better contrast */ /* Dark cyan/black */
--card: hsl(240 5% 9%); --foreground: hsl(180 20% 95%);
--card-foreground: hsl(0 0% 98%); /* Light cyan text */
--popover: hsl(240 5% 9%); --card: hsl(200 15% 12%);
--popover-foreground: hsl(0 0% 98%); /* Slightly lighter card */
--primary: hsl(0 0% 98%); --card-foreground: hsl(180 20% 95%);
--primary-foreground: hsl(240 5.9% 10%); --popover: hsl(200 15% 10%);
--secondary: hsl(240 3.7% 15.9%); --popover-foreground: hsl(180 20% 95%);
--primary: hsl(173 80% 45%);
/* Teal-500: brighter in dark */
--primary-foreground: hsl(180 50% 10%);
--secondary: hsl(200 15% 15%);
--secondary-foreground: hsl(180 20% 90%);
--muted: hsl(200 15% 15%);
--muted-foreground: hsl(180 10% 60%);
--accent: hsl(173 60% 25%);
--accent-foreground: hsl(180 20% 95%);
--destructive: hsl(0 70% 55%);
--destructive-foreground: hsl(0 0% 100%);
--border: hsl(200 15% 20%);
--input: hsl(200 15% 20%);
--ring: hsl(173 80% 45%);
--chart-1: hsl(173 75% 50%);
--chart-2: hsl(180 70% 60%);
--chart-3: hsl(190 65% 55%);
--chart-4: hsl(160 60% 50%);
--chart-5: hsl(200 60% 55%);
--sidebar: hsl(200 15% 10%);
--sidebar-foreground: hsl(180 20% 90%);
--sidebar-primary: hsl(173 80% 45%);
--sidebar-primary-foreground: hsl(180 50% 10%);
--sidebar-accent: hsl(200 15% 18%);
--sidebar-accent-foreground: hsl(180 20% 90%);
--sidebar-border: hsl(200 15% 18%);
--sidebar-ring: hsl(173 80% 50%);
--secondary-foreground: hsl(0 0% 98%); --secondary-foreground: hsl(0 0% 98%);
--muted: hsl(240 3.7% 15.9%); --muted: hsl(240 3.7% 15.9%);
--muted-foreground: hsl(240 5% 64.9%); --muted-foreground: hsl(240 5% 64.9%);
@@ -188,64 +218,6 @@
} }
} }
@layer base {
.dark {
--background: hsl(240 10% 3.9%);
--foreground: hsl(0 0% 98%);
--card: hsl(240 5% 9%);
--card-foreground: hsl(0 0% 98%);
--popover: hsl(240 5% 9%);
--popover-foreground: hsl(0 0% 98%);
--primary: hsl(0 0% 98%);
--primary-foreground: hsl(240 5.9% 10%);
--secondary: hsl(240 3.7% 15.9%);
--secondary-foreground: hsl(0 0% 98%);
--muted: hsl(240 3.7% 15.9%);
--muted-foreground: hsl(240 5% 64.9%);
--accent: hsl(240 3.7% 15.9%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(240 3.7% 15.9%);
--input: hsl(240 3.7% 15.9%);
--ring: hsl(240 4.9% 83.9%);
--chart-1: hsl(220 70% 50%);
--chart-2: hsl(160 60% 45%);
--chart-3: hsl(30 80% 55%);
--chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%);
--sidebar: hsl(240 5.9% 10%);
--sidebar-foreground: hsl(240 4.8% 95.9%);
--sidebar-primary: hsl(224.3 76.3% 48%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(240 3.7% 15.9%);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(240 3.7% 15.9%);
--sidebar-border: hsl(240 3.7% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
/* Validation Dark Mode */
--validation-error-bg: hsl(0 75% 15%);
/* Red 950-ish */
--validation-error-text: hsl(0 100% 90%);
/* Red 100 */
--validation-error-border: hsl(0 50% 30%);
/* Red 900 */
--validation-warning-bg: hsl(30 90% 10%);
/* Amber 950-ish */
--validation-warning-text: hsl(30 100% 90%);
/* Amber 100 */
--validation-warning-border: hsl(30 60% 30%);
/* Amber 900 */
--validation-info-bg: hsl(210 50% 15%);
/* Blue 950-ish */
--validation-info-text: hsl(210 100% 90%);
/* Blue 100 */
--validation-info-border: hsl(210 40% 30%);
/* Blue 900 */
}
}
:root { :root {
/* Validation Light Mode Defaults */ /* Validation Light Mode Defaults */
--validation-error-bg: hsl(0 85% 97%); --validation-error-bg: hsl(0 85% 97%);
@@ -292,6 +264,76 @@
} }
} }
/* Custom Component Theming */
@layer utilities {
/* Chamfered corners - subtle sci-fi tech feel */
.chamfer {
clip-path: polygon(
0.75rem, 0,
100% 0,
100% calc(100% - 0.75rem),
calc(100% - 0.75rem) 100%,
0 100%
);
}
/* Soft corners - very rounded */
.rounded-chonk {
border-radius: 1rem;
}
/* Card with subtle gradient */
.card-teal {
background: linear-gradient(
135deg,
hsl(var(--card) / 1) 0%,
hsl(var(--card-hue, 180) var(--card-sat, 20%) var(--card-light, 95%) / 0.5) 100%
);
}
/* Glow effects for primary actions */
.glow-teal {
box-shadow: 0 0 20px hsl(var(--primary) / 0.3);
}
.glow-teal:hover {
box-shadow: 0 0 30px hsl(var(--primary) / 0.5);
}
/* Subtle pulse animation for active states */
@keyframes pulse-glow {
0%, 100% {
box-shadow: 0 0 10px hsl(var(--primary) / 0.2);
}
50% {
box-shadow: 0 0 20px hsl(var(--primary) / 0.4);
}
}
.animate-pulse-glow {
animation: pulse-glow 2s ease-in-out infinite;
}
/* Italic/oblique text for labels */
.font-slant {
font-style: oblique 10deg;
}
/* Micro-interaction: subtle scale */
.hover-scale {
transition: transform 150ms ease-out;
}
.hover-scale:hover {
transform: scale(1.02);
}
/* Border glow on focus */
.ring-glow:focus-visible {
box-shadow: 0 0 0 2px var(--background), 0 0 0 4px var(--primary);
}
}
/* Viewport height constraint for proper flex layout */ /* Viewport height constraint for proper flex layout */
html, html,
body, body,

View File

@@ -7,7 +7,7 @@ import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server";
import { useState } from "react"; import { useState } from "react";
import SuperJSON from "superjson"; import SuperJSON from "superjson";
import { type AppRouter } from "~/server/api/root"; import type { AppRouter } from "~/server/api/root";
import { createQueryClient } from "./query-client"; import { createQueryClient } from "./query-client";
let clientQueryClientSingleton: QueryClient | undefined = undefined; let clientQueryClientSingleton: QueryClient | undefined = undefined;

192
ws-server.ts Normal file
View File

@@ -0,0 +1,192 @@
import { serve, type ServerWebSocket } from "bun";
import { wsManager } from "./src/server/services/websocket-manager";
import { db } from "./src/server/db";
import { wsConnections } from "./src/server/db/schema";
import { eq } from "drizzle-orm";
const port = parseInt(process.env.WS_PORT || "3001", 10);
interface WSData {
clientId: string;
trialId: string;
userId: string | null;
}
function generateClientId(): string {
return `ws_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
async function recordConnection(
clientId: string,
trialId: string,
userId: string | null,
): Promise<void> {
try {
await db.insert(wsConnections).values({
clientId,
trialId,
userId,
});
console.log(`[DB] Recorded connection for trial ${trialId}`);
} catch (error) {
console.error(`[DB] Failed to record connection:`, error);
}
}
async function removeConnection(clientId: string): Promise<void> {
try {
await db.delete(wsConnections).where(eq(wsConnections.clientId, clientId));
console.log(`[DB] Removed connection ${clientId}`);
} catch (error) {
console.error(`[DB] Failed to remove connection:`, error);
}
}
console.log(`Starting WebSocket server on port ${port}...`);
serve<WSData>({
port,
fetch(req, server) {
const url = new URL(req.url);
if (url.pathname === "/api/websocket") {
if (req.headers.get("upgrade") !== "websocket") {
return new Response("WebSocket upgrade required", { status: 426 });
}
const trialId = url.searchParams.get("trialId");
const token = url.searchParams.get("token");
if (!trialId) {
return new Response("Missing trialId parameter", { status: 400 });
}
let userId: string | null = null;
if (token) {
try {
const tokenData = JSON.parse(atob(token));
userId = tokenData.userId;
} catch {
return new Response("Invalid token", { status: 401 });
}
}
const clientId = generateClientId();
const wsData: WSData = { clientId, trialId, userId };
const upgraded = server.upgrade(req, { data: wsData });
if (!upgraded) {
return new Response("WebSocket upgrade failed", { status: 500 });
}
return;
}
return new Response("Not found", { status: 404 });
},
websocket: {
async open(ws: ServerWebSocket<WSData>) {
const { clientId, trialId, userId } = ws.data;
wsManager.addClient(clientId, {
socket: ws as unknown as WebSocket,
trialId,
userId,
connectedAt: Date.now(),
});
await recordConnection(clientId, trialId, userId);
console.log(
`[WS] Client ${clientId} connected to trial ${trialId}. Total: ${wsManager.getConnectionCount()}`,
);
ws.send(
JSON.stringify({
type: "connection_established",
data: {
trialId,
userId,
role: "connected",
connectedAt: Date.now(),
},
}),
);
},
message(ws: ServerWebSocket<WSData>, message) {
const { clientId, trialId } = ws.data;
try {
const msg = JSON.parse(message.toString());
switch (msg.type) {
case "heartbeat":
ws.send(
JSON.stringify({
type: "heartbeat_response",
data: { timestamp: Date.now() },
}),
);
break;
case "request_trial_status": {
const status = wsManager.getTrialStatusSync(trialId);
ws.send(
JSON.stringify({
type: "trial_status",
data: {
trial: status?.trial ?? null,
current_step_index: status?.currentStepIndex ?? 0,
timestamp: Date.now(),
},
}),
);
break;
}
case "request_trial_events": {
const events = wsManager.getTrialEventsSync(
trialId,
msg.data?.limit ?? 100,
);
ws.send(
JSON.stringify({
type: "trial_events_snapshot",
data: { events, timestamp: Date.now() },
}),
);
break;
}
case "ping":
ws.send(
JSON.stringify({
type: "pong",
data: { timestamp: Date.now() },
}),
);
break;
default:
console.log(
`[WS] Unknown message type from ${clientId}:`,
msg.type,
);
}
} catch (error) {
console.error(`[WS] Error processing message from ${clientId}:`, error);
}
},
async close(ws: ServerWebSocket<WSData>) {
const { clientId } = ws.data;
console.log(`[WS] Client ${clientId} disconnected`);
wsManager.removeClient(clientId);
await removeConnection(clientId);
},
},
});
console.log(
`> WebSocket server running on ws://localhost:${port}/api/websocket`,
);