37 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
Sean O'Connor
20d6d3de1a migrate: replace NextAuth.js with Better Auth
- Install better-auth and @better-auth/drizzle-adapter
- Create src/lib/auth.ts with Better Auth configuration using bcrypt
- Update database schema: change auth table IDs from uuid to text
- Update route handler from /api/auth/[...nextauth] to /api/auth/[...all]
- Update tRPC context and middleware for Better Auth session handling
- Update client components to use Better Auth APIs (signIn, signOut)
- Update seed script with text-based IDs and correct account schema
- Fix type errors in wizard components (robotId, optional chaining)
- Fix API paths: api.robots.initialize -> api.robots.plugins.initialize
- Update auth router to use text IDs for Better Auth compatibility

Note: Auth tables were reset - users will need to re-register.
2026-03-21 23:03:55 -04:00
4bed537943 Update docs: add March 2026 session summary, NAO6 Docker integration docs, and quick reference updates
- Add MARCH-2026-SESSION.md with complete summary of work done
- Update nao6-quick-reference.md for Docker-based deployment
- Update quick-reference.md with NAO6 Docker integration section
2026-03-21 20:51:08 -04:00
73f70f6550 Add nextStepId conditions to Branch A and B to jump to Story Continues 2026-03-21 20:44:47 -04:00
3fafd61553 Fix onClick handlers passing event object to handleNextStep
The issue was that onClick={onNextStep} passes the click event as the first argument,
making targetIndex an object instead of undefined. This caused handleNextStep to fall
through to linear progression instead of properly checking branching logic.

Fixed by wrapping with arrow function: onClick={() => onNextStep()}
2026-03-21 20:35:54 -04:00
3491bf4463 Add debug logging for branching flow 2026-03-21 20:26:55 -04:00
cc58593891 Update robot-plugins submodule 2026-03-21 20:21:38 -04:00
bbbe397ba8 Various improvements: study forms, participant management, PDF generator, robot integration 2026-03-21 20:21:18 -04:00
bbc34921b5 Fix branching logic and robot action timing
- Remove onCompleted() call after branch selection to prevent count increment
- Add proper duration estimation for speech actions (word count + emotion overhead)
- Add say_with_emotion and wave_goodbye to built-in actions
- Add identifier field to admin.ts plugin creation
2026-03-21 20:15:39 -04:00
8e647c958e Fix seed script to include identifier for system plugins 2026-03-21 20:04:46 -04:00
4e86546311 Add identifier column to plugins table for cleaner plugin lookup
- Added 'identifier' column (unique) for machine-readable plugin ID
- 'name' now used for display name only
- Updated trial-execution to look up by identifier first, then name
- Added migration script for existing databases
2026-03-21 20:03:33 -04:00
e84c794962 Load plugin from local file first (not remote) 2026-03-21 19:32:13 -04:00
70064f487e Fix say_with_emotion with proper NAOqi markup, add transform functions, update seed script for linear branching 2026-03-21 19:29:28 -04:00
91d03a789d Redesign experiment structure and add pending trial
- Both branch choices now jump to Story Continues (convergence point)
- Add Story Continues step with expressive actions
- Add pre-seeded pending trial for immediate testing
- Fix duplicate comments in seed script
- Update step ordering (Conclusion now step6)
2026-03-21 19:15:41 -04:00
31d2173703 Fix branching and add move_arm builtin
- Branching: mark source step as completed when jumping to prevent revisiting
- Add move_arm as builtin for arm control
2026-03-21 19:09:26 -04:00
4a9abf4ff1 Restore builtins for standard ROS actions
- Re-add say_text, walk_forward, walk_backward, turn_left, turn_right, move_head, turn_head as builtins
- These use standard ROS topics (/speech, /cmd_vel, /joint_angles) that work with most robots
- Plugin-specific actions should still be defined in plugin config
2026-03-21 19:04:51 -04:00
487f97c5c2 Update robot-plugins submodule 2026-03-21 18:58:29 -04:00
db147f2294 Update robot-plugins submodule 2026-03-21 18:57:00 -04:00
a705c720fb Make wizard-ros-service robot-agnostic
- Remove NAO-specific hardcoded action handlers
- Remove helper methods (executeMovementAction, executeTurnHead, executeMoveArm)
- Keep only generic emergency_stop as builtin
- All robot-specific actions should be defined in plugin config
2026-03-21 18:55:52 -04:00
e460c1b029 Update robot-plugins submodule 2026-03-21 18:54:18 -04:00
eb0d86f570 Clean up debug logs 2026-03-21 18:52:16 -04:00
e40c37cfd0 Fix branching logic and add combo robot actions
- Fix handleNextStep to handle both string and object options in conditions
- Add say_with_emotion, bow, wave, nod, shake_head, point combo actions
- Update seed data with nextStepId in wizard_wait_for_response options
2026-03-21 18:51:27 -04:00
129 changed files with 12592 additions and 4144 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

183
bun.lock
View File

@@ -1,5 +1,6 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "hristudio", "name": "hristudio",
@@ -7,6 +8,7 @@
"@auth/drizzle-adapter": "^1.11.1", "@auth/drizzle-adapter": "^1.11.1",
"@aws-sdk/client-s3": "^3.989.0", "@aws-sdk/client-s3": "^3.989.0",
"@aws-sdk/s3-request-presigner": "^3.989.0", "@aws-sdk/s3-request-presigner": "^3.989.0",
"@better-auth/drizzle-adapter": "^1.5.5",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@@ -48,6 +50,7 @@
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-auth": "^1.5.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
@@ -58,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",
@@ -87,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",
@@ -112,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=="],
@@ -205,8 +212,58 @@
"@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/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/kysely-adapter": ["@better-auth/kysely-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0", "kysely": "^0.27.0 || ^0.28.0" } }, "sha512-LmHffIVnqbfsxcxckMOoE8MwibWrbVFch+kwPKJ5OFDFv6lin75ufN7ZZ7twH0IMPLT/FcgzaRjP8jRrXRef9g=="],
"@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0" } }, "sha512-4X0j1/2L+nsgmObjmy9xEGUFWUv38Qjthp558fwS3DAp6ueWWyCaxaD6VJZ7m5qPNMrsBStO5WGP8CmJTEWm7g=="],
"@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0", "mongodb": "^6.0.0 || ^7.0.0" } }, "sha512-P1J9ljL5X5k740I8Rx1esPWNgWYPdJR5hf2CY7BwDSrQFPUHuzeCg0YhtEEP55niNateTXhBqGAcy0fVOeamZg=="],
"@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-CliDd78CXHzzwQIXhCdwGr5Ml53i6JdCHWV7PYwTIJz9EAm6qb2RVBdpP3nqEfNjINGM22A6gfleCgCdZkTIZg=="],
"@better-auth/telemetry": ["@better-auth/telemetry@1.5.5", "", { "dependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.5.5" } }, "sha512-1+lklxArn4IMHuU503RcPdXrSG2tlXt4jnGG3omolmspQ7tktg/Y9XO/yAkYDurtvMn1xJ8X1Ov01Ji/r5s9BQ=="],
"@better-auth/utils": ["@better-auth/utils@0.3.1", "", {}, "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg=="],
"@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="],
"@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="],
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
@@ -377,27 +434,33 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="],
"@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.4.6", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g=="],
"@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/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
@@ -585,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=="],
@@ -855,6 +916,10 @@
"@types/uuid": ["@types/uuid@11.0.0", "", { "dependencies": { "uuid": "*" } }, "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA=="], "@types/uuid": ["@types/uuid@11.0.0", "", { "dependencies": { "uuid": "*" } }, "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA=="],
"@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="],
"@types/whatwg-url": ["@types/whatwg-url@13.0.0", "", { "dependencies": { "@types/webidl-conversions": "*" } }, "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.55.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/type-utils": "8.55.0", "@typescript-eslint/utils": "8.55.0", "@typescript-eslint/visitor-keys": "8.55.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.55.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.55.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/type-utils": "8.55.0", "@typescript-eslint/utils": "8.55.0", "@typescript-eslint/visitor-keys": "8.55.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.55.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ=="],
@@ -987,6 +1052,10 @@
"bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="], "bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="],
"better-auth": ["better-auth@1.5.5", "", { "dependencies": { "@better-auth/core": "1.5.5", "@better-auth/drizzle-adapter": "1.5.5", "@better-auth/kysely-adapter": "1.5.5", "@better-auth/memory-adapter": "1.5.5", "@better-auth/mongo-adapter": "1.5.5", "@better-auth/prisma-adapter": "1.5.5", "@better-auth/telemetry": "1.5.5", "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.2", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.11", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-GpVPaV1eqr3mOovKfghJXXk6QvlcVeFbS3z+n+FPDid5rK/2PchnDtiaVCzWyXA9jH2KkirOfl+JhAUvnja0Eg=="],
"better-call": ["better-call@1.3.2", "", { "dependencies": { "@better-auth/utils": "^0.3.1", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw=="],
"bl": ["bl@5.1.0", "", { "dependencies": { "buffer": "^6.0.3", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ=="], "bl": ["bl@5.1.0", "", { "dependencies": { "buffer": "^6.0.3", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ=="],
"block-stream2": ["block-stream2@2.1.0", "", { "dependencies": { "readable-stream": "^3.4.0" } }, "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg=="], "block-stream2": ["block-stream2@2.1.0", "", { "dependencies": { "readable-stream": "^3.4.0" } }, "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg=="],
@@ -999,6 +1068,10 @@
"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=="],
"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=="],
"buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="], "buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="],
@@ -1045,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=="],
@@ -1085,6 +1160,8 @@
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
@@ -1101,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=="],
@@ -1131,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=="],
@@ -1151,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=="],
@@ -1225,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=="],
@@ -1261,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=="],
@@ -1353,7 +1440,7 @@
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"jose": ["jose@6.0.12", "", {}, "sha512-T8xypXs8CpmiIi78k0E+Lk7T2zlK4zDyg+o1CZ4AkOHgDg98ogdP2BeZ61lTFKFyoEwJ9RgAgN+SdM3iPgNonQ=="], "jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
"js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="], "js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="],
@@ -1361,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=="],
@@ -1379,6 +1468,8 @@
"kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
"kysely": ["kysely@0.28.14", "", {}, "sha512-SU3lgh0rPvq7upc6vvdVrCsSMUG1h3ChvHVOY7wJ2fw4C9QEB7X3d5eyYEyULUX7UQtxZJtZXGuT6U2US72UYA=="],
"language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="], "language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="],
"language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="], "language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="],
@@ -1423,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=="],
@@ -1435,6 +1528,8 @@
"mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="], "mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="],
"memory-pager": ["memory-pager@1.5.0", "", {}, "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
@@ -1453,15 +1548,21 @@
"minio": ["minio@8.0.6", "", { "dependencies": { "async": "^3.2.4", "block-stream2": "^2.1.0", "browser-or-node": "^2.1.1", "buffer-crc32": "^1.0.0", "eventemitter3": "^5.0.1", "fast-xml-parser": "^4.4.1", "ipaddr.js": "^2.0.1", "lodash": "^4.17.21", "mime-types": "^2.1.35", "query-string": "^7.1.3", "stream-json": "^1.8.0", "through2": "^4.0.2", "web-encoding": "^1.1.5", "xml2js": "^0.5.0 || ^0.6.2" } }, "sha512-sOeh2/b/XprRmEtYsnNRFtOqNRTPDvYtMWh+spWlfsuCV/+IdxNeKVUMKLqI7b5Dr07ZqCPuaRGU/rB9pZYVdQ=="], "minio": ["minio@8.0.6", "", { "dependencies": { "async": "^3.2.4", "block-stream2": "^2.1.0", "browser-or-node": "^2.1.1", "buffer-crc32": "^1.0.0", "eventemitter3": "^5.0.1", "fast-xml-parser": "^4.4.1", "ipaddr.js": "^2.0.1", "lodash": "^4.17.21", "mime-types": "^2.1.35", "query-string": "^7.1.3", "stream-json": "^1.8.0", "through2": "^4.0.2", "web-encoding": "^1.1.5", "xml2js": "^0.5.0 || ^0.6.2" } }, "sha512-sOeh2/b/XprRmEtYsnNRFtOqNRTPDvYtMWh+spWlfsuCV/+IdxNeKVUMKLqI7b5Dr07ZqCPuaRGU/rB9pZYVdQ=="],
"mongodb": ["mongodb@7.1.0", "", { "dependencies": { "@mongodb-js/saslprep": "^1.3.0", "bson": "^7.1.1", "mongodb-connection-string-url": "^7.0.0" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3.806.0", "@mongodb-js/zstd": "^7.0.0", "gcp-metadata": "^7.0.1", "kerberos": "^7.0.0", "mongodb-client-encryption": ">=7.0.0 <7.1.0", "snappy": "^7.3.2", "socks": "^2.8.6" }, "optionalPeers": ["@aws-sdk/credential-providers", "@mongodb-js/zstd", "gcp-metadata", "kerberos", "mongodb-client-encryption", "snappy", "socks"] }, "sha512-kMfnKunbolQYwCIyrkxNJFB4Ypy91pYqua5NargS/f8ODNSJxT03ZU3n1JqL4mCzbSih8tvmMEMLpKTT7x5gCg=="],
"mongodb-connection-string-url": ["mongodb-connection-string-url@7.0.1", "", { "dependencies": { "@types/whatwg-url": "^13.0.0", "whatwg-url": "^14.1.0" } }, "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"nanostores": ["nanostores@1.2.0", "", {}, "sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg=="],
"napi-postinstall": ["napi-postinstall@0.3.2", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-tWVJxJHmBWLy69PvO96TZMZDrzmw5KeiZBz3RHmiM2XZ9grBJ2WgMAFVVg25nqp3ZjTFUs2Ftw1JhscL3Teliw=="], "napi-postinstall": ["napi-postinstall@0.3.2", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-tWVJxJHmBWLy69PvO96TZMZDrzmw5KeiZBz3RHmiM2XZ9grBJ2WgMAFVVg25nqp3ZjTFUs2Ftw1JhscL3Teliw=="],
"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=="],
@@ -1471,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=="],
@@ -1641,6 +1744,8 @@
"rope-sequence": ["rope-sequence@1.3.4", "", {}, "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="], "rope-sequence": ["rope-sequence@1.3.4", "", {}, "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="],
"rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
@@ -1659,6 +1764,8 @@
"server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="], "server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="],
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
"set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
@@ -1697,6 +1804,8 @@
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"sparse-bitfield": ["sparse-bitfield@3.0.3", "", { "dependencies": { "memory-pager": "^1.0.2" } }, "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ=="],
"split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="], "split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="],
"stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
@@ -1773,6 +1882,8 @@
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="],
"trim-canvas": ["trim-canvas@0.1.2", "", {}, "sha512-nd4Ga3iLFV94mdhW9JFMLpQbHUyCQuhFOD71PEAt1NjtMD5wbZctzhX8c3agHNybMR5zXD1XTGoIEWk995E6pQ=="], "trim-canvas": ["trim-canvas@0.1.2", "", {}, "sha512-nd4Ga3iLFV94mdhW9JFMLpQbHUyCQuhFOD71PEAt1NjtMD5wbZctzhX8c3agHNybMR5zXD1XTGoIEWk995E6pQ=="],
"ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
@@ -1809,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=="],
@@ -1837,6 +1950,10 @@
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
"whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
@@ -1857,12 +1974,18 @@
"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=="],
"@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.840.0", "", { "dependencies": { "@smithy/types": "^4.3.1", "tslib": "^2.6.2" } }, "sha512-xliuHaUFZxEx1NSXeLLZ9Dyu6+EJVQKEoD+yM+zqUo3YDZ7medKJWY6fIOKiPX/N7XbLdBYwajb15Q7IL8KkeA=="], "@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.840.0", "", { "dependencies": { "@smithy/types": "^4.3.1", "tslib": "^2.6.2" } }, "sha512-xliuHaUFZxEx1NSXeLLZ9Dyu6+EJVQKEoD+yM+zqUo3YDZ7medKJWY6fIOKiPX/N7XbLdBYwajb15Q7IL8KkeA=="],
"@aws-crypto/crc32c/@aws-sdk/types": ["@aws-sdk/types@3.840.0", "", { "dependencies": { "@smithy/types": "^4.3.1", "tslib": "^2.6.2" } }, "sha512-xliuHaUFZxEx1NSXeLLZ9Dyu6+EJVQKEoD+yM+zqUo3YDZ7medKJWY6fIOKiPX/N7XbLdBYwajb15Q7IL8KkeA=="], "@aws-crypto/crc32c/@aws-sdk/types": ["@aws-sdk/types@3.840.0", "", { "dependencies": { "@smithy/types": "^4.3.1", "tslib": "^2.6.2" } }, "sha512-xliuHaUFZxEx1NSXeLLZ9Dyu6+EJVQKEoD+yM+zqUo3YDZ7medKJWY6fIOKiPX/N7XbLdBYwajb15Q7IL8KkeA=="],
@@ -1883,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=="],
@@ -1965,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=="],
@@ -2097,6 +2234,8 @@
"eslint-import-resolver-typescript/tinyglobby/fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="], "eslint-import-resolver-typescript/tinyglobby/fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="],
"next-auth/@auth/core/jose": ["jose@6.0.12", "", {}, "sha512-T8xypXs8CpmiIi78k0E+Lk7T2zlK4zDyg+o1CZ4AkOHgDg98ogdP2BeZ61lTFKFyoEwJ9RgAgN+SdM3iPgNonQ=="],
"prosemirror-markdown/@types/markdown-it/@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="], "prosemirror-markdown/@types/markdown-it/@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="],
"prosemirror-markdown/@types/markdown-it/@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="], "prosemirror-markdown/@types/markdown-it/@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="],

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

@@ -0,0 +1,159 @@
# HRIStudio - March 2026 Development Summary
## What We Did This Session
### 1. Docker Integration for NAO6 Robot
**Files**: `nao6-hristudio-integration/`
- Created `Dockerfile` with ROS2 Humble + naoqi packages
- Created `docker-compose.yaml` with 3 services: `nao_driver`, `ros_bridge`, `ros_api`
- Created `scripts/init_robot.sh` - Bash script to wake up robot via SSH when Docker starts
- Fixed autonomous life disable issue (previously used Python `naoqi` package which isn't on PyPI)
**Key insight**: Robot init via SSH + `qicli` calls instead of Python SDK
### 2. Plugin System Fixes
**Files**: `robot-plugins/plugins/nao6-ros2.json`, `src/lib/ros/wizard-ros-service.ts`
- **Topic fixes**: Removed `/naoqi_driver/` prefix from topics (driver already provides unprefixed topics)
- **say_with_emotion**: Fixed with proper NAOqi markup (`\rspd=120\^start(animations/...)`)
- **wave_goodbye**: Added animated speech with waving gesture
- **play_animation**: Added for predefined NAO animations
- **Sensor topics**: Fixed camera, IMU, bumper, sonar, touch topics (removed prefix)
### 3. Database Schema - Plugin Identifier
**Files**: `src/server/db/schema.ts`, `src/server/services/trial-execution.ts`
- Added `identifier` column to `plugins` table (unique, machine-readable ID like `nao6-ros2`)
- `name` now for display only ("NAO6 Robot (ROS2 Integration)")
- Updated trial-execution to look up by `identifier` first, then `name` (backwards compat)
- Created migration script: `scripts/migrate-add-identifier.ts`
### 4. Seed Script Improvements
**Files**: `scripts/seed-dev.ts`
- Fixed to use local plugin file (not remote `repo.hristudio.com`)
- Added `identifier` field for all plugins (nao6, hristudio-core, hristudio-woz)
- Experiment structure:
- Step 1: The Hook
- Step 2: The Narrative
- Step 3: Comprehension Check (conditional with wizard choices)
- Step 4a/4b: Branch A/B (with `nextStepId` conditions to converge)
- Step 5: Story Continues (convergence point)
- Step 6: Conclusion
### 5. Robot Action Timing Fix
**Files**: `src/lib/ros/wizard-ros-service.ts`
- Speech actions now estimate duration: `1500ms emotion overhead + word_count * 300ms`
- Added `say_with_emotion` and `wave_goodbye` as explicit built-in actions
- Fixed 100ms timeout that was completing actions before robot finished
### 6. Branching Logic Fixes (Critical!)
**Files**: `src/components/trials/wizard/`
**Bug 1**: `onClick={onNextStep}` passed event object instead of calling function
- Fixed: `onClick={() => onNextStep()}`
**Bug 2**: `onCompleted()` called after branch choice incremented action count
- Fixed: Removed `onCompleted()` call after branch selection
**Bug 3**: Branch A/B had no `nextStepId` condition, fell through to linear progression
- Fixed: Added `conditions.nextStepId: step5.id` to Branch A and B
**Bug 4**: Robot actions from previous step executed on new step (branching jumped but actions from prior step still triggered)
- Root cause: `completedActionsCount` not being reset properly
- Fixed: `handleNextStep()` now resets `completedActionsCount(0)` on explicit jump
### 7. Auth.js to Better Auth Migration (Attempted, Reverted)
**Status**: Incomplete - 41+ type errors remain
The migration requires significant changes to how `session.user.roles` is accessed since Better Auth doesn't include roles in session by default. Would need to fetch roles from database on each request.
**Recommendation**: Defer until more development time available.
---
## Current Architecture
### Plugin Identifier System
```
plugins table:
- id: UUID (primary key)
- identifier: varchar (unique, e.g. "nao6-ros2")
- name: varchar (display, e.g. "NAO6 Robot (ROS2 Integration)")
- 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)
```
### Branching Flow
```
Step 3 (Comprehension Check)
└── wizard_wait_for_response action
├── Click "Correct" → setLastResponse("Correct") → nextStepId=step4a.id
└── Click "Incorrect" → setLastResponse("Incorrect") → nextStepId=step4b.id
Step 4a/4b (Branches)
└── conditions.nextStepId: step5.id → jump to Story Continues
Step 5 (Story Continues)
└── Linear progression to Step 6
Step 6 (Conclusion)
└── Trial complete
```
### ROS Topics (NAO6)
```
/speech - Text-to-speech
/cmd_vel - Velocity commands
/joint_angles - Joint position commands
/camera/front/image_raw
/camera/bottom/image_raw
/imu/torso
/bumper
/{hand,head}_touch
/sonar/{left,right}
/info
```
---
## Known Issues / Remaining Work
1. **Auth.js to Better Auth Migration** - Deferred, requires significant refactoring
2. **robots.executeSystemAction** - Procedure not found error (fallback works but should investigate)
3. **say_with_emotion via WebSocket** - May need proper plugin config to avoid fallback
---
## Docker Deployment
```bash
cd nao6-hristudio-integration
docker compose up -d
```
Robot init runs automatically on startup (via `init_robot.sh`).
---
## Testing Checklist
- [x] Docker builds and starts
- [x] Robot wakes up (autonomous life disabled)
- [x] Seed script runs successfully
- [x] Trial executes with proper branching
- [x] Branch A → Story Continues (not Branch B)
- [x] Robot speaks with emotion (say_with_emotion)
- [x] Wave gesture works
- [ ] Robot movement (walk, turn) tested
- [ ] All NAO6 actions verified
---
*Last Updated: March 21, 2026*

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

@@ -2,88 +2,112 @@
Essential commands for using NAO6 robots with HRIStudio. Essential commands for using NAO6 robots with HRIStudio.
## Quick Start ## Quick Start (Docker)
### 1. Start NAO Integration ### 1. Start Docker Integration
```bash ```bash
cd ~/naoqi_ros2_ws cd ~/Documents/Projects/nao6-hristudio-integration
source install/setup.bash docker compose up -d
ros2 launch nao_launch nao6_hristudio.launch.py nao_ip:=nao.local password:=robolab
``` ```
### 2. Wake Robot The robot will automatically wake up and autonomous life will be disabled on startup.
Press chest button for 3 seconds, or use:
```bash
# Via SSH (institution-specific password)
ssh nao@nao.local
# Then run wake-up command (see integration repo docs)
```
### 3. Start HRIStudio ### 2. Start HRIStudio
```bash ```bash
cd ~/Documents/Projects/hristudio cd ~/Documents/Projects/hristudio
bun dev bun dev
``` ```
### 4. Test Connection ### 3. Verify Connection
- Open: `http://localhost:3000/nao-test` - Open: `http://localhost:3000`
- Click "Connect" - Navigate to trial wizard
- Test robot commands - WebSocket should connect automatically
## Essential Commands ## Docker Services
### Test Connectivity | Service | Port | Description |
```bash |---------|------|-------------|
ping nao.local # Test network | nao_driver | - | NAOqi driver + robot init |
ros2 topic list | grep naoqi # Check ROS topics | ros_bridge | 9090 | WebSocket bridge |
``` | ros_api | - | ROS API services |
### Manual Control **Auto-initialization**: On Docker startup, `init_robot.sh` runs automatically via SSH to:
```bash - Wake up the robot (`ALMotion.wakeUp`)
# Speech - Disable autonomous life (`ALAutonomousLife.setState disabled`)
ros2 topic pub --once /speech std_msgs/String "data: 'Hello world'" - Ensure robot is ready for commands
# Movement (robot must be awake!)
ros2 topic pub --once /cmd_vel geometry_msgs/msg/Twist '{linear: {x: 0.1}}'
# Stop
ros2 topic pub --once /cmd_vel geometry_msgs/msg/Twist '{linear: {x: 0.0}}'
```
### Monitor Status
```bash
ros2 topic echo /naoqi_driver/battery # Battery level
ros2 topic echo /naoqi_driver/joint_states # Joint positions
```
## Troubleshooting
**Robot not moving:** Press chest button for 3 seconds to wake up
**WebSocket fails:** Check rosbridge is running on port 9090
```bash
ss -an | grep 9090
```
**Connection lost:** Restart rosbridge
```bash
pkill -f rosbridge
ros2 run rosbridge_server rosbridge_websocket
```
## ROS Topics ## ROS Topics
**Commands (Input):** **Commands (Publish to these):**
- `/speech` - Text-to-speech ```
- `/cmd_vel` - Movement /speech - Text-to-speech
- `/joint_angles` - Joint control /cmd_vel - Velocity commands (movement)
/joint_angles - Joint position commands
```
**Sensors (Output):** **Sensors (Subscribe to these):**
- `/naoqi_driver/joint_states` - Joint data ```
- `/naoqi_driver/battery` - Battery level /camera/front/image_raw - Front camera
- `/naoqi_driver/bumper` - Foot sensors /camera/bottom/image_raw - Bottom camera
- `/naoqi_driver/sonar/*` - Distance sensors /joint_states - Joint positions
- `/naoqi_driver/camera/*` - Camera feeds /imu/torso - IMU data
/bumper - Foot bumpers
/{hand,head}_touch - Touch sensors
/sonar/{left,right} - Ultrasonic sensors
/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
### Test Connectivity
```bash
# Network
ping 10.0.0.42
# ROS topics (inside Docker)
docker exec -it nao6-hristudio-integration-nao_driver-1 ros2 topic list
```
### Direct Commands (inside Docker)
```bash
# Speech
docker exec -it nao6-hristudio-integration-nao_driver-1 \
ros2 topic pub --once /speech std_msgs/String "{data: 'Hello'}"
# Movement (robot must be awake!)
docker exec -it nao6-hristudio-integration-nao_driver-1 \
ros2 topic pub --once /cmd_vel geometry_msgs/Twist "{linear: {x: 0.1, y: 0.0, z: 0.0}}"
```
### Robot Control via SSH
```bash
# SSH to robot
sshpass -p "nao" ssh nao@10.0.0.42
# Wake up
qicli call ALMotion.wakeUp
# Disable autonomous life
qicli call ALAutonomousLife.setState disabled
# Go to stand
qicli call ALRobotPosture.goToPosture Stand 0.5
```
## WebSocket ## WebSocket
@@ -99,79 +123,76 @@ ros2 run rosbridge_server rosbridge_websocket
} }
``` ```
## More Information ## Troubleshooting
See **[nao6-hristudio-integration](../../nao6-hristudio-integration/)** repository for: **Robot not moving:**
- Complete installation guide - Check robot is awake: `qicli call ALMotion.isWakeUp` → returns `true`
- Detailed usage instructions - If not: `qicli call ALMotion.wakeUp`
- Full troubleshooting guide
- Plugin definitions
- Launch file configurations
## Common Use Cases **WebSocket fails:**
### Make Robot Speak
```bash ```bash
ros2 topic pub --once /speech std_msgs/String "data: 'Welcome to the experiment'" # Check rosbridge is running
docker compose ps
# View logs
docker compose logs ros_bridge
``` ```
### Walk Forward 3 Steps **Connection issues:**
```bash ```bash
ros2 topic pub --times 3 /cmd_vel geometry_msgs/msg/Twist '{linear: {x: 0.1, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.0}}' # Restart Docker
docker compose down && docker compose up -d
# Check robot IP in .env
cat nao6-hristudio-integration/.env
``` ```
### Turn Head Left ## Environment Variables
```bash
ros2 topic pub --once /joint_angles naoqi_bridge_msgs/msg/JointAnglesWithSpeed '{joint_names: ["HeadYaw"], joint_angles: [0.8], speed: 0.2}'
```
### Emergency Stop Create `nao6-hristudio-integration/.env`:
```bash ```
ros2 topic pub --once /cmd_vel geometry_msgs/msg/Twist '{linear: {x: 0.0, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.0}}' NAO_IP=10.0.0.42
NAO_USERNAME=nao
NAO_PASSWORD=nao
BRIDGE_PORT=9090
``` ```
## 🚨 Safety Notes ## 🚨 Safety Notes
- **Always wake up robot before movement commands** - **Always verify robot is awake before movement commands**
- **Keep emergency stop accessible** - **Keep emergency stop accessible** (`qicli call ALMotion.rest()`)
- **Start with small movements (0.05 m/s)** - **Start with small movements (0.05 m/s)**
- **Monitor battery level during experiments** - **Monitor battery level**
- **Ensure clear space around robot** - **Ensure clear space around robot**
## 📝 Credentials ## Credentials
**Default NAO Login:** **NAO Robot:**
- IP: `10.0.0.42` (configurable)
- Username: `nao` - Username: `nao`
- Password: `robolab` (institution-specific) - Password: `nao`
**HRIStudio Login:** **HRIStudio:**
- Email: `sean@soconnor.dev` - Email: `sean@soconnor.dev`
- Password: `password123` - Password: `password123`
## 🔄 Complete Restart Procedure ## Complete Restart
```bash ```bash
# 1. Kill all processes # 1. Restart Docker integration
sudo fuser -k 9090/tcp cd ~/Documents/Projects/nao6-hristudio-integration
pkill -f "rosbridge\|naoqi\|ros2" docker compose down
docker compose up -d
# 2. Restart database # 2. Verify robot is awake (check logs)
sudo docker compose down && sudo docker compose up -d docker compose logs nao_driver | grep -i "wake\|autonomous"
# 3. Start ROS integration # 3. Start HRIStudio
cd ~/naoqi_ros2_ws && source install/setup.bash cd ~/Documents/Projects/hristudio
ros2 launch install/nao_launch/share/nao_launch/launch/nao6_hristudio.launch.py nao_ip:=nao.local password:=robolab bun dev
# 4. Wake up robot (in another terminal)
sshpass -p "robolab" ssh nao@nao.local "python2 -c \"import sys; sys.path.append('/opt/aldebaran/lib/python2.7/site-packages'); import naoqi; naoqi.ALProxy('ALMotion', '127.0.0.1', 9559).wakeUp()\""
# 5. Start HRIStudio (in another terminal)
cd /home/robolab/Documents/Projects/hristudio && bun dev
``` ```
--- ---
**📖 For detailed setup instructions, see:** [NAO6 Complete Integration Guide](./nao6-integration-complete-guide.md)
**✅ Integration Status:** Production Ready **✅ Integration Status:** Production Ready
**🤖 Tested With:** NAO V6.0 / NAOqi 2.8.7.4 / ROS2 Humble **🤖 Tested With:** NAO V6 / ROS2 Humble / Docker

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,566 +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 ```bash
``` cd ~/Documents/Projects/nao6-hristudio-integration
http://localhost:3000/api/trpc/ docker compose up -d
``` ```
### Key Routers **Services**: nao_driver, ros_bridge (:9090), ros_api
- **`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
### Example Usage **Topics**:
```typescript - `/speech` - TTS
// Get user's studies - `/cmd_vel` - Movement
const studies = api.studies.getUserStudies.useQuery(); - `/leds/eyes` - LEDs
// Create new experiment ---
const createExperiment = api.experiments.create.useMutation();
## Architecture Layers
```
┌─────────────────────────────────────┐
│ UI: Design / Execute / Playback │
├─────────────────────────────────────┤
│ Server: tRPC, Auth, Trial Logic │
├─────────────────────────────────────┤
│ Data: PostgreSQL, File Storage │
│ Robot: ROS2 via WebSocket │
└─────────────────────────────────────┘
``` ```
--- ---
## 🗄️ **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.*

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1774137504617,
"tag": "0000_old_tattoo",
"breakpoints": true
}
]
}

View File

@@ -1,55 +1,27 @@
import type { Session } from "next-auth";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "./src/server/auth";
export default auth((req: NextRequest & { auth: Session | null }) => { export default async function middleware(request: NextRequest) {
const { nextUrl } = req; const { nextUrl } = request;
const isLoggedIn = !!req.auth;
// Define route patterns // Skip session checks for now to debug the auth issue
const isApiAuthRoute = nextUrl.pathname.startsWith("/api/auth"); const isApiRoute = nextUrl.pathname.startsWith("/api");
const isPublicRoute = ["/", "/auth/signin", "/auth/signup"].includes(
nextUrl.pathname,
);
const isAuthRoute = nextUrl.pathname.startsWith("/auth"); const isAuthRoute = nextUrl.pathname.startsWith("/auth");
// Allow API auth routes to pass through if (isApiRoute) {
if (isApiAuthRoute) {
return NextResponse.next(); return NextResponse.next();
} }
// If user is on auth pages and already logged in, redirect to dashboard // Allow auth routes through for now
if (isAuthRoute && isLoggedIn) { if (isAuthRoute) {
return NextResponse.redirect(new URL("/", nextUrl)); return NextResponse.next();
}
// If user is not logged in and trying to access protected routes
if (!isLoggedIn && !isPublicRoute && !isAuthRoute) {
let callbackUrl = nextUrl.pathname;
if (nextUrl.search) {
callbackUrl += nextUrl.search;
}
const encodedCallbackUrl = encodeURIComponent(callbackUrl);
return NextResponse.redirect(
new URL(`/auth/signin?callbackUrl=${encodedCallbackUrl}`, nextUrl),
);
} }
return NextResponse.next(); return NextResponse.next();
}); }
// Configure which routes the middleware should run on
export const config = { export const config = {
matcher: [ matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - public files (images, etc.)
*/
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
], ],
}; };

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",
@@ -26,6 +27,7 @@
"@auth/drizzle-adapter": "^1.11.1", "@auth/drizzle-adapter": "^1.11.1",
"@aws-sdk/client-s3": "^3.989.0", "@aws-sdk/client-s3": "^3.989.0",
"@aws-sdk/s3-request-presigner": "^3.989.0", "@aws-sdk/s3-request-presigner": "^3.989.0",
"@better-auth/drizzle-adapter": "^1.5.5",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@@ -67,6 +69,7 @@
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-auth": "^1.5.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
@@ -77,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",
@@ -106,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",
@@ -131,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

@@ -564,6 +564,7 @@ async function seedNAO6Plugin() {
const pluginData: InsertPlugin = { const pluginData: InsertPlugin = {
robotId: robotId, robotId: robotId,
identifier: "nao6-ros2",
name: "NAO6 Robot (Enhanced ROS2 Integration)", name: "NAO6 Robot (Enhanced ROS2 Integration)",
version: "2.0.0", version: "2.0.0",
description: description:

View File

@@ -0,0 +1,274 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "../../src/server/db/schema";
import { sql } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid";
// Database connection
const connectionString = process.env.DATABASE_URL!;
const connection = postgres(connectionString);
const db = drizzle(connection, { schema });
async function main() {
console.log("🌱 Seeding 'Story: Red Rock' experiment...");
try {
// 1. Find Admin User & Study
const user = await db.query.users.findFirst({
where: (users, { eq }) => eq(users.email, "sean@soconnor.dev"),
});
if (!user) throw new Error("Admin user 'sean@soconnor.dev' not found.");
const study = await db.query.studies.findFirst({
where: (studies, { eq }) => eq(studies.name, "Comparative WoZ Study"),
});
if (!study) throw new Error("Study 'Comparative WoZ Study' not found.");
const robot = await db.query.robots.findFirst({
where: (robots, { eq }) => eq(robots.name, "NAO6"),
});
if (!robot) throw new Error("Robot 'NAO6' not found.");
// 2. Create Experiment
const [experiment] = await db
.insert(schema.experiments)
.values({
studyId: study.id,
name: "Story: Red Rock",
description:
"A story about a red rock on Mars with comprehension check and branching.",
version: 1,
status: "draft",
robotId: robot.id,
createdBy: user.id,
})
.returning();
if (!experiment) throw new Error("Failed to create experiment");
console.log(`✅ Created Experiment: ${experiment.id}`);
// 3. Create Steps (in reverse for ID references if needed, but we'll use uuid placeholders)
const conclusionId = uuidv4();
const branchAId = uuidv4();
const branchBId = uuidv4();
const checkId = uuidv4();
// Step 1: The Hook
const [step1] = await db
.insert(schema.steps)
.values({
experimentId: experiment.id,
name: "The Hook",
type: "wizard",
orderIndex: 0,
})
.returning();
// Step 2: The Narrative
const [step2] = await db
.insert(schema.steps)
.values({
experimentId: experiment.id,
name: "The Narrative",
type: "wizard",
orderIndex: 1,
})
.returning();
// Step 3: Comprehension Check (Conditional)
const [step3] = await db
.insert(schema.steps)
.values({
id: checkId,
experimentId: experiment.id,
name: "Comprehension Check",
type: "conditional",
orderIndex: 2,
conditions: {
variable: "last_wizard_response",
options: [
{
label: "Answer: Red (Correct)",
value: "Red",
variant: "default",
nextStepId: branchAId,
},
{
label: "Answer: Other (Incorrect)",
value: "Incorrect",
variant: "destructive",
nextStepId: branchBId,
},
],
},
})
.returning();
// Step 4: Branch A (Correct)
const [step4] = await db
.insert(schema.steps)
.values({
id: branchAId,
experimentId: experiment.id,
name: "Branch A: Correct Response",
type: "wizard",
orderIndex: 3,
conditions: { nextStepId: conclusionId }, // SKIP BRANCH B
})
.returning();
// Step 5: Branch B (Incorrect)
const [step5] = await db
.insert(schema.steps)
.values({
id: branchBId,
experimentId: experiment.id,
name: "Branch B: Incorrect Response",
type: "wizard",
orderIndex: 4,
conditions: { nextStepId: conclusionId },
})
.returning();
// Step 6: Conclusion
const [step6] = await db
.insert(schema.steps)
.values({
id: conclusionId,
experimentId: experiment.id,
name: "Conclusion",
type: "wizard",
orderIndex: 5,
})
.returning();
// 4. Create Actions
// The Hook
await db.insert(schema.actions).values([
{
stepId: step1!.id,
name: "Say Hello",
type: "nao6-ros2.say_text",
orderIndex: 0,
parameters: { text: "Hello! Are you ready for a story?" },
},
{
stepId: step1!.id,
name: "Wave",
type: "nao6-ros2.move_arm",
orderIndex: 1,
parameters: { arm: "right", shoulder_pitch: 0.5 },
},
]);
// The Narrative
await db.insert(schema.actions).values([
{
stepId: step2!.id,
name: "The Story",
type: "nao6-ros2.say_text",
orderIndex: 0,
parameters: {
text: "Once, a traveler went to Mars. He found a bright red rock that glowed.",
},
},
{
stepId: step2!.id,
name: "Look Left",
type: "nao6-ros2.turn_head",
orderIndex: 1,
parameters: { yaw: 0.5, speed: 0.3 },
},
{
stepId: step2!.id,
name: "Look Right",
type: "nao6-ros2.turn_head",
orderIndex: 2,
parameters: { yaw: -0.5, speed: 0.3 },
},
]);
// Comprehension Check
await db.insert(schema.actions).values([
{
stepId: step3!.id,
name: "Ask Color",
type: "nao6-ros2.say_text",
orderIndex: 0,
parameters: { text: "What color was the rock I found on Mars?" },
},
{
stepId: step3!.id,
name: "Wait for Color",
type: "wizard_wait_for_response",
orderIndex: 1,
parameters: {
options: ["Red", "Blue", "Green", "Incorrect"],
prompt_text: "What color did the participant say?",
},
},
]);
// Branch A (Using say_with_emotion)
await db
.insert(schema.actions)
.values([
{
stepId: step4!.id,
name: "Happy Response",
type: "nao6-ros2.say_with_emotion",
orderIndex: 0,
parameters: {
text: "Exacty! It was a glowing red rock.",
emotion: "happy",
},
},
]);
// Branch B
await db.insert(schema.actions).values([
{
stepId: step5!.id,
name: "Correct them",
type: "nao6-ros2.say_text",
orderIndex: 0,
parameters: { text: "Actually, it was red." },
},
{
stepId: step5!.id,
name: "Shake Head",
type: "nao6-ros2.turn_head",
orderIndex: 1,
parameters: { yaw: 0.3, speed: 0.5 },
},
]);
// Conclusion
await db.insert(schema.actions).values([
{
stepId: step6!.id,
name: "Final Goodbye",
type: "nao6-ros2.say_text",
orderIndex: 0,
parameters: { text: "That is all for today. Goodbye!" },
},
{
stepId: step6!.id,
name: "Rest",
type: "nao6-ros2.move_arm",
orderIndex: 1,
parameters: { shoulder_pitch: 1.5 },
},
]);
console.log("✅ Seed completed successfully!");
} catch (err) {
console.error("❌ Seed failed:", err);
process.exit(1);
} finally {
await connection.end();
}
}
main();

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

@@ -0,0 +1,37 @@
import { db } from "~/server/db";
import { sql } from "drizzle-orm";
async function migrate() {
console.log("Adding identifier column to hs_plugin...");
try {
await db.execute(
sql`ALTER TABLE hs_plugin ADD COLUMN identifier varchar(100)`,
);
console.log("✓ Added identifier column");
} catch (e: any) {
console.log("Column may already exist:", e.message);
}
try {
await db.execute(
sql`UPDATE hs_plugin SET identifier = name WHERE identifier IS NULL`,
);
console.log("✓ Copied name to identifier");
} catch (e: any) {
console.log("Error copying:", e.message);
}
try {
await db.execute(
sql`ALTER TABLE hs_plugin ADD CONSTRAINT hs_plugin_identifier_unique UNIQUE (identifier)`,
);
console.log("✓ Added unique constraint");
} catch (e: any) {
console.log("Constraint may already exist:", e.message);
}
console.log("Migration complete!");
}
migrate().catch(console.error);

View File

@@ -14,31 +14,31 @@ const db = drizzle(connection, { schema });
import * as fs from "fs"; import * as fs from "fs";
import * as path from "path"; import * as path from "path";
// Function to load plugin definition (Remote -> Local Fallback) // Function to load plugin definition (Local first -> Remote fallback)
async function loadNaoPluginDef() { async function loadNaoPluginDef() {
const REMOTE_URL = "https://repo.hristudio.com/plugins/nao6-ros2.json";
const LOCAL_PATH = path.join( const LOCAL_PATH = path.join(
__dirname, __dirname,
"../robot-plugins/plugins/nao6-ros2.json", "../robot-plugins/plugins/nao6-ros2.json",
); );
const REMOTE_URL = "https://repo.hristudio.com/plugins/nao6-ros2.json";
// Always load from local file first (has latest fixes)
try { try {
console.log( console.log(`📁 Loading plugin definition from local file...`);
`🌐 Attempting to fetch plugin definition from ${REMOTE_URL}...`, const rawPlugin = fs.readFileSync(LOCAL_PATH, "utf-8");
console.log("✅ Successfully loaded local plugin definition.");
return JSON.parse(rawPlugin);
} catch (err) {
console.warn(
`⚠️ Local file load failed. Falling back to remote: ${REMOTE_URL}`,
); );
const response = await fetch(REMOTE_URL, { const response = await fetch(REMOTE_URL, {
signal: AbortSignal.timeout(3000), signal: AbortSignal.timeout(5000),
}); // 3s timeout });
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json(); const data = await response.json();
console.log("✅ Successfully fetched plugin definition from remote."); console.log("✅ Successfully fetched plugin definition from remote.");
return data; return data;
} catch (err) {
console.warn(
`⚠️ Remote fetch failed (${err instanceof Error ? err.message : String(err)}). Falling back to local file.`,
);
const rawPlugin = fs.readFileSync(LOCAL_PATH, "utf-8");
return JSON.parse(rawPlugin);
} }
} }
@@ -76,6 +76,9 @@ async function main() {
// 1. Clean existing data (Full Wipe) // 1. Clean existing data (Full Wipe)
console.log("🧹 Cleaning existing data..."); console.log("🧹 Cleaning existing data...");
await db.delete(schema.sessions).where(sql`1=1`);
await db.delete(schema.accounts).where(sql`1=1`);
await db.delete(schema.verificationTokens).where(sql`1=1`);
await db.delete(schema.mediaCaptures).where(sql`1=1`); await db.delete(schema.mediaCaptures).where(sql`1=1`);
await db.delete(schema.trialEvents).where(sql`1=1`); await db.delete(schema.trialEvents).where(sql`1=1`);
await db.delete(schema.trials).where(sql`1=1`); await db.delete(schema.trials).where(sql`1=1`);
@@ -93,20 +96,24 @@ async function main() {
await db.delete(schema.users).where(sql`1=1`); await db.delete(schema.users).where(sql`1=1`);
await db.delete(schema.robots).where(sql`1=1`); await db.delete(schema.robots).where(sql`1=1`);
// 2. Create Users // 2. Create Users (Better Auth manages credentials)
console.log("👥 Creating users..."); console.log("👥 Creating users...");
const hashedPassword = await bcrypt.hash("password123", 12); const hashedPassword = await bcrypt.hash("password123", 12);
const gravatarUrl = (email: string) => const gravatarUrl = (email: string) =>
`https://www.gravatar.com/avatar/${createHash("md5").update(email.toLowerCase().trim()).digest("hex")}?d=identicon`; `https://www.gravatar.com/avatar/${createHash("md5").update(email.toLowerCase().trim()).digest("hex")}?d=identicon`;
// Generate text IDs (Better Auth uses text-based IDs)
const adminId = `admin_${randomUUID()}`;
const researcherId = `researcher_${randomUUID()}`;
const [adminUser] = await db const [adminUser] = await db
.insert(schema.users) .insert(schema.users)
.values({ .values({
id: adminId,
name: "Sean O'Connor", name: "Sean O'Connor",
email: "sean@soconnor.dev", email: "sean@soconnor.dev",
password: hashedPassword, emailVerified: true,
emailVerified: new Date(),
image: gravatarUrl("sean@soconnor.dev"), image: gravatarUrl("sean@soconnor.dev"),
}) })
.returning(); .returning();
@@ -114,16 +121,39 @@ async function main() {
const [researcherUser] = await db const [researcherUser] = await db
.insert(schema.users) .insert(schema.users)
.values({ .values({
id: researcherId,
name: "Dr. Felipe Perrone", name: "Dr. Felipe Perrone",
email: "felipe.perrone@bucknell.edu", email: "felipe.perrone@bucknell.edu",
password: hashedPassword, emailVerified: true,
emailVerified: new Date(),
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Felipe", image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Felipe",
}) })
.returning(); .returning();
if (!adminUser) throw new Error("Failed to create admin user"); if (!adminUser) throw new Error("Failed to create admin user");
// Create credential accounts for Better Auth (accountId = userId for credential provider)
await db.insert(schema.accounts).values({
id: `acc_${randomUUID()}`,
userId: adminUser.id,
providerId: "credential",
accountId: adminUser.id,
password: hashedPassword,
});
if (researcherUser) {
await db.insert(schema.accounts).values({
id: `acc_${randomUUID()}`,
userId: researcherUser.id,
providerId: "credential",
accountId: researcherUser.id,
password: hashedPassword,
});
await db
.insert(schema.userSystemRoles)
.values({ userId: researcherUser.id, role: "researcher" });
}
await db await db
.insert(schema.userSystemRoles) .insert(schema.userSystemRoles)
.values({ userId: adminUser.id, role: "administrator" }); .values({ userId: adminUser.id, role: "administrator" });
@@ -159,6 +189,7 @@ async function main() {
.insert(schema.plugins) .insert(schema.plugins)
.values({ .values({
robotId: naoRobot!.id, robotId: naoRobot!.id,
identifier: NAO_PLUGIN_DEF.robotId,
name: NAO_PLUGIN_DEF.name, name: NAO_PLUGIN_DEF.name,
version: NAO_PLUGIN_DEF.version, version: NAO_PLUGIN_DEF.version,
description: NAO_PLUGIN_DEF.description, description: NAO_PLUGIN_DEF.description,
@@ -192,10 +223,103 @@ 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)
.values({ .values({
identifier: CORE_PLUGIN_DEF.id,
name: CORE_PLUGIN_DEF.name, name: CORE_PLUGIN_DEF.name,
version: CORE_PLUGIN_DEF.version, version: CORE_PLUGIN_DEF.version,
description: CORE_PLUGIN_DEF.description, description: CORE_PLUGIN_DEF.description,
@@ -211,6 +335,7 @@ async function main() {
const [wozPlugin] = await db const [wozPlugin] = await db
.insert(schema.plugins) .insert(schema.plugins)
.values({ .values({
identifier: WOZ_PLUGIN_DEF.id,
name: WOZ_PLUGIN_DEF.name, name: WOZ_PLUGIN_DEF.name,
version: WOZ_PLUGIN_DEF.version, version: WOZ_PLUGIN_DEF.version,
description: WOZ_PLUGIN_DEF.description, description: WOZ_PLUGIN_DEF.description,
@@ -262,6 +387,35 @@ async function main() {
// 5. Create Steps & Actions (The Interactive Storyteller Protocol) // 5. Create Steps & Actions (The Interactive Storyteller Protocol)
console.log("🎬 Creating experiment steps (Interactive Storyteller)..."); console.log("🎬 Creating experiment steps (Interactive Storyteller)...");
// Pre-create steps that will be referenced before they're defined
// --- Step 5: Story Continues (Convergence point for both branches) ---
const [step5] = await db
.insert(schema.steps)
.values({
experimentId: experiment!.id,
name: "Story Continues",
description: "Both branches converge here",
type: "robot",
orderIndex: 5,
required: true,
durationEstimate: 15,
})
.returning();
// --- Step 6: Conclusion ---
const [step6] = await db
.insert(schema.steps)
.values({
experimentId: experiment!.id,
name: "Conclusion",
description: "End the story and thank participant",
type: "robot",
orderIndex: 6,
required: true,
durationEstimate: 25,
})
.returning();
// --- Step 1: The Hook --- // --- Step 1: The Hook ---
const [step1] = await db const [step1] = await db
.insert(schema.steps) .insert(schema.steps)
@@ -363,10 +517,6 @@ async function main() {
}, },
]); ]);
// --- Step 3: Comprehension Check (Wizard Decision Point) ---
// Note: Wizard will choose to proceed to Step 4a (Correct) or 4b (Incorrect)
// --- Step 3: Comprehension Check (Wizard Decision Point) ---
// Note: Wizard will choose to proceed to Step 4a (Correct) or 4b (Incorrect)
// --- Step 4a: Correct Response Branch --- // --- Step 4a: Correct Response Branch ---
const [step4a] = await db const [step4a] = await db
.insert(schema.steps) .insert(schema.steps)
@@ -378,6 +528,9 @@ async function main() {
orderIndex: 3, orderIndex: 3,
required: false, required: false,
durationEstimate: 20, durationEstimate: 20,
conditions: {
nextStepId: step5!.id, // Jump to Story Continues after completing
},
}) })
.returning(); .returning();
@@ -392,11 +545,13 @@ async function main() {
orderIndex: 4, orderIndex: 4,
required: false, required: false,
durationEstimate: 20, durationEstimate: 20,
conditions: {
nextStepId: step5!.id, // Jump to Story Continues after completing
},
}) })
.returning(); .returning();
// --- Step 3: Comprehension Check (Wizard Decision Point) --- // --- Step 3: Comprehension Check (Wizard Decision Point) ---
// Note: Wizard will choose to proceed to Step 4a (Correct) or 4b (Incorrect)
const [step3] = await db const [step3] = await db
.insert(schema.steps) .insert(schema.steps)
.values({ .values({
@@ -445,10 +600,12 @@ async function main() {
name: "Wait for Choice", name: "Wait for Choice",
type: "wizard_wait_for_response", type: "wizard_wait_for_response",
orderIndex: 1, orderIndex: 1,
// Define the options that will be presented to the Wizard
parameters: { parameters: {
prompt_text: "Did participant answer 'Red' correctly?", prompt_text: "Did participant answer 'Red' correctly?",
options: ["Correct", "Incorrect"], options: [
{ label: "Correct", value: "Correct", nextStepId: step4a!.id },
{ label: "Incorrect", value: "Incorrect", nextStepId: step4b!.id },
],
}, },
sourceKind: "core", sourceKind: "core",
pluginId: "hristudio-woz", // Explicit link pluginId: "hristudio-woz", // Explicit link
@@ -553,23 +710,42 @@ async function main() {
}, },
]); ]);
// --- Step 5: Conclusion --- // --- Step 5 actions: Story Continues ---
const [step5] = await db
.insert(schema.steps)
.values({
experimentId: experiment!.id,
name: "Conclusion",
description: "End the story and thank participant",
type: "robot",
orderIndex: 5,
required: true,
durationEstimate: 25,
})
.returning();
await db.insert(schema.actions).values([ await db.insert(schema.actions).values([
{ {
stepId: step5!.id, stepId: step5!.id,
name: "Excited Continuation",
type: "nao6-ros2.say_with_emotion",
orderIndex: 0,
parameters: {
text: "And so the adventure continues! The traveler kept the glowing rock as a precious treasure.",
emotion: "excited",
speed: 1.1,
},
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
pluginVersion: "2.2.0",
category: "interaction",
retryable: true,
},
{
stepId: step5!.id,
name: "Wave Goodbye",
type: "nao6-ros2.wave_goodbye",
orderIndex: 1,
parameters: {
text: "See you later!",
},
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
pluginVersion: "2.2.0",
category: "interaction",
retryable: true,
},
]);
// --- Step 6 actions: Conclusion ---
await db.insert(schema.actions).values([
{
stepId: step6!.id,
name: "End Story", name: "End Story",
type: "nao6-ros2.say_text", type: "nao6-ros2.say_text",
orderIndex: 0, orderIndex: 0,
@@ -580,7 +756,7 @@ async function main() {
retryable: true, retryable: true,
}, },
{ {
stepId: step5!.id, stepId: step6!.id,
name: "Bow Gesture", name: "Bow Gesture",
type: "nao6-ros2.move_arm", type: "nao6-ros2.move_arm",
orderIndex: 1, orderIndex: 1,
@@ -843,6 +1019,22 @@ async function main() {
.values(participants) .values(participants)
.returning(); .returning();
// 7. Pre-create a pending trial for immediate testing
console.log("🧪 Creating a pre-seeded pending trial for testing...");
const p001 = insertedParticipants.find((p) => p.participantCode === "P101");
const [pendingTrial] = await db
.insert(schema.trials)
.values({
experimentId: experiment!.id,
participantId: p001?.id,
status: "scheduled",
scheduledAt: new Date(),
})
.returning();
console.log(` Created pending trial: ${pendingTrial?.id}`);
console.log("\n✅ Database seeded successfully!"); console.log("\n✅ Database seeded successfully!");
console.log(`Summary:`); console.log(`Summary:`);
console.log(`- 1 Admin User (sean@soconnor.dev)`); console.log(`- 1 Admin User (sean@soconnor.dev)`);
@@ -1024,7 +1216,7 @@ async function main() {
trialId: analyticsTrial!.id, trialId: analyticsTrial!.id,
eventType: "step_changed", eventType: "step_changed",
timestamp: new Date(currentTime), timestamp: new Date(currentTime),
data: { stepId: step5!.id, stepName: "Conclusion" }, data: { stepId: step6!.id, stepName: "Conclusion" },
}); });
advance(2); advance(2);

View File

@@ -1,5 +1,5 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { cookies } from "next/headers"; import { cookies, headers } from "next/headers";
import { import {
SidebarInset, SidebarInset,
SidebarProvider, SidebarProvider,
@@ -7,7 +7,7 @@ import {
} from "~/components/ui/sidebar"; } from "~/components/ui/sidebar";
import { Separator } from "~/components/ui/separator"; import { Separator } from "~/components/ui/separator";
import { AppSidebar } from "~/components/dashboard/app-sidebar"; import { AppSidebar } from "~/components/dashboard/app-sidebar";
import { auth } from "~/server/auth"; import { auth } from "~/lib/auth";
import { import {
BreadcrumbProvider, BreadcrumbProvider,
BreadcrumbDisplay, BreadcrumbDisplay,
@@ -22,16 +22,15 @@ interface DashboardLayoutProps {
export default async function DashboardLayout({ export default async function DashboardLayout({
children, children,
}: DashboardLayoutProps) { }: DashboardLayoutProps) {
const session = await auth(); const session = await auth.api.getSession({
headers: await headers(),
});
if (!session?.user) { if (!session?.user) {
redirect("/auth/signin"); redirect("/auth/signin");
} }
const userRole = const userRole = "researcher"; // Default role for dashboard access
typeof session.user.roles?.[0] === "string"
? session.user.roles[0]
: (session.user.roles?.[0]?.role ?? "observer");
const cookieStore = await cookies(); const cookieStore = await cookies();
const defaultOpen = cookieStore.get("sidebar_state")?.value === "true"; const defaultOpen = cookieStore.get("sidebar_state")?.value === "true";

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,200 +37,375 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "~/components/ui/card"; } from "~/components/ui/card";
import { Separator } from "~/components/ui/separator";
import { PageHeader } from "~/components/ui/page-header";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { formatRole, getRoleDescription } from "~/lib/auth-client";
import { import {
User, Dialog,
Shield, DialogContent,
Download, DialogDescription,
Trash2, DialogFooter,
ExternalLink, DialogHeader,
Lock, DialogTitle,
UserCog, DialogTrigger,
Mail, } from "~/components/ui/dialog";
Fingerprint,
} from "lucide-react";
import { useSession } from "next-auth/react";
import { cn } from "~/lib/utils";
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: string | Date;
}>;
} }
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>
@@ -213,17 +413,12 @@ function ProfileContent({ user }: { user: ProfileUser }) {
} }
export default function ProfilePage() { export default function ProfilePage() {
const { data: session, status } = useSession(); const { data: session, isPending } = useSession();
useBreadcrumbsEffect([ if (isPending) {
{ label: "Dashboard", href: "/dashboard" },
{ label: "Profile" },
]);
if (status === "loading") {
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>
); );
} }
@@ -232,7 +427,5 @@ export default function ProfilePage() {
redirect("/auth/signin"); redirect("/auth/signin");
} }
const user = session.user; return <ProfilePageContent />;
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

@@ -27,7 +27,7 @@ import {
} from "~/components/ui/entity-view"; } from "~/components/ui/entity-view";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider"; import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { useSession } from "next-auth/react"; import { useSession } from "~/lib/auth-client";
import { useStudyManagement } from "~/hooks/useStudyManagement"; import { useStudyManagement } from "~/hooks/useStudyManagement";
interface ExperimentDetailPageProps { interface ExperimentDetailPageProps {
@@ -99,6 +99,9 @@ export default function ExperimentDetailPage({
params, params,
}: ExperimentDetailPageProps) { }: ExperimentDetailPageProps) {
const { data: session } = useSession(); const { data: session } = useSession();
const { data: userData } = api.auth.me.useQuery(undefined, {
enabled: !!session?.user,
});
const [experiment, setExperiment] = useState<Experiment | null>(null); const [experiment, setExperiment] = useState<Experiment | null>(null);
const [trials, setTrials] = useState<Trial[]>([]); const [trials, setTrials] = useState<Trial[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -181,7 +184,7 @@ export default function ExperimentDetailPage({
const description = experiment.description; const description = experiment.description;
// Check if user can edit this experiment // Check if user can edit this experiment
const userRoles = session?.user?.roles?.map((r) => r.role) ?? []; const userRoles = userData?.roles ?? [];
const canEdit = const canEdit =
userRoles.includes("administrator") || userRoles.includes("researcher"); userRoles.includes("administrator") || userRoles.includes("researcher");

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,317 +1,270 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSession } from "next-auth/react"; import { useParams, useRouter } from "next/navigation";
import { useSession } from "~/lib/auth-client";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { FileText, Loader2, Plus, Download, Edit2, Eye, Save } from "lucide-react"; import Link from "next/link";
import { import {
EntityView, FileText,
EntityViewHeader, Plus,
EntityViewSection, Search,
EmptyState, ClipboardList,
} from "~/components/ui/entity-view"; FileQuestion,
FileSignature,
MoreHorizontal,
Trash2,
Eye,
CheckCircle,
} from "lucide-react";
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 border-input bg-transparent rounded-tr-md rounded-tl-md p-1 flex items-center gap-1 flex-wrap"> 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="w-[1px] h-6 bg-border mx-1" />
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={editor.isActive('heading', { level: 1 }) ? 'bg-muted' : ''}
>
<Heading1 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={editor.isActive('heading', { level: 2 }) ? 'bg-muted' : ''}
>
<Heading2 className="h-4 w-4" />
</Button>
<div className="w-[1px] h-6 bg-border mx-1" />
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={editor.isActive('bulletList') ? 'bg-muted' : ''}
>
<List className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={editor.isActive('orderedList') ? 'bg-muted' : ''}
>
<ListOrdered className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={editor.isActive('blockquote') ? 'bg-muted' : ''}
>
<Quote className="h-4 w-4" />
</Button>
<div className="w-[1px] h-6 bg-border mx-1" />
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()}
>
<TableIcon className="h-4 w-4" />
</Button>
</div>
);
}; };
interface StudyFormsPageProps { interface StudyFormsPageProps {
params: Promise<{ params: Promise<{
id: string; id: string;
}>; }>;
} }
export default function StudyFormsPage({ params }: StudyFormsPageProps) { export default function StudyFormsPage({ params }: StudyFormsPageProps) {
const { data: session } = useSession(); const { data: session } = useSession();
const utils = api.useUtils(); const router = useRouter();
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(null); const utils = api.useUtils();
const [editorTarget, setEditorTarget] = useState<string>(""); const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(
null,
);
const [search, setSearch] = useState("");
useEffect(() => { useEffect(() => {
const resolveParams = async () => { const resolveParams = async () => {
const resolved = await params; const resolved = await params;
setResolvedParams(resolved); setResolvedParams(resolved);
};
void resolveParams();
}, [params]);
const { data: study } = api.studies.get.useQuery(
{ id: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id },
);
const { data: activeConsentForm, refetch: refetchConsentForm } =
api.studies.getActiveConsentForm.useQuery(
{ studyId: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id },
);
// Only sync once when form loads to avoid resetting user edits
useEffect(() => {
if (activeConsentForm && !editorTarget) {
setEditorTarget(activeConsentForm.content);
}
}, [activeConsentForm, editorTarget]);
const editor = useEditor({
extensions: [
StarterKit,
Table.configure({
resizable: true,
}),
TableRow,
TableHeader,
TableCell,
Markdown.configure({
transformPastedText: true,
}),
],
content: editorTarget || '',
immediatelyRender: false,
onUpdate: ({ editor }) => {
// @ts-ignore
setEditorTarget(editor.storage.markdown.getMarkdown());
},
});
// Sync Tiptap when editorTarget is set (e.g., from DB) but make sure not to overwrite active edits
useEffect(() => {
if (editor && editorTarget && editor.isEmpty) {
editor.commands.setContent(editorTarget);
}
}, [editorTarget, editor]);
const generateConsentMutation = api.studies.generateConsentForm.useMutation({
onSuccess: (data) => {
toast.success("Default Consent Form Generated!");
setEditorTarget(data.content);
editor?.commands.setContent(data.content);
void refetchConsentForm();
void utils.studies.getActivity.invalidate({ studyId: resolvedParams?.id ?? "" });
},
onError: (error) => {
toast.error("Error generating consent form", { description: error.message });
},
});
const updateConsentMutation = api.studies.updateConsentForm.useMutation({
onSuccess: () => {
toast.success("Consent Form Saved Successfully!");
void refetchConsentForm();
void utils.studies.getActivity.invalidate({ studyId: resolvedParams?.id ?? "" });
},
onError: (error) => {
toast.error("Error saving consent form", { description: error.message });
},
});
const handleDownloadConsent = async () => {
if (!activeConsentForm || !study || !editor) return;
try {
toast.loading("Generating Document...", { id: "pdf-gen" });
await downloadPdfFromHtml(editor.getHTML(), {
filename: `Consent_Form_${study.name.replace(/\s+/g, "_")}_v${activeConsentForm.version}.pdf`
});
toast.success("Document Downloaded Successfully!", { id: "pdf-gen" });
} catch (error) {
toast.error("Error generating PDF", { id: "pdf-gen" });
console.error(error);
}
}; };
void resolveParams();
}, [params]);
useBreadcrumbsEffect([ const { data: study } = api.studies.get.useQuery(
{ label: "Dashboard", href: "/dashboard" }, { id: resolvedParams?.id ?? "" },
{ label: "Studies", href: "/studies" }, { enabled: !!resolvedParams?.id },
{ label: study?.name ?? "Study", href: `/studies/${resolvedParams?.id}` }, );
{ label: "Forms" },
]);
if (!session?.user) { const { data: formsData, isLoading } = api.forms.list.useQuery(
return notFound(); { studyId: resolvedParams?.id ?? "", search: search || undefined },
} { enabled: !!resolvedParams?.id },
);
if (!study) return <div>Loading...</div>; const userRole = (study as any)?.userRole;
const canManage = userRole === "owner" || userRole === "researcher";
return ( const deleteMutation = api.forms.delete.useMutation({
<EntityView> onSuccess: () => {
<PageHeader toast.success("Form deleted successfully");
title="Study Forms" void utils.forms.list.invalidate({ studyId: resolvedParams?.id });
description="Manage consent forms and future questionnaires for this study" },
icon={FileText} onError: (error) => {
/> toast.error("Failed to delete form", { description: error.message });
},
});
<div className="grid grid-cols-1 gap-8"> const setActiveMutation = api.forms.setActive.useMutation({
<EntityViewSection onSuccess: () => {
title="Consent Document" toast.success("Form set as active");
icon="FileText" void utils.forms.list.invalidate({ studyId: resolvedParams?.id });
description="Design and manage the consent form that participants must sign before participating in your trials." },
actions={ onError: (error) => {
<div className="flex gap-2"> toast.error("Failed to set active", { description: error.message });
<Button },
variant="outline" });
size="sm"
onClick={() => generateConsentMutation.mutate({ studyId: study.id })}
disabled={generateConsentMutation.isPending || updateConsentMutation.isPending}
>
{generateConsentMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
Generate Default Template
</Button>
{activeConsentForm && (
<Button
size="sm"
onClick={() => updateConsentMutation.mutate({ studyId: study.id, content: editorTarget })}
disabled={updateConsentMutation.isPending || editorTarget === activeConsentForm.content}
>
{updateConsentMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Changes
</Button>
)}
</div>
}
>
{activeConsentForm ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium leading-none">
{activeConsentForm.title}
</p>
<p className="text-sm text-muted-foreground">
v{activeConsentForm.version} Status: Active
</p>
</div>
<div className="flex items-center gap-3">
<Button
size="sm"
variant="ghost"
onClick={handleDownloadConsent}
>
<Download className="mr-2 h-4 w-4" />
Download PDF
</Button>
<Badge variant="outline" className="bg-green-50 text-green-700 hover:bg-green-50">Active</Badge>
</div>
</div>
<div className="w-full flex justify-center bg-muted/30 p-8 rounded-md border border-border overflow-hidden"> useBreadcrumbsEffect([
<div className="max-w-4xl w-full bg-white dark:bg-card shadow-xl ring-1 ring-border rounded-sm flex flex-col"> { label: "Dashboard", href: "/dashboard" },
<div className="border-b border-border bg-muted/50 dark:bg-muted/10"> { label: "Studies", href: "/studies" },
<Toolbar editor={editor} /> { label: study?.name ?? "Study", href: `/studies/${resolvedParams?.id}` },
</div> { label: "Forms" },
<div className="min-h-[850px] px-16 py-20 text-sm editor-container bg-white dark:bg-card"> ]);
<EditorContent editor={editor} className="prose prose-sm dark:prose-invert max-w-none h-full outline-none focus:outline-none focus-visible:outline-none" />
</div> if (!session?.user) {
</div> return notFound();
</div> }
</div>
) : ( if (!study) return <div>Loading...</div>;
<EmptyState
icon="FileText" const forms = formsData?.forms ?? [];
title="No Consent Form"
description="Generate a boilerplate consent form for this study to download and collect signatures." return (
/> <div className="space-y-6">
)} <PageHeader
</EntityViewSection> title="Forms"
description="Manage consent forms, surveys, and questionnaires for this study"
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>
)
}
/>
{forms.length === 0 && !isLoading ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<FileText className="text-muted-foreground mb-4 h-12 w-12" />
<h3 className="mb-2 text-lg font-semibold">No Forms Yet</h3>
<p className="text-muted-foreground mb-4">
Create consent forms, surveys, or questionnaires to collect data
from participants
</p>
{canManage && (
<Button asChild>
<Link href={`/studies/${resolvedParams?.id}/forms/new`}>
<Plus className="mr-2 h-4 w-4" />
Create Your First Form
</Link>
</Button>
)}
</div>
) : (
<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>
</EntityView> </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

@@ -18,8 +18,9 @@ import {
} from "~/components/ui/entity-view"; } from "~/components/ui/entity-view";
import { PageHeader } from "~/components/ui/page-header"; import { PageHeader } from "~/components/ui/page-header";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider"; import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { useSession } from "next-auth/react"; 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 ? (
@@ -273,12 +285,13 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
</Link> </Link>
</h4> </h4>
<span <span
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${experiment.status === "draft" className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
? "bg-gray-100 text-gray-800" experiment.status === "draft"
: experiment.status === "ready" ? "bg-gray-100 text-gray-800"
? "bg-green-100 text-green-800" : experiment.status === "ready"
: "bg-blue-100 text-blue-800" ? "bg-green-100 text-green-800"
}`} : "bg-blue-100 text-blue-800"
}`}
> >
{experiment.status} {experiment.status}
</span> </span>
@@ -390,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

@@ -13,7 +13,7 @@ import { WizardView } from "~/components/trials/views/WizardView";
import { ObserverView } from "~/components/trials/views/ObserverView"; import { ObserverView } from "~/components/trials/views/ObserverView";
import { ParticipantView } from "~/components/trials/views/ParticipantView"; import { ParticipantView } from "~/components/trials/views/ParticipantView";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { useSession } from "next-auth/react"; import { useSession } from "~/lib/auth-client";
function WizardPageContent() { function WizardPageContent() {
const params = useParams(); const params = useParams();
@@ -25,6 +25,11 @@ function WizardPageContent() {
const { study } = useSelectedStudyDetails(); const { study } = useSelectedStudyDetails();
const { data: session } = useSession(); const { data: session } = useSession();
// Get user roles
const { data: userData } = api.auth.me.useQuery(undefined, {
enabled: !!session?.user,
});
// Get trial data // Get trial data
const { const {
data: trial, data: trial,
@@ -67,7 +72,7 @@ function WizardPageContent() {
} }
// Default role logic based on user // Default role logic based on user
const userRole = session.user.roles?.[0]?.role ?? "observer"; const userRole = userData?.roles?.[0] ?? "observer";
if (userRole === "administrator" || userRole === "researcher") { if (userRole === "administrator" || userRole === "researcher") {
return "wizard"; return "wizard";
} }
@@ -188,6 +193,7 @@ function WizardPageContent() {
name: trial.experiment.name, name: trial.experiment.name,
description: trial.experiment.description, description: trial.experiment.description,
studyId: trial.experiment.studyId, studyId: trial.experiment.studyId,
robotId: trial.experiment.robotId,
}, },
participant: { participant: {
id: trial.participant.id, id: trial.participant.id,

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,4 @@
import { auth } from "~/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth);

View File

@@ -1,3 +0,0 @@
import { handlers } from "~/server/auth";
export const { GET, POST } = handlers;

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

@@ -1,5 +1,6 @@
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { NextResponse, type NextRequest } from "next/server"; import { NextResponse, type NextRequest } from "next/server";
import { headers } from "next/headers";
import { z } from "zod"; import { z } from "zod";
import { import {
generateFileKey, generateFileKey,
@@ -7,9 +8,14 @@ import {
uploadFile, uploadFile,
validateFile, validateFile,
} from "~/lib/storage/minio"; } from "~/lib/storage/minio";
import { auth } from "~/server/auth"; import { auth } from "~/lib/auth";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { experiments, mediaCaptures, studyMembers, trials } from "~/server/db/schema"; import {
experiments,
mediaCaptures,
studyMembers,
trials,
} from "~/server/db/schema";
const uploadSchema = z.object({ const uploadSchema = z.object({
trialId: z.string().optional(), trialId: z.string().optional(),
@@ -23,7 +29,9 @@ const uploadSchema = z.object({
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
// Check authentication // Check authentication
const session = await auth(); const session = await auth.api.getSession({
headers: await headers(),
});
if (!session?.user) { if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
@@ -91,15 +99,15 @@ export async function POST(request: NextRequest) {
.where( .where(
and( and(
eq(studyMembers.studyId, trial[0].studyId), eq(studyMembers.studyId, trial[0].studyId),
eq(studyMembers.userId, session.user.id) eq(studyMembers.userId, session.user.id),
) ),
) )
.limit(1); .limit(1);
if (!membership.length) { if (!membership.length) {
return NextResponse.json( return NextResponse.json(
{ error: "Insufficient permissions to upload to this trial" }, { error: "Insufficient permissions to upload to this trial" },
{ status: 403 } { status: 403 },
); );
} }
} }
@@ -176,7 +184,9 @@ export async function POST(request: NextRequest) {
// Generate presigned upload URL for direct client uploads // Generate presigned upload URL for direct client uploads
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const session = await auth(); const session = await auth.api.getSession({
headers: await headers(),
});
if (!session?.user) { if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }

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

@@ -1,6 +1,6 @@
"use client"; "use client";
import { signIn } from "next-auth/react"; import { signIn } from "~/lib/auth-client";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
@@ -37,22 +37,21 @@ export default function SignInPage() {
} }
try { try {
const result = await signIn("credentials", { const result = await signIn.email({
email, email,
password, password,
redirect: false,
}); });
if (result?.error) { if (result.error) {
setError("Invalid email or password"); setError(result.error.message || "Invalid email or password");
} else { } else {
router.push("/"); router.push("/");
router.refresh(); router.refresh();
} }
} catch (error: unknown) { } catch (err: unknown) {
setError( setError(
error instanceof Error err instanceof Error
? error.message ? err.message
: "An error occurred. Please try again.", : "An error occurred. Please try again.",
); );
} finally { } finally {

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { signOut, useSession } from "next-auth/react"; import { signOut, useSession } from "~/lib/auth-client";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -14,33 +14,29 @@ import {
} from "~/components/ui/card"; } from "~/components/ui/card";
export default function SignOutPage() { export default function SignOutPage() {
const { data: session, status } = useSession(); const { data: session, isPending } = useSession();
const router = useRouter(); const router = useRouter();
const [isSigningOut, setIsSigningOut] = useState(false); const [isSigningOut, setIsSigningOut] = useState(false);
useEffect(() => { useEffect(() => {
// If user is not logged in, redirect to home if (!isPending && !session) {
if (status === "loading") return; // Still loading
if (!session) {
router.push("/"); router.push("/");
return;
} }
}, [session, status, router]); }, [session, isPending, router]);
const handleSignOut = async () => { const handleSignOut = async () => {
setIsSigningOut(true); setIsSigningOut(true);
try { try {
await signOut({ await signOut();
callbackUrl: "/", router.push("/");
redirect: true, router.refresh();
});
} catch (error) { } catch (error) {
console.error("Error signing out:", error); console.error("Error signing out:", error);
setIsSigningOut(false); setIsSigningOut(false);
} }
}; };
if (status === "loading") { if (isPending) {
return ( return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100"> <div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100">
<div className="text-center"> <div className="text-center">
@@ -52,7 +48,7 @@ export default function SignOutPage() {
} }
if (!session) { if (!session) {
return null; // Will redirect via useEffect return null;
} }
return ( return (
@@ -80,7 +76,7 @@ export default function SignOutPage() {
<div className="rounded-md bg-blue-50 p-3 text-sm text-blue-700"> <div className="rounded-md bg-blue-50 p-3 text-sm text-blue-700">
<p className="font-medium"> <p className="font-medium">
Currently signed in as:{" "} Currently signed in as:{" "}
{session.user.name ?? session.user.email} {session.user?.name ?? session.user?.email}
</p> </p>
</div> </div>
@@ -103,7 +99,8 @@ export default function SignOutPage() {
{/* Footer */} {/* Footer */}
<div className="mt-8 text-center text-xs text-slate-500"> <div className="mt-8 text-center text-xs text-slate-500">
<p> <p>
© 2024 HRIStudio. A platform for Human-Robot Interaction research. © {new Date().getFullYear()} HRIStudio. A platform for Human-Robot
Interaction research.
</p> </p>
</div> </div>
</div> </div>

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 "next-auth/react";
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

@@ -3,7 +3,6 @@ import "~/styles/globals.css";
import { type Metadata } from "next"; import { type Metadata } from "next";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
import { SessionProvider } from "next-auth/react";
import { TRPCReactProvider } from "~/trpc/react"; import { TRPCReactProvider } from "~/trpc/react";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -24,9 +23,7 @@ export default function RootLayout({
return ( return (
<html lang="en" className={`${inter.variable}`}> <html lang="en" className={`${inter.variable}`}>
<body> <body>
<SessionProvider> <TRPCReactProvider>{children}</TRPCReactProvider>
<TRPCReactProvider>{children}</TRPCReactProvider>
</SessionProvider>
</body> </body>
</html> </html>
); );

View File

@@ -1,26 +1,53 @@
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 { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Logo } from "~/components/ui/logo"; import { Logo } from "~/components/ui/logo";
import { auth } from "~/server/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(); const session = await auth.api.getSession({
headers: await headers(),
});
if (session?.user) { if (session?.user) {
redirect("/dashboard"); redirect("/dashboard");
@@ -37,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>
@@ -52,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">
@@ -62,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>
@@ -99,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">
@@ -229,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>
@@ -240,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>
@@ -313,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>
@@ -337,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

@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { headers } from "next/headers";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import {
Card, Card,
@@ -7,10 +8,12 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "~/components/ui/card"; } from "~/components/ui/card";
import { auth } from "~/server/auth"; import { auth } from "~/lib/auth";
export default async function UnauthorizedPage() { export default async function UnauthorizedPage() {
const session = await auth(); const session = await auth.api.getSession({
headers: await headers(),
});
return ( return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 px-4"> <div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 px-4">
@@ -60,13 +63,6 @@ export default async function UnauthorizedPage() {
<div className="rounded-md bg-blue-50 p-3 text-sm text-blue-700"> <div className="rounded-md bg-blue-50 p-3 text-sm text-blue-700">
<p className="font-medium">Current User:</p> <p className="font-medium">Current User:</p>
<p>{session.user.name ?? session.user.email}</p> <p>{session.user.name ?? session.user.email}</p>
{session.user.roles && session.user.roles.length > 0 ? (
<p className="mt-1">
Roles: {session.user.roles.map((r) => r.role).join(", ")}
</p>
) : (
<p className="mt-1">No roles assigned</p>
)}
</div> </div>
)} )}

View File

@@ -3,7 +3,7 @@
import React, { useEffect, useRef } from "react"; import React, { useEffect, useRef } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { signOut, useSession } from "next-auth/react"; import { signOut, useSession } from "~/lib/auth-client";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
BarChart3, BarChart3,
@@ -197,13 +197,14 @@ export function AppSidebar({
// Build study work items with proper URLs when study is selected // Build study work items with proper URLs when study is selected
const studyWorkItemsWithUrls = selectedStudyId const studyWorkItemsWithUrls = selectedStudyId
? studyWorkItems.map((item) => ({ ? studyWorkItems.map((item) => ({
...item, ...item,
url: `/studies/${selectedStudyId}${item.url}`, url: `/studies/${selectedStudyId}${item.url}`,
})) }))
: []; : [];
const handleSignOut = async () => { const handleSignOut = async () => {
await signOut({ callbackUrl: "/" }); await signOut();
window.location.href = "/";
}; };
const handleStudySelect = async (studyId: string) => { const handleStudySelect = async (studyId: string) => {

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

@@ -4,12 +4,12 @@ import { useRef, useState } from "react";
import SignatureCanvas from "react-signature-canvas"; import SignatureCanvas from "react-signature-canvas";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "~/components/ui/dialog"; } from "~/components/ui/dialog";
import { PenBox, Eraser, Loader2, CheckCircle } from "lucide-react"; import { PenBox, Eraser, Loader2, CheckCircle } from "lucide-react";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
@@ -25,211 +25,250 @@ import TableHeader from "@tiptap/extension-table-header";
import { ScrollArea } from "~/components/ui/scroll-area"; import { ScrollArea } from "~/components/ui/scroll-area";
interface DigitalSignatureModalProps { interface DigitalSignatureModalProps {
studyId: string; studyId: string;
participantId: string; participantId: string;
participantName?: string | null; participantName?: string | null;
participantCode: string; participantCode: string;
activeForm: { id: string; content: string; version: number }; activeForm: { id: string; content: string; version: number };
onSuccess: () => void; onSuccess: () => void;
} }
export function DigitalSignatureModal({ export function DigitalSignatureModal({
studyId, studyId,
participantId, participantId,
participantName, participantName,
participantCode, participantCode,
activeForm, activeForm,
onSuccess, onSuccess,
}: DigitalSignatureModalProps) { }: DigitalSignatureModalProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const sigCanvas = useRef<any>(null); const sigCanvas = useRef<any>(null);
// Mutations // Mutations
const getUploadUrlMutation = api.participants.getConsentUploadUrl.useMutation(); const getUploadUrlMutation =
const recordConsentMutation = api.participants.recordConsent.useMutation(); api.participants.getConsentUploadUrl.useMutation();
const recordConsentMutation = api.participants.recordConsent.useMutation();
// Create a preview version of the text // Create a preview version of the text
let previewMd = activeForm.content; let previewMd = activeForm.content;
previewMd = previewMd.replace(/{{PARTICIPANT_NAME}}/g, participantName ?? "_________________"); previewMd = previewMd.replace(
previewMd = previewMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode); /{{PARTICIPANT_NAME}}/g,
const today = new Date().toLocaleDateString(); participantName ?? "_________________",
previewMd = previewMd.replace(/{{DATE}}/g, today); );
previewMd = previewMd.replace(/{{SIGNATURE_IMAGE}}/g, "_[Signature Here]_"); previewMd = previewMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
const today = new Date().toLocaleDateString();
previewMd = previewMd.replace(/{{DATE}}/g, today);
previewMd = previewMd.replace(/{{SIGNATURE_IMAGE}}/g, "_[Signature Here]_");
const previewEditor = useEditor({ const previewEditor = useEditor({
extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown], extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown],
content: previewMd, content: previewMd,
editable: false, editable: false,
immediatelyRender: false, immediatelyRender: false,
}); });
const handleClear = () => { const handleClear = () => {
sigCanvas.current?.clear(); sigCanvas.current?.clear();
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
if (sigCanvas.current?.isEmpty()) { if (sigCanvas.current?.isEmpty()) {
toast.error("Signature required", { description: "Please sign the document before submitting." }); toast.error("Signature required", {
return; description: "Please sign the document before submitting.",
} });
return;
}
try { try {
setIsSubmitting(true); setIsSubmitting(true);
toast.loading("Generating Signed Document...", { id: "sig-upload" }); toast.loading("Generating Signed Document...", { id: "sig-upload" });
// 1. Get Signature Image Data URL // 1. Get Signature Image Data URL
const signatureDataUrl = sigCanvas.current.getTrimmedCanvas().toDataURL("image/png"); const signatureDataUrl = sigCanvas.current
.getTrimmedCanvas()
.toDataURL("image/png");
// 2. Prepare final Markdown and HTML // 2. Prepare final Markdown and HTML
let finalMd = activeForm.content; let finalMd = activeForm.content;
finalMd = finalMd.replace(/{{PARTICIPANT_NAME}}/g, participantName ?? "_________________"); finalMd = finalMd.replace(
finalMd = finalMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode); /{{PARTICIPANT_NAME}}/g,
finalMd = finalMd.replace(/{{DATE}}/g, today); participantName ?? "_________________",
finalMd = finalMd.replace(/{{SIGNATURE_IMAGE}}/g, `<img src="${signatureDataUrl}" style="height: 60px; max-width: 250px;" />`); );
finalMd = finalMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
finalMd = finalMd.replace(/{{DATE}}/g, today);
finalMd = finalMd.replace(
/{{SIGNATURE_IMAGE}}/g,
`<img src="${signatureDataUrl}" style="height: 60px; max-width: 250px;" />`,
);
const headlessEditor = new Editor({ const headlessEditor = new Editor({
extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown], extensions: [
content: finalMd, StarterKit,
}); Table,
const htmlContent = headlessEditor.getHTML(); TableRow,
headlessEditor.destroy(); TableHeader,
TableCell,
Markdown,
],
content: finalMd,
});
const htmlContent = headlessEditor.getHTML();
headlessEditor.destroy();
// 3. Generate PDF Blob // 3. Generate PDF Blob
const filename = `Signed_Consent_${participantCode}_v${activeForm.version}.pdf`; const filename = `Signed_Consent_${participantCode}_v${activeForm.version}.pdf`;
const pdfBlob = await generatePdfBlobFromHtml(htmlContent, { filename }); const pdfBlob = await generatePdfBlobFromHtml(htmlContent, { filename });
const file = new File([pdfBlob], filename, { type: "application/pdf" }); const file = new File([pdfBlob], filename, { type: "application/pdf" });
// 4. Get Presigned URL // 4. Get Presigned URL
toast.loading("Uploading Document...", { id: "sig-upload" }); toast.loading("Uploading Document...", { id: "sig-upload" });
const { url, key } = await getUploadUrlMutation.mutateAsync({ const { url, key } = await getUploadUrlMutation.mutateAsync({
studyId, studyId,
participantId, participantId,
filename: file.name, filename: file.name,
contentType: file.type, contentType: file.type,
size: file.size, size: file.size,
}); });
// 5. Upload to MinIO // 5. Upload to MinIO
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open("PUT", url, true); xhr.open("PUT", url, true);
xhr.setRequestHeader("Content-Type", file.type); xhr.setRequestHeader("Content-Type", file.type);
xhr.onload = () => { xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve(); if (xhr.status >= 200 && xhr.status < 300) resolve();
else reject(new Error(`Upload failed with status ${xhr.status}`)); else reject(new Error(`Upload failed with status ${xhr.status}`));
}; };
xhr.onerror = () => reject(new Error("Network error during upload")); xhr.onerror = () => reject(new Error("Network error during upload"));
xhr.send(file); xhr.send(file);
}); });
// 6. Record Consent in DB // 6. Record Consent in DB
toast.loading("Finalizing Consent...", { id: "sig-upload" }); toast.loading("Finalizing Consent...", { id: "sig-upload" });
await recordConsentMutation.mutateAsync({ await recordConsentMutation.mutateAsync({
participantId, participantId,
consentFormId: activeForm.id, consentFormId: activeForm.id,
storagePath: key, storagePath: key,
}); });
toast.success("Consent Successfully Recorded!", { id: "sig-upload" }); toast.success("Consent Successfully Recorded!", { id: "sig-upload" });
setIsOpen(false); setIsOpen(false);
onSuccess(); onSuccess();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
toast.error("Failed to submit digital signature", { toast.error("Failed to submit digital signature", {
id: "sig-upload", id: "sig-upload",
description: error instanceof Error ? error.message : "Unknown error", description: error instanceof Error ? error.message : "Unknown error",
}); });
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="default" size="sm" className="bg-primary/90 hover:bg-primary"> <Button
<PenBox className="mr-2 h-4 w-4" /> variant="default"
Sign Digitally size="sm"
</Button> className="bg-primary/90 hover:bg-primary"
</DialogTrigger> >
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-6"> <PenBox className="mr-2 h-4 w-4" />
<DialogHeader> Sign Digitally
<DialogTitle>Digital Consent Signature</DialogTitle> </Button>
<DialogDescription> </DialogTrigger>
Please review the document below and provide your digital signature to consent to this study. <DialogContent className="flex h-[90vh] max-w-4xl flex-col p-6">
</DialogDescription> <DialogHeader>
</DialogHeader> <DialogTitle>Digital Consent Signature</DialogTitle>
<DialogDescription>
Please review the document below and provide your digital signature
to consent to this study.
</DialogDescription>
</DialogHeader>
<div className="flex-1 min-h-0 grid grid-cols-1 md:grid-cols-2 gap-6 mt-4"> <div className="mt-4 grid min-h-0 flex-1 grid-cols-1 gap-6 md:grid-cols-2">
{/* Document Preview (Left) */} {/* Document Preview (Left) */}
<div className="flex flex-col border rounded-md overflow-hidden bg-muted/20"> <div className="bg-muted/20 flex flex-col overflow-hidden rounded-md border">
<div className="bg-muted px-4 py-2 border-b text-xs font-semibold text-muted-foreground uppercase tracking-wider"> <div className="bg-muted text-muted-foreground border-b px-4 py-2 text-xs font-semibold tracking-wider uppercase">
Document Preview Document Preview
</div> </div>
<ScrollArea className="flex-1 w-full bg-white p-6 shadow-inner"> <ScrollArea className="w-full flex-1 bg-white p-6 shadow-inner">
<div className="prose prose-sm max-w-none text-black"> <div className="prose prose-sm max-w-none text-black">
<EditorContent editor={previewEditor} /> <EditorContent editor={previewEditor} />
</div> </div>
</ScrollArea> </ScrollArea>
</div> </div>
{/* Signature Panel (Right) */} {/* Signature Panel (Right) */}
<div className="flex flex-col space-y-4"> <div className="flex flex-col space-y-4">
<div className="border rounded-md overflow-hidden bg-white shadow-sm flex flex-col"> <div className="flex flex-col overflow-hidden rounded-md border bg-white shadow-sm">
<div className="bg-muted px-4 py-2 border-b text-xs font-semibold text-muted-foreground uppercase tracking-wider"> <div className="bg-muted text-muted-foreground border-b px-4 py-2 text-xs font-semibold tracking-wider uppercase">
Digital Signature Pad Digital Signature Pad
</div> </div>
<div className="p-4 bg-muted/10 relative"> <div className="bg-muted/10 relative p-4">
<div className="absolute top-4 right-4"> <div className="absolute top-4 right-4">
<Button variant="ghost" size="sm" onClick={handleClear} disabled={isSubmitting}> <Button
<Eraser className="h-4 w-4 mr-2" /> variant="ghost"
Clear size="sm"
</Button> onClick={handleClear}
</div> disabled={isSubmitting}
<div className="border-2 border-dashed border-input rounded-md bg-white mt-10" style={{ height: "250px" }}> >
<SignatureCanvas <Eraser className="mr-2 h-4 w-4" />
ref={sigCanvas} Clear
penColor="black" </Button>
canvasProps={{ className: "w-full h-full cursor-crosshair rounded-md" }}
/>
</div>
<p className="text-center text-xs text-muted-foreground mt-2">
Draw your signature using your mouse or touch screen inside the box above.
</p>
</div>
</div>
<div className="flex-1" />
{/* Submission Actions */}
<div className="flex flex-col space-y-3 p-4 bg-primary/5 rounded-lg border border-primary/20">
<h4 className="flex items-center text-sm font-semibold text-primary">
<CheckCircle className="h-4 w-4 mr-2" />
Agreement
</h4>
<p className="text-xs text-muted-foreground leading-relaxed">
By clicking "Submit Signed Document", you confirm that you have read and understood the information provided in the document preview, and you voluntarily agree to participate in this study.
</p>
<Button
className="w-full mt-2"
size="lg"
onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : (
"Submit Signed Document"
)}
</Button>
</div>
</div>
</div> </div>
</DialogContent> <div
</Dialog> className="border-input mt-10 rounded-md border-2 border-dashed bg-white"
); style={{ height: "250px" }}
>
<SignatureCanvas
ref={sigCanvas}
penColor="black"
canvasProps={{
className: "w-full h-full cursor-crosshair rounded-md",
}}
/>
</div>
<p className="text-muted-foreground mt-2 text-center text-xs">
Draw your signature using your mouse or touch screen inside
the box above.
</p>
</div>
</div>
<div className="flex-1" />
{/* Submission Actions */}
<div className="bg-primary/5 border-primary/20 flex flex-col space-y-3 rounded-lg border p-4">
<h4 className="text-primary flex items-center text-sm font-semibold">
<CheckCircle className="mr-2 h-4 w-4" />
Agreement
</h4>
<p className="text-muted-foreground text-xs leading-relaxed">
By clicking "Submit Signed Document", you confirm that you have
read and understood the information provided in the document
preview, and you voluntarily agree to participate in this study.
</p>
<Button
className="mt-2 w-full"
size="lg"
onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : (
"Submit Signed Document"
)}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
} }

View File

@@ -56,7 +56,10 @@ export function ParticipantConsentManager({
existingConsent, existingConsent,
participantName, participantName,
participantCode, participantCode,
}: ParticipantConsentManagerProps & { participantName?: string | null; participantCode: string }) { }: ParticipantConsentManagerProps & {
participantName?: string | null;
participantCode: string;
}) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils(); const utils = api.useUtils();
@@ -99,14 +102,24 @@ export function ParticipantConsentManager({
// Substitute placeholders in markdown // Substitute placeholders in markdown
let customMd = activeForm.content; let customMd = activeForm.content;
customMd = customMd.replace(/{{PARTICIPANT_NAME}}/g, participantName ?? "_________________"); customMd = customMd.replace(
/{{PARTICIPANT_NAME}}/g,
participantName ?? "_________________",
);
customMd = customMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode); customMd = customMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
customMd = customMd.replace(/{{DATE}}/g, "_________________"); customMd = customMd.replace(/{{DATE}}/g, "_________________");
customMd = customMd.replace(/{{SIGNATURE_IMAGE}}/g, ""); // Blank ready for physical signature customMd = customMd.replace(/{{SIGNATURE_IMAGE}}/g, ""); // Blank ready for physical signature
// Use headless Tiptap to parse MD to HTML via same extensions // Use headless Tiptap to parse MD to HTML via same extensions
const editor = new Editor({ const editor = new Editor({
extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown], extensions: [
StarterKit,
Table,
TableRow,
TableHeader,
TableCell,
Markdown,
],
content: customMd, content: customMd,
}); });
@@ -195,7 +208,11 @@ export function ParticipantConsentManager({
activeForm={activeForm} activeForm={activeForm}
onSuccess={handleSuccess} onSuccess={handleSuccess}
/> />
<Button variant="outline" size="sm" onClick={handleDownloadUnsigned}> <Button
variant="outline"
size="sm"
onClick={handleDownloadUnsigned}
>
<Download className="mr-2 h-4 w-4" /> <Download className="mr-2 h-4 w-4" />
Print Empty Form Print Empty Form
</Button> </Button>

View File

@@ -119,39 +119,39 @@ export function ParticipantForm({
{ label: "Studies", href: "/studies" }, { label: "Studies", href: "/studies" },
...(contextStudyId ...(contextStudyId
? [ ? [
{ {
label: participant?.study?.name ?? "Study", label: participant?.study?.name ?? "Study",
href: `/studies/${contextStudyId}`, href: `/studies/${contextStudyId}`,
}, },
{ {
label: "Participants", label: "Participants",
href: `/studies/${contextStudyId}/participants`, href: `/studies/${contextStudyId}/participants`,
}, },
...(mode === "edit" && participant ...(mode === "edit" && participant
? [ ? [
{ {
label: participant.name ?? participant.participantCode, label: participant.name ?? participant.participantCode,
href: `/studies/${contextStudyId}/participants/${participant.id}`, href: `/studies/${contextStudyId}/participants/${participant.id}`,
}, },
{ label: "Edit" }, { label: "Edit" },
] ]
: [{ label: "New Participant" }]), : [{ label: "New Participant" }]),
] ]
: [ : [
{ {
label: "Participants", label: "Participants",
href: `/studies/${contextStudyId}/participants`, href: `/studies/${contextStudyId}/participants`,
}, },
...(mode === "edit" && participant ...(mode === "edit" && participant
? [ ? [
{ {
label: participant.name ?? participant.participantCode, label: participant.name ?? participant.participantCode,
href: `/studies/${contextStudyId}/participants/${participant.id}`, href: `/studies/${contextStudyId}/participants/${participant.id}`,
}, },
{ label: "Edit" }, { label: "Edit" },
] ]
: [{ label: "New Participant" }]), : [{ label: "New Participant" }]),
]), ]),
]; ];
useBreadcrumbsEffect(breadcrumbs); useBreadcrumbsEffect(breadcrumbs);
@@ -291,7 +291,7 @@ export function ParticipantForm({
readOnly={true} readOnly={true}
className={cn( className={cn(
"bg-muted text-muted-foreground", "bg-muted text-muted-foreground",
form.formState.errors.participantCode ? "border-red-500" : "" form.formState.errors.participantCode ? "border-red-500" : "",
)} )}
/> />
{form.formState.errors.participantCode && ( {form.formState.errors.participantCode && (
@@ -338,7 +338,11 @@ export function ParticipantForm({
<FormSection <FormSection
title={contextStudyId ? "Demographics" : "Demographics & Study"} title={contextStudyId ? "Demographics" : "Demographics & Study"}
description={contextStudyId ? "Participant demographic details." : "Study association and demographic details."} description={
contextStudyId
? "Participant demographic details."
: "Study association and demographic details."
}
> >
<div className="grid grid-cols-1 gap-6 md:grid-cols-3"> <div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{!contextStudyId && ( {!contextStudyId && (
@@ -358,7 +362,9 @@ export function ParticipantForm({
} }
> >
<SelectValue <SelectValue
placeholder={studiesLoading ? "Loading..." : "Select study"} placeholder={
studiesLoading ? "Loading..." : "Select study"
}
/> />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -404,11 +410,11 @@ export function ParticipantForm({
form.setValue( form.setValue(
"gender", "gender",
value as value as
| "male" | "male"
| "female" | "female"
| "non_binary" | "non_binary"
| "prefer_not_to_say" | "prefer_not_to_say"
| "other", | "other",
) )
} }
> >

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.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,7 +205,34 @@ export const WizardInterface = React.memo(function WizardInterface({
}, },
}); });
const executeSystemActionMutation = api.robots.executeSystemAction.useMutation(); const executeSystemAction = async (
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
@@ -234,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,
});
}, },
}); });
@@ -251,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(() => {
@@ -312,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;
@@ -321,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;
@@ -363,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(
@@ -541,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");
@@ -578,11 +626,20 @@ export const WizardInterface = React.memo(function WizardInterface({
}; };
const handleNextStep = (targetIndex?: number) => { const handleNextStep = (targetIndex?: number) => {
console.log(
`[DEBUG] handleNextStep called: targetIndex=${targetIndex}, currentStepIndex=${currentStepIndex}`,
);
console.log(
`[DEBUG] Steps: ${steps.map((s, i) => `${i}:${s.name}`).join(" | ")}`,
);
// If explicit target provided (from branching choice), use it // If explicit target provided (from branching choice), use it
if (typeof targetIndex === "number") { if (typeof targetIndex === "number") {
// Find step by index to ensure safety // Find step by index to ensure safety
if (targetIndex >= 0 && targetIndex < steps.length) { if (targetIndex >= 0 && targetIndex < steps.length) {
console.log(`[WizardInterface] Manual jump to step ${targetIndex}`); console.log(
`[WizardInterface] Manual jump to step ${targetIndex} (${steps[targetIndex]?.name})`,
);
// Log manual jump // Log manual jump
logEventMutation.mutate({ logEventMutation.mutate({
@@ -600,7 +657,18 @@ export const WizardInterface = React.memo(function WizardInterface({
setCompletedActionsCount(0); setCompletedActionsCount(0);
setCurrentStepIndex(targetIndex); setCurrentStepIndex(targetIndex);
setLastResponse(null); setLastResponse(null);
// Mark source step as completed so it won't be revisited
setCompletedSteps((prev) => {
const next = new Set(prev);
next.add(currentStepIndex);
return next;
});
return; return;
} else {
console.warn(
`[DEBUG] Invalid targetIndex: ${targetIndex}, steps.length=${steps.length}`,
);
} }
} }
@@ -613,33 +681,51 @@ export const WizardInterface = React.memo(function WizardInterface({
currentStep.conditions?.options && currentStep.conditions?.options &&
lastResponse lastResponse
) { ) {
const matchedOption = currentStep.conditions.options.find( // Handle both string options and object options
(opt) => opt.value === lastResponse, const matchedOption = currentStep.conditions.options.find((opt) => {
); // If opt is a string, compare directly with lastResponse
if (matchedOption && matchedOption.nextStepId) { if (typeof opt === "string") {
// Find index of the target step return opt === lastResponse;
const targetIndex = steps.findIndex( }
(s) => s.id === matchedOption.nextStepId, // If opt is an object, check .value property
); return opt.value === lastResponse;
if (targetIndex !== -1) { });
console.log(
`[WizardInterface] Branching to step ${targetIndex} (${matchedOption.label})`,
);
logEventMutation.mutate({ if (matchedOption) {
trialId: trial.id, // Handle both string options and object options for nextStepId
type: "step_branched", const nextStepId =
data: { typeof matchedOption === "string"
fromIndex: currentStepIndex, ? null // String options don't have nextStepId
toIndex: targetIndex, : matchedOption.nextStepId;
condition: matchedOption.label,
value: lastResponse,
},
});
setCurrentStepIndex(targetIndex); if (nextStepId) {
setLastResponse(null); // Reset after consuming // Find index of the target step
return; const targetIndex = steps.findIndex((s) => s.id === nextStepId);
if (targetIndex !== -1) {
const label =
typeof matchedOption === "string"
? matchedOption
: matchedOption.label;
console.log(
`[WizardInterface] Branching to step ${targetIndex} (${label})`,
);
logEventMutation.mutate({
trialId: trial.id,
type: "step_branched",
data: {
fromIndex: currentStepIndex,
toIndex: targetIndex,
condition: label,
value: lastResponse,
},
});
setCurrentStepIndex(targetIndex);
setLastResponse(null); // Reset after consuming
return;
}
} }
} }
} }
@@ -837,13 +923,25 @@ export const WizardInterface = React.memo(function WizardInterface({
if (parameters.nextStepId) { if (parameters.nextStepId) {
const nextId = String(parameters.nextStepId); const nextId = String(parameters.nextStepId);
const targetIndex = steps.findIndex((s) => s.id === nextId); const targetIndex = steps.findIndex((s) => s.id === nextId);
console.log(
`[DEBUG] Branch choice: value=${parameters.value}, label=${parameters.label}`,
);
console.log(`[DEBUG] Target step ID: ${nextId}`);
console.log(`[DEBUG] Target index in steps array: ${targetIndex}`);
console.log(
`[DEBUG] Available step IDs: ${steps.map((s) => s.id).join(", ")}`,
);
if (targetIndex !== -1) { if (targetIndex !== -1) {
console.log( console.log(
`[WizardInterface] Choice-based jump to step ${targetIndex} (${nextId})`, `[WizardInterface] Choice-based jump to step ${targetIndex} (${nextId})`,
); );
handleNextStep(targetIndex); handleNextStep(targetIndex);
return; // Exit after jump return; // Exit after jump
} else {
console.warn(`[DEBUG] Target step not found! nextStepId=${nextId}`);
} }
} else {
console.warn(`[DEBUG] No nextStepId in parameters!`, parameters);
} }
} }

View File

@@ -188,7 +188,7 @@ export function TrialControlPanel({
Pause Pause
</Button> </Button>
<Button <Button
onClick={onNextStep} onClick={() => onNextStep()}
disabled={currentStepIndex >= steps.length - 1} disabled={currentStepIndex >= steps.length - 1}
size="sm" size="sm"
> >

View File

@@ -535,8 +535,10 @@ export function WizardActionItem({
className="hover:border-primary hover:bg-primary/5 h-auto justify-start px-4 py-3 text-left" className="hover:border-primary hover:bg-primary/5 h-auto justify-start px-4 py-3 text-left"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
console.log(`[DEBUG WizardActionItem] Choice clicked: actionId=${action.id}, value=${value}, label=${label}, nextStepId=${nextStepId}`);
onExecute(action.id, { value, label, nextStepId }); onExecute(action.id, { value, label, nextStepId });
onCompleted(); // Don't call onCompleted() here - the branching logic in handleWizardResponse
// will handle the jump and reset completedActionsCount
}} }}
disabled={readOnly || isExecuting} disabled={readOnly || isExecuting}
> >

View File

@@ -403,8 +403,8 @@ export function WizardExecutionPanel({
size="lg" size="lg"
onClick={ onClick={
currentStepIndex === steps.length - 1 currentStepIndex === steps.length - 1
? onCompleteTrial ? (onCompleteTrial ?? (() => {}))
: onNextStep : () => onNextStep?.()
} }
className={`w-full max-w-sm text-white shadow-lg transition-all hover:scale-[1.02] ${ className={`w-full max-w-sm text-white shadow-lg transition-all hover:scale-[1.02] ${
currentStepIndex === steps.length - 1 currentStepIndex === steps.length - 1

View File

@@ -1,8 +1,6 @@
"use client"; "use client";
/* eslint-disable react-hooks/exhaustive-deps */ import { useSession } from "~/lib/auth-client";
import { useSession } from "next-auth/react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
export type TrialStatus = export type TrialStatus =
@@ -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

@@ -345,7 +345,8 @@ export function useWizardRos(
...execution, ...execution,
status: "failed", status: "failed",
endTime: new Date(), endTime: new Date(),
error: error instanceof Error ? error.message : "System action failed", error:
error instanceof Error ? error.message : "System action failed",
}; };
service.emit("action_failed", failedExecution); service.emit("action_failed", failedExecution);
throw error; throw error;

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