Compare commits
6 Commits
original-w
...
hristudio-
| Author | SHA1 | Date | |
|---|---|---|---|
| ab08c1b724 | |||
| 88c305de61 | |||
| 4901729bd9 | |||
| ec4d8db16e | |||
| 6e3f2e1601 | |||
| e6962aef79 |
@@ -1,4 +1,4 @@
|
|||||||
You are an expert in TypeScript, Clerk, Node.js, Drizzle ORM, Next.js App Router, React, Shadcn UI, Radix UI and Tailwind.
|
You are an expert in TypeScript, Auth.js, Node.js, Drizzle ORM, Next.js 15 App Router, React, Shadcn UI, Radix UI and Tailwind.
|
||||||
|
|
||||||
Key Principles
|
Key Principles
|
||||||
- Write concise, technical TypeScript code with accurate examples.
|
- Write concise, technical TypeScript code with accurate examples.
|
||||||
@@ -7,7 +7,7 @@ Key Principles
|
|||||||
- Use descriptive variable names with auxiliary verbs (e.g., isLoading, hasError).
|
- Use descriptive variable names with auxiliary verbs (e.g., isLoading, hasError).
|
||||||
- Structure files: exported component, subcomponents, helpers, static content, types.
|
- Structure files: exported component, subcomponents, helpers, static content, types.
|
||||||
- When working with a database, use Drizzle ORM.
|
- When working with a database, use Drizzle ORM.
|
||||||
- When working with authentication, use Clerk.
|
- When working with authentication, use Auth.js v5.
|
||||||
|
|
||||||
Naming Conventions
|
Naming Conventions
|
||||||
- Use lowercase with dashes for directories (e.g., components/auth-wizard).
|
- Use lowercase with dashes for directories (e.g., components/auth-wizard).
|
||||||
@@ -41,3 +41,8 @@ Key Conventions
|
|||||||
- Use only for Web API access in small components.
|
- Use only for Web API access in small components.
|
||||||
- Avoid for data fetching or state management.
|
- Avoid for data fetching or state management.
|
||||||
|
|
||||||
|
Security Practices
|
||||||
|
- Implement CSRF protection with Auth.js
|
||||||
|
- Use bcrypt for password hashing
|
||||||
|
- Validate all inputs with Zod
|
||||||
|
- Store secrets in environment variables
|
||||||
52
.env.example
@@ -1,20 +1,40 @@
|
|||||||
# Clerk Authentication
|
# Since the ".env" file is gitignored, you can use the ".env.example" file to
|
||||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_
|
# build a new ".env" file when you clone the repo. Keep this file up-to-date
|
||||||
CLERK_SECRET_KEY=sk_test_
|
# when you add new variables to `.env`.
|
||||||
|
|
||||||
# Database
|
# This file will be committed to version control, so make sure not to have any
|
||||||
POSTGRES_URL="postgresql://user:password@localhost:5432/dbname"
|
# secrets in it. If you are cloning this repo, create a copy of this file named
|
||||||
|
# ".env" and populate it with your secrets.
|
||||||
|
|
||||||
# Next.js
|
# When adding additional environment variables, the schema in "/src/env.js"
|
||||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
# should be updated accordingly.
|
||||||
|
|
||||||
# Email (SMTP)
|
# Next Auth
|
||||||
SMTP_HOST=smtp.mail.me.com
|
# You can generate a new secret on the command line with:
|
||||||
SMTP_PORT=587
|
# npx auth secret
|
||||||
SMTP_USER=your-email@example.com
|
# https://next-auth.js.org/configuration/options#secret
|
||||||
SMTP_PASSWORD=your-app-specific-password
|
AUTH_SECRET=""
|
||||||
SMTP_FROM_ADDRESS=noreply@yourdomain.com
|
|
||||||
|
|
||||||
# Optional: For production deployments
|
# Next Auth Discord Provider
|
||||||
# NEXT_PUBLIC_APP_URL="https://yourdomain.com"
|
AUTH_DISCORD_ID=""
|
||||||
# VERCEL_URL="https://yourdomain.com"
|
AUTH_DISCORD_SECRET=""
|
||||||
|
|
||||||
|
# Drizzle
|
||||||
|
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/hristudio"
|
||||||
|
|
||||||
|
# S3/MinIO Configuration
|
||||||
|
S3_ENDPOINT="http://localhost:9000"
|
||||||
|
S3_REGION="us-east-1"
|
||||||
|
S3_ACCESS_KEY="minioadmin"
|
||||||
|
S3_SECRET_KEY="minioadmin"
|
||||||
|
S3_BUCKET_NAME="hristudio"
|
||||||
|
S3_USE_PATH_STYLE_ENDPOINT=true
|
||||||
|
|
||||||
|
# Next Auth Configuration
|
||||||
|
# Generate one using: openssl rand -base64 32
|
||||||
|
NEXTAUTH_SECRET=""
|
||||||
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
|
|
||||||
|
# Add other auth provider secrets here as needed
|
||||||
|
# GITHUB_ID=""
|
||||||
|
# GITHUB_SECRET=""
|
||||||
|
|||||||
61
.eslintrc.cjs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/** @type {import("eslint").Linter.Config} */
|
||||||
|
const config = {
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"parserOptions": {
|
||||||
|
"project": true
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"@typescript-eslint",
|
||||||
|
"drizzle"
|
||||||
|
],
|
||||||
|
"extends": [
|
||||||
|
"next/core-web-vitals",
|
||||||
|
"plugin:@typescript-eslint/recommended-type-checked",
|
||||||
|
"plugin:@typescript-eslint/stylistic-type-checked"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/array-type": "off",
|
||||||
|
"@typescript-eslint/consistent-type-definitions": "off",
|
||||||
|
"@typescript-eslint/consistent-type-imports": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"prefer": "type-imports",
|
||||||
|
"fixStyle": "inline-type-imports"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"argsIgnorePattern": "^_"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@typescript-eslint/require-await": "off",
|
||||||
|
"@typescript-eslint/no-misused-promises": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"checksVoidReturn": {
|
||||||
|
"attributes": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"drizzle/enforce-delete-with-where": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"drizzleObjectName": [
|
||||||
|
"db",
|
||||||
|
"ctx.db"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"drizzle/enforce-update-with-where": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"drizzleObjectName": [
|
||||||
|
"db",
|
||||||
|
"ctx.db"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = config;
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "next/core-web-vitals",
|
|
||||||
"rules": {
|
|
||||||
"@typescript-eslint/no-empty-interface": "off"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BIN
.github/homepage-screenshot.png
vendored
|
Before Width: | Height: | Size: 816 KiB |
25
.vscode/settings.json
vendored
@@ -1,8 +1,21 @@
|
|||||||
{
|
{
|
||||||
"conventionalCommits.scopes": [
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"homepage",
|
"editor.formatOnSave": true,
|
||||||
"repo",
|
"editor.codeActionsOnSave": {
|
||||||
"auth",
|
"source.fixAll.eslint": "explicit"
|
||||||
"perms"
|
},
|
||||||
]
|
"tailwindCSS.experimental.classRegex": [
|
||||||
|
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
|
||||||
|
["cn\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
|
||||||
|
["tw=\"([^\"]*)\""]
|
||||||
|
],
|
||||||
|
"tailwindCSS.includeLanguages": {
|
||||||
|
"typescript": "javascript",
|
||||||
|
"typescriptreact": "javascript"
|
||||||
|
},
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
|
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||||
|
"css.validate": false,
|
||||||
|
"scss.validate": false,
|
||||||
|
"tailwindCSS.validate": true
|
||||||
}
|
}
|
||||||
7
LICENSE
@@ -1,7 +0,0 @@
|
|||||||
Copyright © 2024 Sean O'Connor
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
229
README.md
@@ -1,85 +1,188 @@
|
|||||||
# [HRIStudio](https://www.hristudio.com)
|
# HRIStudio
|
||||||
|
|
||||||
A web platform for managing human-robot interaction studies, participants, and wizard-of-oz experiments.
|
A modern web application for managing human-robot interaction studies, built with Next.js 15, TypeScript, and the App Router.
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Role-based access control with granular permissions
|
|
||||||
- Study management and participant tracking
|
|
||||||
- Wizard-of-oz experiment support
|
|
||||||
- Data collection and analysis tools
|
|
||||||
- Secure authentication with Clerk
|
|
||||||
- Real-time participant management
|
|
||||||
- Study-specific data isolation
|
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- [Next.js](https://nextjs.org/) - React framework with App Router
|
- **Framework**: Next.js 15 with App Router
|
||||||
- [TypeScript](https://www.typescriptlang.org/) - Static type checking
|
- **Language**: TypeScript
|
||||||
- [Clerk](https://clerk.com/) - Authentication and user management
|
- **Authentication**: NextAuth.js
|
||||||
- [Drizzle ORM](https://orm.drizzle.team/) - TypeScript ORM
|
- **Database**: PostgreSQL with Drizzle ORM
|
||||||
- [PostgreSQL](https://www.postgresql.org/) - Database
|
- **UI Components**: Shadcn UI + Radix UI
|
||||||
- [TailwindCSS](https://tailwindcss.com/) - Utility-first CSS
|
- **Styling**: Tailwind CSS
|
||||||
- [Shadcn UI](https://ui.shadcn.com/) - Component library
|
- **API Layer**: tRPC
|
||||||
- [Radix UI](https://www.radix-ui.com/) - Accessible component primitives
|
- **File Storage**: MinIO (S3-compatible)
|
||||||
- [Lucide Icons](https://lucide.dev/) - Icon system
|
|
||||||
|
|
||||||
## Getting Started
|
## Key Principles
|
||||||
|
|
||||||
1. Clone the repository:
|
### TypeScript Usage
|
||||||
```bash
|
- Use TypeScript for all code files
|
||||||
git clone https://github.com/yourusername/hristudio.git
|
- Prefer interfaces over types
|
||||||
```
|
- Avoid enums; use const objects with `as const` instead
|
||||||
|
- Use proper type inference with `zod` schemas
|
||||||
|
|
||||||
2. Install dependencies:
|
### Component Structure
|
||||||
```bash
|
- Use functional components with TypeScript interfaces
|
||||||
pnpm install
|
- Structure files in this order:
|
||||||
```
|
1. Exported component
|
||||||
|
2. Subcomponents
|
||||||
|
3. Helper functions
|
||||||
|
4. Static content
|
||||||
|
5. Types/interfaces
|
||||||
|
|
||||||
3. Set up environment variables:
|
### Naming Conventions
|
||||||
```bash
|
- Use lowercase with dashes for directories (e.g., `components/auth-wizard`)
|
||||||
cp .env.example .env
|
- Use PascalCase for components
|
||||||
```
|
- Use camelCase for functions and variables
|
||||||
|
- Prefix boolean variables with auxiliary verbs (e.g., `isLoading`, `hasError`)
|
||||||
|
|
||||||
4. Set up the database:
|
### Data Management
|
||||||
```bash
|
- Use Drizzle ORM for database operations
|
||||||
pnpm db:push
|
- Split names into `firstName` and `lastName` fields
|
||||||
```
|
- Use tRPC for type-safe API calls
|
||||||
|
- Implement proper error handling and loading states
|
||||||
|
|
||||||
5. Start the development server:
|
### Authentication
|
||||||
```bash
|
- Use NextAuth.js for authentication
|
||||||
pnpm dev
|
- Handle user sessions with JWT strategy
|
||||||
```
|
- Store passwords with bcrypt hashing
|
||||||
|
- Implement proper CSRF protection
|
||||||
6. Open [http://localhost:3000](http://localhost:3000) in your browser
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
|
### File Structure
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── app/ # Next.js app router pages and API routes
|
├── app/ # Next.js App Router pages
|
||||||
├── components/ # React components
|
├── components/
|
||||||
│ ├── ui/ # Shadcn UI components
|
│ ├── ui/ # Reusable UI components
|
||||||
│ └── ... # Feature-specific components
|
│ └── layout/ # Layout components
|
||||||
├── context/ # React context providers
|
├── server/
|
||||||
├── db/ # Database schema and configuration
|
│ ├── api/ # tRPC routers
|
||||||
├── hooks/ # Custom React hooks
|
│ ├── auth/ # Authentication config
|
||||||
├── lib/ # Utility functions and permissions
|
│ └── db/ # Database schema and config
|
||||||
└── types/ # TypeScript type definitions
|
└── lib/ # Utility functions
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
|
|
||||||
|
#### Forms
|
||||||
|
```typescript
|
||||||
|
// Form Schema
|
||||||
|
const formSchema = z.object({
|
||||||
|
firstName: z.string().min(1, "First name is required"),
|
||||||
|
lastName: z.string().min(1, "Last name is required"),
|
||||||
|
email: z.string().email(),
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form Component
|
||||||
|
export function MyForm() {
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
// ...
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Server Components
|
||||||
|
- Use Server Components by default
|
||||||
|
- Add 'use client' only when needed for:
|
||||||
|
- Event listeners
|
||||||
|
- Browser APIs
|
||||||
|
- React hooks
|
||||||
|
- Client-side state
|
||||||
|
|
||||||
|
#### Image Handling
|
||||||
|
```typescript
|
||||||
|
// Image Upload
|
||||||
|
const handleFileUpload = async (file: File) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
const response = await fetch("/api/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Image Display
|
||||||
|
<Image
|
||||||
|
src={imageUrl}
|
||||||
|
alt="Description"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
className="object-cover"
|
||||||
|
priority={isAboveFold}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Database Schema
|
||||||
|
```typescript
|
||||||
|
// User Table
|
||||||
|
export const users = createTable("user", {
|
||||||
|
id: varchar("id", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
|
firstName: varchar("first_name", { length: 255 }),
|
||||||
|
lastName: varchar("last_name", { length: 255 }),
|
||||||
|
email: varchar("email", { length: 255 }).notNull(),
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance Optimization
|
||||||
|
- Use React Server Components where possible
|
||||||
|
- Implement proper image optimization
|
||||||
|
- Use dynamic imports for large client components
|
||||||
|
- Implement proper caching strategies
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Implement proper CSRF protection
|
||||||
|
- Use environment variables for sensitive data
|
||||||
|
- Implement proper input validation
|
||||||
|
- Use proper content security policies
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
- Run `pnpm db:studio` to open the Drizzle Studio database UI
|
```bash
|
||||||
- Use `pnpm lint` to check for code style issues
|
# Install dependencies
|
||||||
- Run `pnpm build` to create a production build
|
pnpm install
|
||||||
|
|
||||||
## License
|
# Set up environment variables
|
||||||
|
cp .env.example .env.local
|
||||||
|
|
||||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
# Start development server
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# Run type checking
|
||||||
|
pnpm type-check
|
||||||
|
|
||||||
|
# Run linting
|
||||||
|
pnpm lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate migration
|
||||||
|
pnpm drizzle-kit generate:pg
|
||||||
|
|
||||||
|
# Push migration
|
||||||
|
pnpm db:push
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
The application is designed to be deployed on any platform that supports Node.js. We recommend using Vercel for the best Next.js deployment experience.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome! Please read our [Contributing Guide](CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests.
|
1. Follow the TypeScript guidelines
|
||||||
|
2. Use the provided component patterns
|
||||||
|
3. Implement proper error handling
|
||||||
|
4. Add appropriate tests
|
||||||
|
5. Follow the commit message convention
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "tailwind.config.ts",
|
"config": "tailwind.config.ts",
|
||||||
"css": "src/app/globals.css",
|
"css": "src/styles/globals.css",
|
||||||
"baseColor": "neutral",
|
"baseColor": "neutral",
|
||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
"prefix": ""
|
"prefix": ""
|
||||||
@@ -16,5 +16,6 @@
|
|||||||
"ui": "~/components/ui",
|
"ui": "~/components/ui",
|
||||||
"lib": "~/lib",
|
"lib": "~/lib",
|
||||||
"hooks": "~/hooks"
|
"hooks": "~/hooks"
|
||||||
}
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
}
|
}
|
||||||
47
docker-compose.yml
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:15
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: hristudio
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# pgadmin:
|
||||||
|
# image: dpage/pgadmin4
|
||||||
|
# environment:
|
||||||
|
# PGADMIN_DEFAULT_EMAIL: admin@admin.com
|
||||||
|
# PGADMIN_DEFAULT_PASSWORD: admin
|
||||||
|
# PGADMIN_CONFIG_SERVER_MODE: 'False'
|
||||||
|
# ports:
|
||||||
|
# - "5050:80"
|
||||||
|
# volumes:
|
||||||
|
# - pgadmin_data:/var/lib/pgadmin
|
||||||
|
# depends_on:
|
||||||
|
# db:
|
||||||
|
# condition: service_healthy
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio
|
||||||
|
ports:
|
||||||
|
- "9000:9000" # API
|
||||||
|
- "9001:9001" # Console
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: minioadmin
|
||||||
|
MINIO_ROOT_PASSWORD: minioadmin
|
||||||
|
volumes:
|
||||||
|
- minio_data:/data
|
||||||
|
command: server --console-address ":9001" /data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
minio_data:
|
||||||
|
pgadmin_data:
|
||||||
55
docs/DESIGN_DECISIONS.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# HRIStudio Design Decisions Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This document captures the design decisions made during the implementation of HRIStudio. It covers various areas including UI improvements, plugin store architecture, data loading and error handling, styling conformance, and overall system architecture. This serves as a reference for developers and other stakeholders to understand the rationale behind our choices.
|
||||||
|
|
||||||
|
## 1. UI Design & User Experience
|
||||||
|
- **Conditional Rendering and User Feedback:**
|
||||||
|
- Implemented dynamic messages in components (e.g., PluginBrowser, RobotList) to notify users when repositories or plugins are missing.
|
||||||
|
- Clear call-to-action prompts guide users to add repositories/plugins when necessary.
|
||||||
|
- **Consistent Styling and Themes:**
|
||||||
|
- Merged multiple globals.css files to create a cohesive, unified theme across the application.
|
||||||
|
- Updated sidebar and top bar styling, including gradients, background colors, and hover effects to ensure visual consistency in both light and dark modes.
|
||||||
|
- **Component-Based Approach:**
|
||||||
|
- Developed reusable, functional components (e.g., StudyCard, StudyForm, DeleteStudyButton) with TypeScript interfaces for enhanced maintainability and readability.
|
||||||
|
|
||||||
|
## 2. Plugin Store Architecture
|
||||||
|
- **Repository and Plugin Loading:**
|
||||||
|
- Updated the plugin loading mechanism to fetch repository metadata from `repository.json` instead of `index.json`.
|
||||||
|
- Implemented a fall-back sequence for loading individual plugin files (prioritizing `robot-plugins/plugins/{filename}` over alternative locations).
|
||||||
|
- **Error Handling:**
|
||||||
|
- Introduced a custom `PluginLoadError` to encapsulate errors in plugin loading and provide clearer error messages.
|
||||||
|
- **Caching Strategy:**
|
||||||
|
- Incorporated cache management with a TTL (5 minutes) to reduce frequent metadata fetching.
|
||||||
|
- **Transform Functions:**
|
||||||
|
- Allowed registration and retrieval of transformation functions (e.g., `transformToTwist`, `transformToPoseStamped`) that convert plugin parameters into suitable payload formats.
|
||||||
|
|
||||||
|
## 3. Data Access & ORM Integration
|
||||||
|
- **Drizzle ORM:**
|
||||||
|
- Adopted Drizzle ORM for type-safe database operations, using a custom `createTable` utility to avoid table naming conflicts.
|
||||||
|
- Defined schemas for studies, participants, permissions, and plugin repositories with clear relationships.
|
||||||
|
- **Type-Safe Data Validation:**
|
||||||
|
- Used Zod schemas along with TypeScript interfaces to validate data structures, ensuring integrity in plugin metadata and user data.
|
||||||
|
|
||||||
|
## 4. Authentication & Permissions
|
||||||
|
- **NextAuth & Auth.js Integration:**
|
||||||
|
- Configured NextAuth with a credentials provider, including bcrypt-based password verification.
|
||||||
|
- Extended session callbacks to include custom user details like name and role.
|
||||||
|
- **Role-Based Access Control:**
|
||||||
|
- Established a clear role hierarchy (Owner, Admin, Principal Investigator, Wizard, Researcher, Observer) with distinct permissions defined in a role-permission matrix.
|
||||||
|
- Enforced visibility and access controls for sensitive information based on user roles.
|
||||||
|
|
||||||
|
## 5. Next.js Architecture and Component Providers
|
||||||
|
- **Next.js 15 App Router:**
|
||||||
|
- Leveraged the new App Router to prioritize server components over client components, reducing reliance on `useEffect` and client-side state for data fetching.
|
||||||
|
- **Global Providers:**
|
||||||
|
- Wrapped the application with multiple providers (ThemeProvider, PluginStoreProvider, StudyProvider) to supply context and maintain consistent application state.
|
||||||
|
|
||||||
|
## 6. Additional Design Considerations
|
||||||
|
- **Error Logging and Debugging:**
|
||||||
|
- Enhanced error logging throughout the plugin store and repository loading processes to aid in troubleshooting.
|
||||||
|
- **Naming Conventions and File Structure:**
|
||||||
|
- Maintained a clear naming convention for directories (lowercase with dashes) and reused descriptive interface names to ensure clarity and consistency.
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
These design decisions were made to build a robust, scalable, and user-friendly platform for managing human-robot interaction studies. By emphasizing type safety, modularization, and thorough error handling, HRIStudio is well-equipped for both current needs and future enhancements. Future work may focus on extending experimental features and integrating advanced analytics tools.
|
||||||
29
docs/README.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# HRIStudio Documentation
|
||||||
|
|
||||||
|
Welcome to the HRIStudio documentation. This directory contains comprehensive documentation about the design, architecture, and implementation of HRIStudio.
|
||||||
|
|
||||||
|
## Documentation Structure
|
||||||
|
|
||||||
|
- [`architecture.md`](./architecture.md) - System architecture, tech stack, and core design decisions
|
||||||
|
- [`ui-design.md`](./ui-design.md) - UI/UX guidelines, component structure, and styling decisions
|
||||||
|
- [`plugin-store.md`](./plugin-store.md) - Plugin system architecture and implementation details
|
||||||
|
- [`auth-and-permissions.md`](./auth-and-permissions.md) - Authentication and role-based access control
|
||||||
|
- [`experiment-designer.md`](./experiment-designer.md) - Experiment designer implementation and flow control
|
||||||
|
- [`data-layer.md`](./data-layer.md) - Database schema, ORM usage, and data validation
|
||||||
|
- [`development.md`](./development.md) - Development guidelines, conventions, and best practices
|
||||||
|
- [`future-roadmap.md`](./future-roadmap.md) - Planned features and future enhancements
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
1. Read through [`architecture.md`](./architecture.md) for a high-level overview
|
||||||
|
2. Review [`development.md`](./development.md) for development setup and guidelines
|
||||||
|
3. Explore specific topics in their dedicated files
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
When adding new features or making significant changes:
|
||||||
|
|
||||||
|
1. Update relevant documentation files
|
||||||
|
2. Follow the established format and style
|
||||||
|
3. Include code examples where appropriate
|
||||||
|
4. Update this README if adding new documentation files
|
||||||
249
docs/architecture.md
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
# System Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
HRIStudio is built on a modern tech stack centered around Next.js 15's App Router, emphasizing server-side rendering and type safety throughout the application. This document outlines the core architectural decisions and system design.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Framework:** Next.js 15 (App Router)
|
||||||
|
- **Language:** TypeScript
|
||||||
|
- **Database ORM:** Drizzle
|
||||||
|
- **Authentication:** NextAuth.js (Auth.js)
|
||||||
|
- **API Layer:** tRPC
|
||||||
|
- **UI Components:**
|
||||||
|
- Shadcn UI
|
||||||
|
- Radix UI
|
||||||
|
- Tailwind CSS
|
||||||
|
- **State Management:** React Context + Hooks
|
||||||
|
- **Form Handling:** React Hook Form + Zod
|
||||||
|
|
||||||
|
## Core Architecture Components
|
||||||
|
|
||||||
|
### Next.js App Router Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/ # Next.js 15 App Router pages
|
||||||
|
│ ├── api/ # API routes
|
||||||
|
│ ├── auth/ # Authentication pages
|
||||||
|
│ ├── dashboard/ # Dashboard and main application
|
||||||
|
│ └── layout.tsx # Root layout
|
||||||
|
├── components/ # Shared React components
|
||||||
|
├── lib/ # Utility functions and shared logic
|
||||||
|
├── server/ # Server-side code
|
||||||
|
│ ├── api/ # tRPC routers
|
||||||
|
│ ├── auth/ # Authentication configuration
|
||||||
|
│ └── db/ # Database schemas and utilities
|
||||||
|
└── styles/ # Global styles and Tailwind config
|
||||||
|
```
|
||||||
|
|
||||||
|
### Global Providers
|
||||||
|
|
||||||
|
The application is wrapped in several context providers that manage global state:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<PluginStoreProvider>
|
||||||
|
<StudyProvider>
|
||||||
|
{children}
|
||||||
|
<Toaster />
|
||||||
|
</StudyProvider>
|
||||||
|
</PluginStoreProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server Components vs Client Components
|
||||||
|
|
||||||
|
We prioritize Server Components for better performance and SEO:
|
||||||
|
|
||||||
|
- **Server Components (Default):**
|
||||||
|
- Data fetching
|
||||||
|
- Static content rendering
|
||||||
|
- Layout components
|
||||||
|
- Database operations
|
||||||
|
|
||||||
|
- **Client Components (Marked with "use client"):**
|
||||||
|
- Interactive UI elements
|
||||||
|
- Components requiring browser APIs
|
||||||
|
- Real-time updates
|
||||||
|
- Form handling
|
||||||
|
|
||||||
|
## API Layer
|
||||||
|
|
||||||
|
### tRPC Integration
|
||||||
|
|
||||||
|
Type-safe API routes are implemented using tRPC:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const appRouter = createTRPCRouter({
|
||||||
|
study: studyRouter,
|
||||||
|
participant: participantRouter,
|
||||||
|
experiment: experimentRouter,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Each router provides strongly-typed procedures:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const studyRouter = createTRPCRouter({
|
||||||
|
create: protectedProcedure
|
||||||
|
.input(studySchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
// Implementation
|
||||||
|
}),
|
||||||
|
// Other procedures...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
Centralized error handling through tRPC:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const createTRPCContext = async (opts: CreateNextContextOptions) => {
|
||||||
|
const session = await getServerAuthSession();
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
db,
|
||||||
|
// Additional context...
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Architecture
|
||||||
|
|
||||||
|
### Drizzle ORM Integration
|
||||||
|
|
||||||
|
Custom table creation utility to prevent naming conflicts:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const createTable = pgTableCreator((name) => `hs_${name}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
Example schema definition:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const studies = createTable("study", {
|
||||||
|
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
|
||||||
|
title: varchar("title", { length: 256 }).notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
// Additional fields...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Relations
|
||||||
|
|
||||||
|
Explicit relation definitions using Drizzle's relations API:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const studiesRelations = relations(studies, ({ one, many }) => ({
|
||||||
|
creator: one(users, {
|
||||||
|
fields: [studies.createdById],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
members: many(studyMembers),
|
||||||
|
// Additional relations...
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
### Server-Side Rendering
|
||||||
|
|
||||||
|
- Leveraging Next.js App Router for optimal server-side rendering
|
||||||
|
- Minimizing client-side JavaScript
|
||||||
|
- Implementing proper caching strategies
|
||||||
|
|
||||||
|
### Data Fetching
|
||||||
|
|
||||||
|
- Using React Suspense for loading states
|
||||||
|
- Implementing stale-while-revalidate patterns
|
||||||
|
- Optimizing database queries
|
||||||
|
|
||||||
|
### Caching Strategy
|
||||||
|
|
||||||
|
- Browser-level caching for static assets
|
||||||
|
- Server-side caching for API responses
|
||||||
|
- Plugin store metadata caching (5-minute TTL)
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
|
||||||
|
1. User credentials validation
|
||||||
|
2. Password hashing with bcrypt
|
||||||
|
3. Session management with NextAuth
|
||||||
|
4. Role-based access control
|
||||||
|
|
||||||
|
### Data Protection
|
||||||
|
|
||||||
|
- Input validation using Zod schemas
|
||||||
|
- SQL injection prevention through Drizzle ORM
|
||||||
|
- XSS prevention through proper React escaping
|
||||||
|
- CSRF protection via Auth.js tokens
|
||||||
|
|
||||||
|
## Monitoring and Debugging
|
||||||
|
|
||||||
|
### Error Tracking
|
||||||
|
|
||||||
|
- Custom error classes for specific scenarios
|
||||||
|
- Detailed error logging
|
||||||
|
- Error boundary implementation
|
||||||
|
|
||||||
|
### Performance Monitoring
|
||||||
|
|
||||||
|
- Web Vitals tracking
|
||||||
|
- Server-side metrics collection
|
||||||
|
- Client-side performance monitoring
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### Type Safety
|
||||||
|
|
||||||
|
- Strict TypeScript configuration
|
||||||
|
- Zod schema validation
|
||||||
|
- tRPC for end-to-end type safety
|
||||||
|
|
||||||
|
### Code Organization
|
||||||
|
|
||||||
|
- Feature-based directory structure
|
||||||
|
- Clear separation of concerns
|
||||||
|
- Consistent naming conventions
|
||||||
|
|
||||||
|
### Testing Strategy
|
||||||
|
|
||||||
|
- Unit tests for utility functions
|
||||||
|
- Integration tests for API routes
|
||||||
|
- End-to-end tests for critical flows
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Environment Configuration
|
||||||
|
|
||||||
|
- Development environment
|
||||||
|
- Staging environment
|
||||||
|
- Production environment
|
||||||
|
|
||||||
|
### CI/CD Pipeline
|
||||||
|
|
||||||
|
- Automated testing
|
||||||
|
- Type checking
|
||||||
|
- Linting
|
||||||
|
- Build verification
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
This architecture provides a solid foundation for HRIStudio, emphasizing:
|
||||||
|
|
||||||
|
- Type safety
|
||||||
|
- Performance
|
||||||
|
- Scalability
|
||||||
|
- Maintainability
|
||||||
|
- Security
|
||||||
|
|
||||||
|
Future architectural decisions should align with these principles while considering the evolving needs of the platform.
|
||||||
365
docs/auth-and-permissions.md
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
# Authentication & Permissions System
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
HRIStudio implements a robust authentication and role-based access control (RBAC) system using NextAuth.js (Auth.js) and a custom permissions framework. This system ensures secure access to resources and proper isolation of sensitive data.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
### NextAuth Configuration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const authConfig = {
|
||||||
|
adapter: DrizzleAdapter(db),
|
||||||
|
providers: [
|
||||||
|
CredentialsProvider({
|
||||||
|
name: "credentials",
|
||||||
|
credentials: {
|
||||||
|
email: { label: "Email", type: "email" },
|
||||||
|
password: { label: "Password", type: "password" }
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
// Credential validation logic
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
session: ({ session, user }) => ({
|
||||||
|
...session,
|
||||||
|
user: {
|
||||||
|
...session.user,
|
||||||
|
id: user.id,
|
||||||
|
name: user.firstName && user.lastName
|
||||||
|
? `${user.firstName} ${user.lastName}`
|
||||||
|
: null,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
signIn: '/auth/signin',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session Management
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Session {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string | null;
|
||||||
|
lastName: string | null;
|
||||||
|
} & DefaultSession["user"];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Role-Based Access Control
|
||||||
|
|
||||||
|
### Role Hierarchy
|
||||||
|
|
||||||
|
1. **Owner**
|
||||||
|
- Single owner per study
|
||||||
|
- Full control over all aspects
|
||||||
|
- Can delete study or transfer ownership
|
||||||
|
- Can manage all other roles
|
||||||
|
|
||||||
|
2. **Admin**
|
||||||
|
- Multiple admins allowed
|
||||||
|
- Can manage participants and experiments
|
||||||
|
- Cannot delete study or transfer ownership
|
||||||
|
- Can invite and manage other users (except Owner)
|
||||||
|
|
||||||
|
3. **Principal Investigator (PI)**
|
||||||
|
- Scientific oversight role
|
||||||
|
- Full access to participant data
|
||||||
|
- Can manage experiment protocols
|
||||||
|
- Cannot modify core study settings
|
||||||
|
|
||||||
|
4. **Wizard**
|
||||||
|
- Operates robots during experiments
|
||||||
|
- Can control live experiment sessions
|
||||||
|
- Limited view of participant data
|
||||||
|
- Cannot modify study design
|
||||||
|
|
||||||
|
5. **Researcher**
|
||||||
|
- Can view and analyze data
|
||||||
|
- Access to anonymized information
|
||||||
|
- Cannot modify study or participant data
|
||||||
|
- Cannot run experiment trials
|
||||||
|
|
||||||
|
6. **Observer**
|
||||||
|
- Can view live experiments
|
||||||
|
- Access to anonymized data
|
||||||
|
- Can add annotations
|
||||||
|
- Cannot modify any study aspects
|
||||||
|
|
||||||
|
### Permission Categories
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const PERMISSIONS = {
|
||||||
|
// Study Management
|
||||||
|
CREATE_STUDY: "create_study",
|
||||||
|
DELETE_STUDY: "delete_study",
|
||||||
|
EDIT_STUDY: "edit_study",
|
||||||
|
TRANSFER_OWNERSHIP: "transfer_ownership",
|
||||||
|
|
||||||
|
// Participant Management
|
||||||
|
VIEW_PARTICIPANTS: "view_participants",
|
||||||
|
ADD_PARTICIPANT: "add_participant",
|
||||||
|
EDIT_PARTICIPANT: "edit_participant",
|
||||||
|
DELETE_PARTICIPANT: "delete_participant",
|
||||||
|
VIEW_PARTICIPANT_NAMES: "view_participant_names",
|
||||||
|
|
||||||
|
// Experiment Management
|
||||||
|
CREATE_EXPERIMENT: "create_experiment",
|
||||||
|
EDIT_EXPERIMENT: "edit_experiment",
|
||||||
|
DELETE_EXPERIMENT: "delete_experiment",
|
||||||
|
RUN_EXPERIMENT: "run_experiment",
|
||||||
|
|
||||||
|
// Data Access
|
||||||
|
EXPORT_DATA: "export_data",
|
||||||
|
VIEW_ANALYTICS: "view_analytics",
|
||||||
|
|
||||||
|
// User Management
|
||||||
|
INVITE_USERS: "invite_users",
|
||||||
|
MANAGE_ROLES: "manage_roles",
|
||||||
|
} as const;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Role-Permission Matrix
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||||
|
OWNER: Object.values(PERMISSIONS),
|
||||||
|
ADMIN: [
|
||||||
|
PERMISSIONS.EDIT_STUDY,
|
||||||
|
PERMISSIONS.VIEW_PARTICIPANTS,
|
||||||
|
PERMISSIONS.ADD_PARTICIPANT,
|
||||||
|
PERMISSIONS.EDIT_PARTICIPANT,
|
||||||
|
PERMISSIONS.DELETE_PARTICIPANT,
|
||||||
|
PERMISSIONS.VIEW_PARTICIPANT_NAMES,
|
||||||
|
PERMISSIONS.CREATE_EXPERIMENT,
|
||||||
|
PERMISSIONS.EDIT_EXPERIMENT,
|
||||||
|
PERMISSIONS.DELETE_EXPERIMENT,
|
||||||
|
PERMISSIONS.RUN_EXPERIMENT,
|
||||||
|
PERMISSIONS.EXPORT_DATA,
|
||||||
|
PERMISSIONS.VIEW_ANALYTICS,
|
||||||
|
PERMISSIONS.INVITE_USERS,
|
||||||
|
PERMISSIONS.MANAGE_ROLES,
|
||||||
|
],
|
||||||
|
PRINCIPAL_INVESTIGATOR: [
|
||||||
|
PERMISSIONS.VIEW_PARTICIPANTS,
|
||||||
|
PERMISSIONS.ADD_PARTICIPANT,
|
||||||
|
PERMISSIONS.EDIT_PARTICIPANT,
|
||||||
|
PERMISSIONS.VIEW_PARTICIPANT_NAMES,
|
||||||
|
PERMISSIONS.CREATE_EXPERIMENT,
|
||||||
|
PERMISSIONS.EDIT_EXPERIMENT,
|
||||||
|
PERMISSIONS.RUN_EXPERIMENT,
|
||||||
|
PERMISSIONS.EXPORT_DATA,
|
||||||
|
PERMISSIONS.VIEW_ANALYTICS,
|
||||||
|
],
|
||||||
|
// ... additional role permissions
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### Permission Checking
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function checkPermissions({
|
||||||
|
studyId,
|
||||||
|
permission,
|
||||||
|
session,
|
||||||
|
}: {
|
||||||
|
studyId?: number;
|
||||||
|
permission: Permission;
|
||||||
|
session: Session | null;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (!session?.user) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You must be logged in to perform this action",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anyone who is logged in can create a study
|
||||||
|
if (!studyId) {
|
||||||
|
if (permission === "CREATE_STUDY") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Study ID is required for this action",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = await db.query.studyMembers.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(studyMembers.studyId, studyId),
|
||||||
|
eq(studyMembers.userId, session.user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "You do not have permission to perform this action",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedRole = membership.role.toUpperCase() as keyof typeof ROLE_PERMISSIONS;
|
||||||
|
const permittedActions = ROLE_PERMISSIONS[normalizedRole] ?? [];
|
||||||
|
|
||||||
|
if (normalizedRole === "OWNER") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!permittedActions.includes(permission)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "You do not have permission to perform this action",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const studyMembers = createTable("study_member", {
|
||||||
|
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
|
||||||
|
studyId: integer("study_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => studies.id, { onDelete: "cascade" }),
|
||||||
|
userId: varchar("user_id", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
role: studyRoleEnum("role").notNull(),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI Integration
|
||||||
|
|
||||||
|
### Protected Routes
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default function DashboardLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const { data: session, status } = useSession()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === "unauthenticated") {
|
||||||
|
router.replace("/auth/signin")
|
||||||
|
}
|
||||||
|
}, [status, router])
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Layout content
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional Rendering
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function ParticipantDetails({ participant, role }: Props) {
|
||||||
|
const canViewIdentifiableInfo = [
|
||||||
|
ROLES.OWNER,
|
||||||
|
ROLES.ADMIN,
|
||||||
|
ROLES.PRINCIPAL_INVESTIGATOR
|
||||||
|
].includes(role);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{canViewIdentifiableInfo ? (
|
||||||
|
<div>
|
||||||
|
<h3>{participant.name}</h3>
|
||||||
|
<p>{participant.email}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<h3>Participant {participant.id}</h3>
|
||||||
|
<p>[Redacted]</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Password Handling
|
||||||
|
|
||||||
|
- Passwords are hashed using bcrypt before storage
|
||||||
|
- Minimum password requirements enforced
|
||||||
|
- Rate limiting on authentication attempts
|
||||||
|
|
||||||
|
### Session Security
|
||||||
|
|
||||||
|
- CSRF protection enabled
|
||||||
|
- Secure session cookies
|
||||||
|
- Session expiration and renewal
|
||||||
|
|
||||||
|
### Data Access
|
||||||
|
|
||||||
|
- Row-level security through role checks
|
||||||
|
- Audit logging of sensitive operations
|
||||||
|
- Data encryption at rest
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Permission Checking:**
|
||||||
|
- Always check permissions before sensitive operations
|
||||||
|
- Use the checkPermissions utility consistently
|
||||||
|
- Include proper error messages
|
||||||
|
|
||||||
|
2. **Role Assignment:**
|
||||||
|
- Validate role assignments
|
||||||
|
- Maintain role hierarchy
|
||||||
|
- Prevent privilege escalation
|
||||||
|
|
||||||
|
3. **UI Security:**
|
||||||
|
- Hide sensitive UI elements based on permissions
|
||||||
|
- Clear error messages without exposing internals
|
||||||
|
- Proper loading states during authentication
|
||||||
|
|
||||||
|
4. **Audit Trail:**
|
||||||
|
- Log authentication attempts
|
||||||
|
- Track permission changes
|
||||||
|
- Monitor sensitive data access
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
1. **Advanced Authentication:**
|
||||||
|
- Multi-factor authentication
|
||||||
|
- OAuth provider integration
|
||||||
|
- SSO support
|
||||||
|
|
||||||
|
2. **Enhanced Permissions:**
|
||||||
|
- Custom role creation
|
||||||
|
- Temporary permissions
|
||||||
|
- Permission inheritance
|
||||||
|
|
||||||
|
3. **Audit System:**
|
||||||
|
- Detailed activity logging
|
||||||
|
- Security alerts
|
||||||
|
- Compliance reporting
|
||||||
|
|
||||||
|
4. **UI Improvements:**
|
||||||
|
- Role management interface
|
||||||
|
- Permission visualization
|
||||||
|
- Audit log viewer
|
||||||
304
docs/data-layer.md
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
# Data Layer
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
HRIStudio's data layer is built on PostgreSQL using Drizzle ORM for type-safe database operations. The system implements a comprehensive schema design that supports studies, experiments, participants, and plugin management while maintaining data integrity and proper relationships.
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### Core Tables
|
||||||
|
|
||||||
|
#### Studies
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const studies = createTable("study", {
|
||||||
|
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
|
||||||
|
title: varchar("title", { length: 256 }).notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
createdById: varchar("created_by", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const studyMembers = createTable("study_member", {
|
||||||
|
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
|
||||||
|
studyId: integer("study_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => studies.id, { onDelete: "cascade" }),
|
||||||
|
userId: varchar("user_id", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
role: studyRoleEnum("role").notNull(),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Experiments
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const experiments = createTable("experiment", {
|
||||||
|
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
|
||||||
|
studyId: integer("study_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => studies.id, { onDelete: "cascade" }),
|
||||||
|
title: varchar("title", { length: 256 }).notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
version: integer("version").notNull().default(1),
|
||||||
|
status: experimentStatusEnum("status").notNull().default("draft"),
|
||||||
|
steps: jsonb("steps").$type<Step[]>().default([]),
|
||||||
|
createdById: varchar("created_by", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Participants
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const participants = createTable("participant", {
|
||||||
|
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
|
||||||
|
studyId: integer("study_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => studies.id, { onDelete: "cascade" }),
|
||||||
|
identifier: varchar("identifier", { length: 256 }),
|
||||||
|
email: varchar("email", { length: 256 }),
|
||||||
|
firstName: varchar("first_name", { length: 256 }),
|
||||||
|
lastName: varchar("last_name", { length: 256 }),
|
||||||
|
notes: text("notes"),
|
||||||
|
status: participantStatusEnum("status").notNull().default("active"),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Plugin Store Tables
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const pluginRepositories = createTable("plugin_repository", {
|
||||||
|
id: varchar("id", { length: 255 }).primaryKey(),
|
||||||
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
url: varchar("url", { length: 255 }).notNull(),
|
||||||
|
official: boolean("official").default(false).notNull(),
|
||||||
|
author: jsonb("author").notNull().$type<{
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
url?: string;
|
||||||
|
organization?: string;
|
||||||
|
}>(),
|
||||||
|
maintainers: jsonb("maintainers").$type<Array<{
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
url?: string;
|
||||||
|
}>>(),
|
||||||
|
compatibility: jsonb("compatibility").notNull().$type<{
|
||||||
|
hristudio: {
|
||||||
|
min: string;
|
||||||
|
recommended?: string;
|
||||||
|
};
|
||||||
|
ros2?: {
|
||||||
|
distributions: string[];
|
||||||
|
recommended?: string;
|
||||||
|
};
|
||||||
|
}>(),
|
||||||
|
stats: jsonb("stats").$type<{
|
||||||
|
downloads: number;
|
||||||
|
stars: number;
|
||||||
|
plugins: number;
|
||||||
|
}>(),
|
||||||
|
addedById: varchar("added_by", { length: 255 })
|
||||||
|
.references(() => users.id),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Relations
|
||||||
|
|
||||||
|
### Study Relations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const studiesRelations = relations(studies, ({ one, many }) => ({
|
||||||
|
creator: one(users, {
|
||||||
|
fields: [studies.createdById],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
members: many(studyMembers),
|
||||||
|
participants: many(participants),
|
||||||
|
experiments: many(experiments),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const studyMembersRelations = relations(studyMembers, ({ one }) => ({
|
||||||
|
study: one(studies, {
|
||||||
|
fields: [studyMembers.studyId],
|
||||||
|
references: [studies.id],
|
||||||
|
}),
|
||||||
|
user: one(users, {
|
||||||
|
fields: [studyMembers.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Validation
|
||||||
|
|
||||||
|
### Zod Schemas
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const studySchema = z.object({
|
||||||
|
title: z.string().min(1, "Title is required").max(256, "Title is too long"),
|
||||||
|
description: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const participantSchema = z.object({
|
||||||
|
identifier: z.string().optional(),
|
||||||
|
email: z.string().email().optional(),
|
||||||
|
firstName: z.string().optional(),
|
||||||
|
lastName: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
status: z.enum(["active", "inactive", "completed", "withdrawn"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const experimentSchema = z.object({
|
||||||
|
title: z.string().min(1, "Title is required").max(256, "Title is too long"),
|
||||||
|
description: z.string().optional(),
|
||||||
|
steps: z.array(stepSchema),
|
||||||
|
status: z.enum(["draft", "active", "archived"]),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Query Building
|
||||||
|
|
||||||
|
### Type-Safe Queries
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const study = await db.query.studies.findFirst({
|
||||||
|
where: eq(studies.id, studyId),
|
||||||
|
with: {
|
||||||
|
creator: true,
|
||||||
|
members: {
|
||||||
|
with: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
participants: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mutations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const newStudy = await db.transaction(async (tx) => {
|
||||||
|
const [study] = await tx.insert(studies).values({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
createdById: userId,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
await tx.insert(studyMembers).values({
|
||||||
|
studyId: study.id,
|
||||||
|
userId,
|
||||||
|
role: "OWNER",
|
||||||
|
});
|
||||||
|
|
||||||
|
return study;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Database Errors
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
try {
|
||||||
|
await db.insert(participants).values({
|
||||||
|
studyId,
|
||||||
|
identifier,
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PostgresError) {
|
||||||
|
if (error.code === "23505") { // Unique violation
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "CONFLICT",
|
||||||
|
message: "A participant with this identifier already exists",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
### Migration Structure
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { createTable, integer, text, timestamp, varchar } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
export async function up(db: Database) {
|
||||||
|
await db.schema.createTable("study")
|
||||||
|
.addColumn("id", "serial", (col) => col.primaryKey())
|
||||||
|
.addColumn("title", "varchar(256)", (col) => col.notNull())
|
||||||
|
.addColumn("description", "text")
|
||||||
|
.addColumn("created_by", "varchar(255)", (col) =>
|
||||||
|
col.notNull().references("user.id"))
|
||||||
|
.addColumn("created_at", "timestamp", (col) =>
|
||||||
|
col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`))
|
||||||
|
.addColumn("updated_at", "timestamp");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Database) {
|
||||||
|
await db.schema.dropTable("study");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Type Safety:**
|
||||||
|
- Use Drizzle's type inference
|
||||||
|
- Define explicit types for complex queries
|
||||||
|
- Validate input with Zod schemas
|
||||||
|
|
||||||
|
2. **Performance:**
|
||||||
|
- Use appropriate indexes
|
||||||
|
- Optimize complex queries
|
||||||
|
- Implement caching where needed
|
||||||
|
|
||||||
|
3. **Data Integrity:**
|
||||||
|
- Use transactions for related operations
|
||||||
|
- Implement proper cascading
|
||||||
|
- Validate data before insertion
|
||||||
|
|
||||||
|
4. **Security:**
|
||||||
|
- Sanitize user input
|
||||||
|
- Implement proper access control
|
||||||
|
- Use parameterized queries
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
1. **Query Optimization:**
|
||||||
|
- Query caching
|
||||||
|
- Materialized views
|
||||||
|
- Query analysis tools
|
||||||
|
|
||||||
|
2. **Data Management:**
|
||||||
|
- Data archiving
|
||||||
|
- Backup strategies
|
||||||
|
- Data export tools
|
||||||
|
|
||||||
|
3. **Schema Evolution:**
|
||||||
|
- Zero-downtime migrations
|
||||||
|
- Schema versioning
|
||||||
|
- Backward compatibility
|
||||||
|
|
||||||
|
4. **Monitoring:**
|
||||||
|
- Query performance metrics
|
||||||
|
- Error tracking
|
||||||
|
- Usage analytics
|
||||||
376
docs/development.md
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
# Development Guidelines
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document outlines the development practices, coding standards, and workflow guidelines for contributing to HRIStudio. Following these guidelines ensures consistency and maintainability across the codebase.
|
||||||
|
|
||||||
|
## Development Environment
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js (v18+)
|
||||||
|
- PostgreSQL (v14+)
|
||||||
|
- Docker (for local development)
|
||||||
|
- VS Code (recommended)
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
1. **Clone the Repository:**
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/hristudio.git
|
||||||
|
cd hristudio
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Install Dependencies:**
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Environment Configuration:**
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your local settings
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Database Setup:**
|
||||||
|
```bash
|
||||||
|
npm run docker:up # Start PostgreSQL container
|
||||||
|
npm run db:push # Apply database schema
|
||||||
|
npm run db:seed # Seed initial data
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Start Development Server:**
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Organization
|
||||||
|
|
||||||
|
### Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/ # Next.js pages and layouts
|
||||||
|
├── components/ # React components
|
||||||
|
│ ├── auth/ # Authentication components
|
||||||
|
│ ├── experiments/ # Experiment-related components
|
||||||
|
│ ├── layout/ # Layout components
|
||||||
|
│ ├── navigation/ # Navigation components
|
||||||
|
│ ├── studies/ # Study-related components
|
||||||
|
│ └── ui/ # Shared UI components
|
||||||
|
├── lib/ # Utility functions and shared logic
|
||||||
|
│ ├── experiments/ # Experiment-related utilities
|
||||||
|
│ ├── permissions/ # Permission checking utilities
|
||||||
|
│ └── plugin-store/ # Plugin store implementation
|
||||||
|
├── server/ # Server-side code
|
||||||
|
│ ├── api/ # tRPC routers
|
||||||
|
│ ├── auth/ # Authentication configuration
|
||||||
|
│ └── db/ # Database schemas and utilities
|
||||||
|
└── styles/ # Global styles and Tailwind config
|
||||||
|
```
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
|
||||||
|
1. **Files and Directories:**
|
||||||
|
```typescript
|
||||||
|
// Components
|
||||||
|
components/auth/sign-in-form.tsx
|
||||||
|
components/studies/study-card.tsx
|
||||||
|
|
||||||
|
// Pages
|
||||||
|
app/dashboard/studies/[id]/page.tsx
|
||||||
|
app/dashboard/experiments/new/page.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Component Names:**
|
||||||
|
```typescript
|
||||||
|
// PascalCase for component names
|
||||||
|
export function SignInForm() { ... }
|
||||||
|
export function StudyCard() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Variables and Functions:**
|
||||||
|
```typescript
|
||||||
|
// camelCase for variables and functions
|
||||||
|
const userSession = useSession();
|
||||||
|
function handleSubmit() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coding Standards
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
|
||||||
|
1. **Type Definitions:**
|
||||||
|
```typescript
|
||||||
|
// Use interfaces for object definitions
|
||||||
|
interface StudyProps {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use type for unions and intersections
|
||||||
|
type Status = "draft" | "active" | "archived";
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Type Safety:**
|
||||||
|
```typescript
|
||||||
|
// Use proper type annotations
|
||||||
|
function getStudy(id: number): Promise<Study> {
|
||||||
|
return db.query.studies.findFirst({
|
||||||
|
where: eq(studies.id, id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### React Components
|
||||||
|
|
||||||
|
1. **Functional Components:**
|
||||||
|
```typescript
|
||||||
|
interface ButtonProps {
|
||||||
|
variant?: "default" | "outline" | "ghost";
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({ variant = "default", children }: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<button className={cn(buttonVariants({ variant }))}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Hooks:**
|
||||||
|
```typescript
|
||||||
|
function useStudy(studyId: number) {
|
||||||
|
const { data, isLoading } = api.study.getById.useQuery({ id: studyId });
|
||||||
|
|
||||||
|
return {
|
||||||
|
study: data,
|
||||||
|
isLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
|
||||||
|
1. **Tailwind CSS:**
|
||||||
|
```typescript
|
||||||
|
// Use Tailwind classes
|
||||||
|
<div className="flex items-center justify-between p-4 bg-card">
|
||||||
|
<h2 className="text-lg font-semibold">Title</h2>
|
||||||
|
<Button className="hover:bg-primary/90">Action</Button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **CSS Variables:**
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--primary: 217 91% 60%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-element {
|
||||||
|
background: hsl(var(--primary));
|
||||||
|
color: hsl(var(--primary-foreground));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe("StudyCard", () => {
|
||||||
|
it("renders study information correctly", () => {
|
||||||
|
const study = {
|
||||||
|
id: 1,
|
||||||
|
title: "Test Study",
|
||||||
|
description: "Test Description",
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<StudyCard study={study} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Test Study")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Test Description")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe("Study Creation", () => {
|
||||||
|
it("creates a new study", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<CreateStudyForm />);
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText("Title"), "New Study");
|
||||||
|
await user.click(screen.getByText("Create Study"));
|
||||||
|
|
||||||
|
expect(await screen.findByText("Study created successfully")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### API Errors
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
try {
|
||||||
|
const result = await api.study.create.mutate(data);
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Study created successfully",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Form Validation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const form = useForm<FormData>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: {
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSubmit(data: FormData) {
|
||||||
|
try {
|
||||||
|
// Form submission logic
|
||||||
|
} catch (error) {
|
||||||
|
form.setError("root", {
|
||||||
|
type: "submit",
|
||||||
|
message: "Something went wrong",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Optimization
|
||||||
|
|
||||||
|
### React Optimization
|
||||||
|
|
||||||
|
1. **Memoization:**
|
||||||
|
```typescript
|
||||||
|
const MemoizedComponent = memo(function Component({ data }: Props) {
|
||||||
|
return <div>{data}</div>;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Code Splitting:**
|
||||||
|
```typescript
|
||||||
|
const DynamicComponent = dynamic(() => import("./HeavyComponent"), {
|
||||||
|
loading: () => <Skeleton />,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Optimization
|
||||||
|
|
||||||
|
1. **Efficient Queries:**
|
||||||
|
```typescript
|
||||||
|
// Use select to only fetch needed fields
|
||||||
|
const study = await db.query.studies.findFirst({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
},
|
||||||
|
where: eq(studies.id, studyId),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Batch Operations:**
|
||||||
|
```typescript
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await Promise.all(
|
||||||
|
participants.map(p =>
|
||||||
|
tx.insert(participants).values(p)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Git Workflow
|
||||||
|
|
||||||
|
### Branching Strategy
|
||||||
|
|
||||||
|
1. `main` - Production-ready code
|
||||||
|
2. `develop` - Development branch
|
||||||
|
3. Feature branches: `feature/feature-name`
|
||||||
|
4. Bug fixes: `fix/bug-description`
|
||||||
|
|
||||||
|
### Commit Messages
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Format
|
||||||
|
<type>(<scope>): <description>
|
||||||
|
|
||||||
|
# Examples
|
||||||
|
feat(studies): add study creation workflow
|
||||||
|
fix(auth): resolve sign-in validation issue
|
||||||
|
docs(api): update API documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Environment Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/hristudio"
|
||||||
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
|
|
||||||
|
# Production
|
||||||
|
DATABASE_URL="postgresql://user:pass@production-db/hristudio"
|
||||||
|
NEXTAUTH_URL="https://hristudio.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build Process
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build application
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Type check
|
||||||
|
npm run typecheck
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
npm run test
|
||||||
|
|
||||||
|
# Start production server
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Code Quality:**
|
||||||
|
- Write self-documenting code
|
||||||
|
- Add comments for complex logic
|
||||||
|
- Follow TypeScript best practices
|
||||||
|
|
||||||
|
2. **Security:**
|
||||||
|
- Validate all inputs
|
||||||
|
- Implement proper authentication
|
||||||
|
- Use HTTPS in production
|
||||||
|
|
||||||
|
3. **Performance:**
|
||||||
|
- Optimize bundle size
|
||||||
|
- Implement caching strategies
|
||||||
|
- Monitor performance metrics
|
||||||
|
|
||||||
|
4. **Maintenance:**
|
||||||
|
- Keep dependencies updated
|
||||||
|
- Document breaking changes
|
||||||
|
- Maintain test coverage
|
||||||
383
docs/experiment-designer.md
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
# Experiment Designer
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Experiment Designer is a core feature of HRIStudio that enables researchers to create and configure robot experiments using a visual, flow-based interface. It supports drag-and-drop functionality, real-time updates, and integration with the plugin system.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ExperimentDesignerProps {
|
||||||
|
className?: string;
|
||||||
|
defaultSteps?: Step[];
|
||||||
|
onChange?: (steps: Step[]) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExperimentDesigner({
|
||||||
|
className,
|
||||||
|
defaultSteps = [],
|
||||||
|
onChange,
|
||||||
|
readOnly = false,
|
||||||
|
}: ExperimentDesignerProps) {
|
||||||
|
// Implementation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type ActionType =
|
||||||
|
| "move" // Robot movement
|
||||||
|
| "speak" // Robot speech
|
||||||
|
| "wait" // Wait for a duration
|
||||||
|
| "input" // Wait for user input
|
||||||
|
| "gesture" // Robot gesture
|
||||||
|
| "record" // Start/stop recording
|
||||||
|
| "condition" // Conditional branching
|
||||||
|
| "loop"; // Repeat actions
|
||||||
|
|
||||||
|
export interface Action {
|
||||||
|
id: string;
|
||||||
|
type: ActionType;
|
||||||
|
parameters: Record<string, any>;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Step {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
actions: Action[];
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Experiment {
|
||||||
|
id: number;
|
||||||
|
studyId: number;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
version: number;
|
||||||
|
status: "draft" | "active" | "archived";
|
||||||
|
steps: Step[];
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Visual Components
|
||||||
|
|
||||||
|
### Action Node
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ActionNodeData {
|
||||||
|
type: string;
|
||||||
|
parameters: Record<string, any>;
|
||||||
|
onChange?: (parameters: Record<string, any>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ActionNode = memo(({ data, selected }: NodeProps<ActionNodeData>) => {
|
||||||
|
const [configOpen, setConfigOpen] = useState(false);
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const actionConfig = AVAILABLE_ACTIONS.find((a) => a.type === data.type);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.8, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className={cn(
|
||||||
|
"relative",
|
||||||
|
"before:absolute before:inset-[-2px] before:rounded-xl before:bg-gradient-to-br",
|
||||||
|
selected && "before:from-primary/50 before:to-primary/20"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-gradient-to-br">
|
||||||
|
{actionConfig?.icon}
|
||||||
|
</div>
|
||||||
|
<CardTitle>{actionConfig?.title}</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CardDescription>{actionConfig?.description}</CardDescription>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flow Edge
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function FlowEdge({
|
||||||
|
id,
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
sourcePosition,
|
||||||
|
targetPosition,
|
||||||
|
style = {},
|
||||||
|
markerEnd,
|
||||||
|
}: EdgeProps) {
|
||||||
|
const [edgePath] = getBezierPath({
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
sourcePosition,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
targetPosition,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<BaseEdge path={edgePath} markerEnd={markerEnd} style={style} />
|
||||||
|
<motion.path
|
||||||
|
id={id}
|
||||||
|
style={{
|
||||||
|
strokeWidth: 3,
|
||||||
|
fill: "none",
|
||||||
|
stroke: "hsl(var(--primary))",
|
||||||
|
strokeDasharray: "5,5",
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
d={edgePath}
|
||||||
|
animate={{
|
||||||
|
strokeDashoffset: [0, -10],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 1,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "linear",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Action Configuration
|
||||||
|
|
||||||
|
### Available Actions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const AVAILABLE_ACTIONS: ActionConfig[] = [
|
||||||
|
{
|
||||||
|
type: "move",
|
||||||
|
title: "Move Robot",
|
||||||
|
description: "Move the robot to a specific position",
|
||||||
|
icon: <Move className="h-4 w-4" />,
|
||||||
|
defaultParameters: {
|
||||||
|
position: { x: 0, y: 0, z: 0 },
|
||||||
|
speed: 1,
|
||||||
|
easing: "linear",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "speak",
|
||||||
|
title: "Robot Speech",
|
||||||
|
description: "Make the robot say something",
|
||||||
|
icon: <MessageSquare className="h-4 w-4" />,
|
||||||
|
defaultParameters: {
|
||||||
|
text: "",
|
||||||
|
speed: 1,
|
||||||
|
pitch: 1,
|
||||||
|
volume: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Additional actions...
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parameter Configuration Dialog
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ActionConfigDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
type: ActionType;
|
||||||
|
parameters: Record<string, any>;
|
||||||
|
onSubmit: (parameters: Record<string, any>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionConfigDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
type,
|
||||||
|
parameters,
|
||||||
|
onSubmit,
|
||||||
|
}: ActionConfigDialogProps) {
|
||||||
|
const actionConfig = AVAILABLE_ACTIONS.find(a => a.type === type);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Configure {actionConfig?.title}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{actionConfig?.description}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form>
|
||||||
|
{/* Parameter fields */}
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const experiments = createTable("experiment", {
|
||||||
|
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
|
||||||
|
studyId: integer("study_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => studies.id, { onDelete: "cascade" }),
|
||||||
|
title: varchar("title", { length: 256 }).notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
version: integer("version").notNull().default(1),
|
||||||
|
status: experimentStatusEnum("status").notNull().default("draft"),
|
||||||
|
steps: jsonb("steps").$type<Step[]>().default([]),
|
||||||
|
createdById: varchar("created_by", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration with Plugin System
|
||||||
|
|
||||||
|
### Action Transformation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ActionTransform {
|
||||||
|
type: "direct" | "transform";
|
||||||
|
transformFn?: string;
|
||||||
|
map?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformActionParameters(
|
||||||
|
parameters: Record<string, any>,
|
||||||
|
transform: ActionTransform
|
||||||
|
): unknown {
|
||||||
|
if (transform.type === "direct") {
|
||||||
|
return parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transformFn = getTransformFunction(transform.transformFn!);
|
||||||
|
return transformFn(parameters);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Plugin Action Integration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function getAvailableActions(plugin: RobotPlugin): ActionConfig[] {
|
||||||
|
return plugin.actions.map(action => ({
|
||||||
|
type: action.type,
|
||||||
|
title: action.title,
|
||||||
|
description: action.description,
|
||||||
|
icon: getActionIcon(action.type),
|
||||||
|
defaultParameters: getDefaultParameters(action.parameters),
|
||||||
|
transform: action.ros2?.payloadMapping,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## User Interface Features
|
||||||
|
|
||||||
|
### Drag and Drop
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function onDragStart(event: DragEvent, nodeType: string) {
|
||||||
|
event.dataTransfer.setData("application/reactflow", nodeType);
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const type = event.dataTransfer.getData("application/reactflow");
|
||||||
|
const position = project({
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newNode = {
|
||||||
|
id: getId(),
|
||||||
|
type,
|
||||||
|
position,
|
||||||
|
data: { label: `${type} node` },
|
||||||
|
};
|
||||||
|
|
||||||
|
setNodes((nds) => nds.concat(newNode));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step Organization
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function reorderSteps(steps: Step[], sourceIndex: number, targetIndex: number): Step[] {
|
||||||
|
const result = Array.from(steps);
|
||||||
|
const [removed] = result.splice(sourceIndex, 1);
|
||||||
|
result.splice(targetIndex, 0, removed);
|
||||||
|
|
||||||
|
return result.map((step, index) => ({
|
||||||
|
...step,
|
||||||
|
order: index,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Performance:**
|
||||||
|
- Use React.memo for expensive components
|
||||||
|
- Implement virtualization for large flows
|
||||||
|
- Optimize drag and drop operations
|
||||||
|
|
||||||
|
2. **User Experience:**
|
||||||
|
- Provide clear visual feedback
|
||||||
|
- Implement undo/redo functionality
|
||||||
|
- Show validation errors inline
|
||||||
|
|
||||||
|
3. **Data Management:**
|
||||||
|
- Validate experiment data
|
||||||
|
- Implement auto-save
|
||||||
|
- Version control experiments
|
||||||
|
|
||||||
|
4. **Error Handling:**
|
||||||
|
- Validate action parameters
|
||||||
|
- Handle plugin loading errors
|
||||||
|
- Provide clear error messages
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
1. **Advanced Flow Control:**
|
||||||
|
- Conditional branching
|
||||||
|
- Parallel execution
|
||||||
|
- Loop constructs
|
||||||
|
|
||||||
|
2. **Visual Improvements:**
|
||||||
|
- Custom node themes
|
||||||
|
- Animation preview
|
||||||
|
- Mini-map navigation
|
||||||
|
|
||||||
|
3. **Collaboration:**
|
||||||
|
- Real-time collaboration
|
||||||
|
- Comment system
|
||||||
|
- Version history
|
||||||
|
|
||||||
|
4. **Analysis Tools:**
|
||||||
|
- Flow validation
|
||||||
|
- Performance analysis
|
||||||
|
- Debug tools
|
||||||
306
docs/future-roadmap.md
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
# Future Roadmap
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document outlines the planned features, improvements, and future direction for HRIStudio. The roadmap is organized by priority and expected timeline.
|
||||||
|
|
||||||
|
## Q2 2024
|
||||||
|
|
||||||
|
### 1. Enhanced Plugin System
|
||||||
|
|
||||||
|
- **Plugin Marketplace**
|
||||||
|
- Community plugin submissions
|
||||||
|
- Plugin ratings and reviews
|
||||||
|
- Download statistics
|
||||||
|
- Version management
|
||||||
|
|
||||||
|
- **Advanced Plugin Features**
|
||||||
|
- Real-time plugin updates
|
||||||
|
- Plugin dependency management
|
||||||
|
- Custom action visualization
|
||||||
|
- Plugin testing framework
|
||||||
|
|
||||||
|
- **Plugin Development Tools**
|
||||||
|
- Plugin scaffolding CLI
|
||||||
|
- Development documentation
|
||||||
|
- Plugin validation tools
|
||||||
|
- Local testing environment
|
||||||
|
|
||||||
|
### 2. Experiment Designer Improvements
|
||||||
|
|
||||||
|
- **Advanced Flow Control**
|
||||||
|
- Conditional branching
|
||||||
|
- Parallel execution paths
|
||||||
|
- Loop constructs
|
||||||
|
- Event-based triggers
|
||||||
|
|
||||||
|
- **Visual Enhancements**
|
||||||
|
- Custom node themes
|
||||||
|
- Animation preview
|
||||||
|
- Mini-map navigation
|
||||||
|
- Grid snapping
|
||||||
|
|
||||||
|
- **Collaboration Features**
|
||||||
|
- Real-time collaboration
|
||||||
|
- Comment system
|
||||||
|
- Version history
|
||||||
|
- Experiment templates
|
||||||
|
|
||||||
|
## Q3 2024
|
||||||
|
|
||||||
|
### 1. Data Analysis Tools
|
||||||
|
|
||||||
|
- **Analytics Dashboard**
|
||||||
|
- Experiment metrics
|
||||||
|
- Participant statistics
|
||||||
|
- Performance analytics
|
||||||
|
- Custom reports
|
||||||
|
|
||||||
|
- **Data Visualization**
|
||||||
|
- Interactive charts
|
||||||
|
- Timeline views
|
||||||
|
- Heat maps
|
||||||
|
- Export capabilities
|
||||||
|
|
||||||
|
- **Machine Learning Integration**
|
||||||
|
- Pattern recognition
|
||||||
|
- Behavior analysis
|
||||||
|
- Predictive modeling
|
||||||
|
- Anomaly detection
|
||||||
|
|
||||||
|
### 2. Real-time Monitoring
|
||||||
|
|
||||||
|
- **Live Experiment Tracking**
|
||||||
|
- Real-time status updates
|
||||||
|
- Video streaming
|
||||||
|
- Sensor data visualization
|
||||||
|
- Remote control capabilities
|
||||||
|
|
||||||
|
- **Performance Monitoring**
|
||||||
|
- System metrics
|
||||||
|
- Robot status
|
||||||
|
- Network health
|
||||||
|
- Resource usage
|
||||||
|
|
||||||
|
- **Alert System**
|
||||||
|
- Custom alert rules
|
||||||
|
- Notification preferences
|
||||||
|
- Incident reporting
|
||||||
|
- Alert history
|
||||||
|
|
||||||
|
## Q4 2024
|
||||||
|
|
||||||
|
### 1. Advanced Authentication
|
||||||
|
|
||||||
|
- **Multi-factor Authentication**
|
||||||
|
- SMS verification
|
||||||
|
- Authenticator apps
|
||||||
|
- Hardware key support
|
||||||
|
- Biometric authentication
|
||||||
|
|
||||||
|
- **Single Sign-On**
|
||||||
|
- SAML integration
|
||||||
|
- OAuth providers
|
||||||
|
- Active Directory
|
||||||
|
- Custom IdP support
|
||||||
|
|
||||||
|
- **Enhanced Security**
|
||||||
|
- Audit logging
|
||||||
|
- Session management
|
||||||
|
- IP restrictions
|
||||||
|
- Rate limiting
|
||||||
|
|
||||||
|
### 2. Collaboration Tools
|
||||||
|
|
||||||
|
- **Team Management**
|
||||||
|
- Team hierarchies
|
||||||
|
- Resource sharing
|
||||||
|
- Permission inheritance
|
||||||
|
- Team analytics
|
||||||
|
|
||||||
|
- **Communication Features**
|
||||||
|
- In-app messaging
|
||||||
|
- Discussion boards
|
||||||
|
- File sharing
|
||||||
|
- Notification system
|
||||||
|
|
||||||
|
- **Knowledge Base**
|
||||||
|
- Documentation
|
||||||
|
- Best practices
|
||||||
|
- Troubleshooting guides
|
||||||
|
- Community forums
|
||||||
|
|
||||||
|
## 2025 and Beyond
|
||||||
|
|
||||||
|
### 1. AI Integration
|
||||||
|
|
||||||
|
- **Intelligent Assistance**
|
||||||
|
- Experiment suggestions
|
||||||
|
- Optimization recommendations
|
||||||
|
- Automated analysis
|
||||||
|
- Natural language processing
|
||||||
|
|
||||||
|
- **Predictive Features**
|
||||||
|
- Resource forecasting
|
||||||
|
- Behavior prediction
|
||||||
|
- Risk assessment
|
||||||
|
- Performance optimization
|
||||||
|
|
||||||
|
- **Automated Testing**
|
||||||
|
- Test case generation
|
||||||
|
- Regression testing
|
||||||
|
- Load testing
|
||||||
|
- Security scanning
|
||||||
|
|
||||||
|
### 2. Extended Platform Support
|
||||||
|
|
||||||
|
- **Mobile Applications**
|
||||||
|
- iOS app
|
||||||
|
- Android app
|
||||||
|
- Responsive web
|
||||||
|
- Cross-platform sync
|
||||||
|
|
||||||
|
- **Additional Robot Platforms**
|
||||||
|
- ROS1 support
|
||||||
|
- Custom protocols
|
||||||
|
- Hardware abstraction
|
||||||
|
- Simulator integration
|
||||||
|
|
||||||
|
- **Cloud Integration**
|
||||||
|
- Multi-cloud support
|
||||||
|
- Edge computing
|
||||||
|
- Data replication
|
||||||
|
- Disaster recovery
|
||||||
|
|
||||||
|
## Technical Improvements
|
||||||
|
|
||||||
|
### 1. Performance Optimization
|
||||||
|
|
||||||
|
- **Frontend**
|
||||||
|
- Bundle optimization
|
||||||
|
- Code splitting
|
||||||
|
- Lazy loading
|
||||||
|
- Service workers
|
||||||
|
|
||||||
|
- **Backend**
|
||||||
|
- Query optimization
|
||||||
|
- Caching strategies
|
||||||
|
- Load balancing
|
||||||
|
- Database sharding
|
||||||
|
|
||||||
|
- **Infrastructure**
|
||||||
|
- Container orchestration
|
||||||
|
- Auto-scaling
|
||||||
|
- CDN integration
|
||||||
|
- Geographic distribution
|
||||||
|
|
||||||
|
### 2. Developer Experience
|
||||||
|
|
||||||
|
- **Development Tools**
|
||||||
|
- CLI improvements
|
||||||
|
- Debug tools
|
||||||
|
- Testing utilities
|
||||||
|
- Documentation generator
|
||||||
|
|
||||||
|
- **Code Quality**
|
||||||
|
- Automated testing
|
||||||
|
- Code coverage
|
||||||
|
- Static analysis
|
||||||
|
- Performance profiling
|
||||||
|
|
||||||
|
- **Deployment**
|
||||||
|
- CI/CD enhancements
|
||||||
|
- Environment management
|
||||||
|
- Monitoring tools
|
||||||
|
- Rollback capabilities
|
||||||
|
|
||||||
|
## Research Integration
|
||||||
|
|
||||||
|
### 1. Academic Features
|
||||||
|
|
||||||
|
- **Publication Support**
|
||||||
|
- Data export
|
||||||
|
- Citation generation
|
||||||
|
- Figure creation
|
||||||
|
- Statistical analysis
|
||||||
|
|
||||||
|
- **Study Management**
|
||||||
|
- IRB integration
|
||||||
|
- Consent management
|
||||||
|
- Protocol tracking
|
||||||
|
- Data anonymization
|
||||||
|
|
||||||
|
- **Collaboration Tools**
|
||||||
|
- Institution management
|
||||||
|
- Grant tracking
|
||||||
|
- Resource sharing
|
||||||
|
- Publication tracking
|
||||||
|
|
||||||
|
### 2. Industry Integration
|
||||||
|
|
||||||
|
- **Enterprise Features**
|
||||||
|
- SLA management
|
||||||
|
- Compliance reporting
|
||||||
|
- Asset tracking
|
||||||
|
- Cost analysis
|
||||||
|
|
||||||
|
- **Integration Options**
|
||||||
|
- API expansion
|
||||||
|
- Custom connectors
|
||||||
|
- Data pipeline
|
||||||
|
- Workflow automation
|
||||||
|
|
||||||
|
- **Support Services**
|
||||||
|
- Training programs
|
||||||
|
- Technical support
|
||||||
|
- Consulting services
|
||||||
|
- Custom development
|
||||||
|
|
||||||
|
## Timeline and Milestones
|
||||||
|
|
||||||
|
### Q2 2024
|
||||||
|
- Plugin marketplace beta release
|
||||||
|
- Advanced experiment designer features
|
||||||
|
- Initial analytics dashboard
|
||||||
|
|
||||||
|
### Q3 2024
|
||||||
|
- Real-time monitoring system
|
||||||
|
- Data analysis tools
|
||||||
|
- Machine learning integration beta
|
||||||
|
|
||||||
|
### Q4 2024
|
||||||
|
- Multi-factor authentication
|
||||||
|
- Team collaboration tools
|
||||||
|
- Knowledge base launch
|
||||||
|
|
||||||
|
### 2025
|
||||||
|
- AI assistant beta
|
||||||
|
- Mobile applications
|
||||||
|
- Extended platform support
|
||||||
|
- Research integration features
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
1. **User Engagement**
|
||||||
|
- Active users
|
||||||
|
- Feature adoption
|
||||||
|
- User satisfaction
|
||||||
|
- Time spent in platform
|
||||||
|
|
||||||
|
2. **Platform Growth**
|
||||||
|
- Number of studies
|
||||||
|
- Plugin ecosystem
|
||||||
|
- API usage
|
||||||
|
- Community growth
|
||||||
|
|
||||||
|
3. **Technical Performance**
|
||||||
|
- System uptime
|
||||||
|
- Response times
|
||||||
|
- Error rates
|
||||||
|
- Resource utilization
|
||||||
|
|
||||||
|
4. **Research Impact**
|
||||||
|
- Published studies
|
||||||
|
- Citations
|
||||||
|
- Collaborations
|
||||||
|
- Grant success
|
||||||
119
docs/plan.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# HRIStudio Development Plan
|
||||||
|
|
||||||
|
## Immediate Goal: Paper Submission (1 Month)
|
||||||
|
Focus on delivering a functional experiment designer that demonstrates the platform's capabilities for Wizard-of-Oz HRI studies.
|
||||||
|
|
||||||
|
### 1. Experiment Designer Core
|
||||||
|
- [x] Basic flow-based designer UI
|
||||||
|
- [ ] Step containers with drag-and-drop, that can contain sets of actions
|
||||||
|
- [ ] Action node system
|
||||||
|
- [ ] Action schema definition
|
||||||
|
- [ ] Visual node editor
|
||||||
|
- [ ] Connection validation
|
||||||
|
- [ ] Parameter configuration UI
|
||||||
|
|
||||||
|
### 2. Plugin System
|
||||||
|
- [x] Plugin store infrastructure
|
||||||
|
- [x] Basic plugin loading mechanism
|
||||||
|
- [ ] Action Libraries
|
||||||
|
- [ ] Wizard Actions
|
||||||
|
- [ ] Robot movement control
|
||||||
|
- [ ] Speech synthesis
|
||||||
|
- [ ] Gesture control
|
||||||
|
- [ ] TurtleBot3 Integration
|
||||||
|
- [ ] ROS2 message types
|
||||||
|
- [ ] Movement actions
|
||||||
|
- [ ] Sensor feedback
|
||||||
|
- [ ] Experiment Flow
|
||||||
|
- [ ] Timing controls
|
||||||
|
- [ ] Wait conditions
|
||||||
|
- [ ] Participant input handling
|
||||||
|
- [ ] Data recording triggers
|
||||||
|
|
||||||
|
### 3. Execution Engine
|
||||||
|
- [ ] Step execution pipeline
|
||||||
|
- [ ] Action validation
|
||||||
|
- [ ] Real-time monitoring
|
||||||
|
- [ ] Data collection
|
||||||
|
- [ ] Action logs
|
||||||
|
- [ ] Timing data
|
||||||
|
- [ ] Participant responses
|
||||||
|
|
||||||
|
## Future Extensions
|
||||||
|
|
||||||
|
### 1. Enhanced Plugin Ecosystem
|
||||||
|
- Community plugin repository
|
||||||
|
- Plugin versioning and compatibility
|
||||||
|
- Custom action development tools
|
||||||
|
|
||||||
|
### 2. Advanced Experiment Features
|
||||||
|
- Conditional branching
|
||||||
|
- Dynamic parameter adjustment
|
||||||
|
- Multi-robot coordination
|
||||||
|
- Real-time visualization
|
||||||
|
|
||||||
|
### 3. Data Analysis Tools
|
||||||
|
- Session replay
|
||||||
|
- Data export
|
||||||
|
- Analysis templates
|
||||||
|
- Visualization tools
|
||||||
|
|
||||||
|
## Technical Requirements
|
||||||
|
|
||||||
|
### Action Schema
|
||||||
|
```typescript
|
||||||
|
interface ActionDefinition {
|
||||||
|
actionId: string;
|
||||||
|
type: ActionType;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
parameters: {
|
||||||
|
type: "object";
|
||||||
|
properties: Record<string, {
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
default?: any;
|
||||||
|
minimum?: number;
|
||||||
|
maximum?: number;
|
||||||
|
enum?: string[];
|
||||||
|
unit?: string;
|
||||||
|
}>;
|
||||||
|
required: string[];
|
||||||
|
};
|
||||||
|
ros2?: {
|
||||||
|
messageType: string;
|
||||||
|
topic?: string;
|
||||||
|
service?: string;
|
||||||
|
action?: string;
|
||||||
|
payloadMapping: {
|
||||||
|
type: "direct" | "transform";
|
||||||
|
map?: Record<string, string>;
|
||||||
|
transformFn?: string;
|
||||||
|
};
|
||||||
|
qos?: {
|
||||||
|
reliability: "reliable" | "best_effort";
|
||||||
|
durability: "volatile" | "transient_local";
|
||||||
|
history: "keep_last" | "keep_all";
|
||||||
|
depth?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Plugin Structure
|
||||||
|
```
|
||||||
|
plugin-name/
|
||||||
|
├── plugin.json # Plugin metadata and action definitions
|
||||||
|
├── transforms.ts # Custom transform functions
|
||||||
|
├── validators.ts # Parameter validation
|
||||||
|
└── assets/ # Icons and documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Priority
|
||||||
|
1. Core action system and visual editor
|
||||||
|
2. Basic wizard actions (movement, speech)
|
||||||
|
3. TurtleBot3 integration
|
||||||
|
4. Flow control actions
|
||||||
|
5. Data collection
|
||||||
|
6. Analysis tools
|
||||||
371
docs/plugin-store.md
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
# Plugin Store System
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Plugin Store is a core feature of HRIStudio that manages robot plugins, their repositories, and their integration into the platform. It provides a robust system for loading, validating, and utilizing robot plugins within experiments.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class PluginStore {
|
||||||
|
private plugins: Map<string, RobotPlugin> = new Map();
|
||||||
|
private repositories: Map<string, RepositoryMetadata> = new Map();
|
||||||
|
private transformFunctions: Map<string, Function> = new Map();
|
||||||
|
private pluginToRepo: Map<string, string> = new Map();
|
||||||
|
private lastRefresh: Map<string, number> = new Map();
|
||||||
|
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Plugin Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface RobotPlugin {
|
||||||
|
robotId: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
platform: string;
|
||||||
|
version: string;
|
||||||
|
manufacturer: {
|
||||||
|
name: string;
|
||||||
|
website?: string;
|
||||||
|
support?: string;
|
||||||
|
};
|
||||||
|
documentation: {
|
||||||
|
mainUrl: string;
|
||||||
|
apiReference?: string;
|
||||||
|
wikiUrl?: string;
|
||||||
|
videoUrl?: string;
|
||||||
|
};
|
||||||
|
assets: {
|
||||||
|
thumbnailUrl: string;
|
||||||
|
images: {
|
||||||
|
main: string;
|
||||||
|
angles?: {
|
||||||
|
front?: string;
|
||||||
|
side?: string;
|
||||||
|
top?: string;
|
||||||
|
};
|
||||||
|
dimensions?: string;
|
||||||
|
};
|
||||||
|
model?: {
|
||||||
|
format: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
specs: {
|
||||||
|
dimensions: {
|
||||||
|
length: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
weight: number;
|
||||||
|
};
|
||||||
|
capabilities: string[];
|
||||||
|
maxSpeed: number;
|
||||||
|
batteryLife: number;
|
||||||
|
};
|
||||||
|
actions: ActionDefinition[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Repository Management
|
||||||
|
|
||||||
|
### Loading Repositories
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async loadRepository(url: string): Promise<RepositoryMetadata> {
|
||||||
|
// Clean URL
|
||||||
|
const cleanUrl = url.trim().replace(/\/$/, "");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch repository metadata
|
||||||
|
const metadataUrl = this.getRepositoryFileUrl(cleanUrl, "repository.json");
|
||||||
|
const response = await fetch(metadataUrl);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch repository metadata: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = await response.json();
|
||||||
|
// Validate and process metadata
|
||||||
|
return metadata;
|
||||||
|
} catch (error) {
|
||||||
|
throw new PluginLoadError("Failed to load repository", undefined, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Repository Metadata
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface RepositoryMetadata {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
url: string;
|
||||||
|
official: boolean;
|
||||||
|
author: {
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
url?: string;
|
||||||
|
organization?: string;
|
||||||
|
};
|
||||||
|
maintainers?: Array<{
|
||||||
|
name: string;
|
||||||
|
url?: string;
|
||||||
|
}>;
|
||||||
|
compatibility: {
|
||||||
|
hristudio: {
|
||||||
|
min: string;
|
||||||
|
recommended?: string;
|
||||||
|
};
|
||||||
|
ros2?: {
|
||||||
|
distributions: string[];
|
||||||
|
recommended?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
stats: {
|
||||||
|
downloads: number;
|
||||||
|
stars: number;
|
||||||
|
plugins: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Plugin Loading & Validation
|
||||||
|
|
||||||
|
### Loading Process
|
||||||
|
|
||||||
|
1. **Repository Metadata:**
|
||||||
|
```typescript
|
||||||
|
private async loadRepositoryPlugins(repository: RepositoryMetadata) {
|
||||||
|
const metadataUrl = this.getRepositoryFileUrl(
|
||||||
|
repository.url,
|
||||||
|
"repository.json"
|
||||||
|
);
|
||||||
|
// Fetch and validate metadata
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Individual Plugins:**
|
||||||
|
```typescript
|
||||||
|
async loadPluginFromJson(jsonString: string): Promise<RobotPlugin> {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(jsonString);
|
||||||
|
return await this.validatePlugin(data);
|
||||||
|
} catch (error) {
|
||||||
|
throw new PluginLoadError(
|
||||||
|
"Failed to parse plugin JSON",
|
||||||
|
undefined,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
private async validatePlugin(data: unknown): Promise<RobotPlugin> {
|
||||||
|
try {
|
||||||
|
return robotPluginSchema.parse(data);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
throw new PluginLoadError(
|
||||||
|
`Invalid plugin format: ${error.errors.map(e => e.message).join(", ")}`,
|
||||||
|
undefined,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Action System
|
||||||
|
|
||||||
|
### Action Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type ActionType =
|
||||||
|
| "move" // Robot movement
|
||||||
|
| "speak" // Robot speech
|
||||||
|
| "wait" // Wait for a duration
|
||||||
|
| "input" // Wait for user input
|
||||||
|
| "gesture" // Robot gesture
|
||||||
|
| "record" // Start/stop recording
|
||||||
|
| "condition" // Conditional branching
|
||||||
|
| "loop"; // Repeat actions
|
||||||
|
```
|
||||||
|
|
||||||
|
### Action Definition
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ActionDefinition {
|
||||||
|
actionId: string;
|
||||||
|
type: ActionType;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
parameters: {
|
||||||
|
type: "object";
|
||||||
|
properties: Record<string, ParameterProperty>;
|
||||||
|
required: string[];
|
||||||
|
};
|
||||||
|
ros2?: {
|
||||||
|
messageType: string;
|
||||||
|
topic?: string;
|
||||||
|
service?: string;
|
||||||
|
action?: string;
|
||||||
|
payloadMapping: {
|
||||||
|
type: "direct" | "transform";
|
||||||
|
transformFn?: string;
|
||||||
|
};
|
||||||
|
qos?: QoSSettings;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transform Functions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
private transformToTwist(params: { linear: number; angular: number }) {
|
||||||
|
return {
|
||||||
|
linear: {
|
||||||
|
x: params.linear,
|
||||||
|
y: 0.0,
|
||||||
|
z: 0.0
|
||||||
|
},
|
||||||
|
angular: {
|
||||||
|
x: 0.0,
|
||||||
|
y: 0.0,
|
||||||
|
z: params.angular
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Caching & Performance
|
||||||
|
|
||||||
|
### Cache Implementation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
private shouldRefreshCache(repositoryId: string): boolean {
|
||||||
|
const lastRefresh = this.lastRefresh.get(repositoryId);
|
||||||
|
if (!lastRefresh) return true;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
return now - lastRefresh > this.CACHE_TTL;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class PluginLoadError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public robotId?: string,
|
||||||
|
public cause?: unknown
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "PluginLoadError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Loading a Repository
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const store = new PluginStore();
|
||||||
|
await store.loadRepository("https://github.com/org/robot-plugins");
|
||||||
|
```
|
||||||
|
|
||||||
|
### Getting Plugin Information
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const plugin = store.getPlugin("turtlebot3-burger");
|
||||||
|
if (plugin) {
|
||||||
|
console.log(`Loaded ${plugin.name} version ${plugin.version}`);
|
||||||
|
console.log(`Supported actions: ${plugin.actions.length}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registering Transform Functions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
store.registerTransformFunction("transformToTwist", (params) => {
|
||||||
|
// Custom transformation logic
|
||||||
|
return transformedData;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Error Handling:**
|
||||||
|
- Always catch and properly handle plugin loading errors
|
||||||
|
- Provide meaningful error messages
|
||||||
|
- Include error context when possible
|
||||||
|
|
||||||
|
2. **Validation:**
|
||||||
|
- Validate all plugin metadata
|
||||||
|
- Verify action parameters
|
||||||
|
- Check compatibility requirements
|
||||||
|
|
||||||
|
3. **Performance:**
|
||||||
|
- Use caching appropriately
|
||||||
|
- Implement lazy loading where possible
|
||||||
|
- Monitor memory usage
|
||||||
|
|
||||||
|
4. **Security:**
|
||||||
|
- Validate URLs and file paths
|
||||||
|
- Implement proper access controls
|
||||||
|
- Sanitize plugin inputs
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
1. **Plugin Versioning:**
|
||||||
|
- Semantic versioning support
|
||||||
|
- Version compatibility checking
|
||||||
|
- Update management
|
||||||
|
|
||||||
|
2. **Advanced Caching:**
|
||||||
|
- Persistent cache storage
|
||||||
|
- Cache invalidation strategies
|
||||||
|
- Partial cache updates
|
||||||
|
|
||||||
|
3. **Plugin Marketplace:**
|
||||||
|
- User ratings and reviews
|
||||||
|
- Download statistics
|
||||||
|
- Community contributions
|
||||||
|
|
||||||
|
4. **Enhanced Validation:**
|
||||||
|
- Runtime validation
|
||||||
|
- Performance benchmarking
|
||||||
|
- Compatibility testing
|
||||||
|
|
||||||
|
## Dynamic Plugin Loading
|
||||||
|
|
||||||
|
The plugin store in HRI Studio supports modular loading of robot actions. Not every robot action is installed by default; instead, only the necessary plugins for the desired robots are installed. This approach offers several benefits:
|
||||||
|
|
||||||
|
- Flexibility: Deploy only the robots and actions you need.
|
||||||
|
- Performance: Avoid loading unnecessary modules, leading to faster startup times and reduced memory usage.
|
||||||
|
- Extensibility: Allow companies and users to host their own plugin repositories with custom robot actions.
|
||||||
|
|
||||||
|
### Implementation Details
|
||||||
|
|
||||||
|
1. Each plugin should export a manifest adhering to the `RobotPlugin` interface, containing a unique identifier, display name, and a list of actions.
|
||||||
|
2. The system loads only the configured plugins, which can be managed via environment variables, a database table, or an admin interface.
|
||||||
|
3. Dynamic imports are used in the Next.js server environment to load robot actions on demand. For example:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async function loadPlugin(pluginUrl: string): Promise<RobotPlugin> {
|
||||||
|
const pluginModule = await import(pluginUrl);
|
||||||
|
return pluginModule.default as RobotPlugin;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This design ensures that HRI Studio remains lean and agile, seamlessly integrating new robot actions without overhead.
|
||||||
1
docs/root.tex
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/Users/soconnor/Projects/csci378/hristudio-sp2025/root.tex
|
||||||
169
docs/structure.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# HRIStudio Structure and Requirements
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
A *study* is a general term for a research project.
|
||||||
|
|
||||||
|
An *experiment* is a specific set of steps and actions that will be conducted with a participant and robot. Experiments are designed and configured via a dedicated drag and drop experiment designer. This interactive designer features a dotted background—similar to Unreal Engine's IDE drag and drop area—that clearly indicates drop zones. Users can add, reorder, and connect individual steps and actions visually.
|
||||||
|
|
||||||
|
An *trial* is a specific instance of an experiment. It is a single run of the experiment with a specific participant and robot.
|
||||||
|
|
||||||
|
A *step* is a general term for something that is being done in the experiment. It is represented as a collection of actions that are being done in a specific order.
|
||||||
|
|
||||||
|
An *action* is a specific operation that is being done (like "move to position", "press button", "say something", etc.) These are the smallest atomic units of the platform.
|
||||||
|
|
||||||
|
A *participant* is a person that has been added to a study. This person does not have an account.
|
||||||
|
|
||||||
|
A *user* is a person that has an account, which is a person that has been added to a study. Anyone can sign up for an account, but they must be added to a study or create their own. A user can have different roles in different studies.
|
||||||
|
|
||||||
|
## Experiment Design and Implementation
|
||||||
|
|
||||||
|
Experiments are central to HRIStudio and are managed with full CRUD operations. The Experiment Design feature includes:
|
||||||
|
|
||||||
|
- **Drag and Drop Designer:** An interactive design area with a dotted background, reminiscent of Unreal Engine's IDE, which allows users to visually add, reposition, and connect steps and actions. The designer includes:
|
||||||
|
- A dotted grid background that provides visual cues for alignment and spacing
|
||||||
|
- Highlighted drop zones that activate when dragging components
|
||||||
|
- Visual feedback for valid/invalid drop targets
|
||||||
|
- Smooth animations for reordering and nesting
|
||||||
|
- Connection lines showing relationships between steps
|
||||||
|
- A side panel of available actions that can be dragged into steps
|
||||||
|
- **Experiment Templates:** The ability to save and reuse experiment configurations.
|
||||||
|
- **CRUD Operations:** Procedures to create, retrieve, update, and delete experiments associated with a study.
|
||||||
|
- **Dynamic Interaction:** Support for adding and reordering steps, and nesting actions within steps.
|
||||||
|
|
||||||
|
## Roles and Permissions
|
||||||
|
|
||||||
|
### Core Roles
|
||||||
|
|
||||||
|
1. **Owner**
|
||||||
|
- Single owner per study
|
||||||
|
- Full control over all aspects of the study
|
||||||
|
- Can delete study or transfer ownership
|
||||||
|
- Can manage all other roles
|
||||||
|
- Usually the study creator or designated successor
|
||||||
|
- Cannot be removed except through ownership transfer
|
||||||
|
|
||||||
|
2. **Admin**
|
||||||
|
- Multiple admins allowed
|
||||||
|
- Can manage participants, experiments, and study settings
|
||||||
|
- Can invite and manage other users (except Owner)
|
||||||
|
- Cannot delete study or transfer ownership
|
||||||
|
- Appointed by Owner
|
||||||
|
|
||||||
|
3. **Principal Investigator (PI)**
|
||||||
|
- Scientific oversight role
|
||||||
|
- Full access to participant data and experiment design
|
||||||
|
- Can manage experiment protocols
|
||||||
|
- Can analyze and export all data
|
||||||
|
- Cannot modify core study settings or manage user roles
|
||||||
|
- Typically one PI per study
|
||||||
|
|
||||||
|
4. **Wizard**
|
||||||
|
- Operates the robot during experiment trials
|
||||||
|
- Can control live experiment sessions
|
||||||
|
- Can view anonymized participant data
|
||||||
|
- Can annotate experiments in real-time
|
||||||
|
- Cannot modify study design or access sensitive participant data
|
||||||
|
- Multiple wizards allowed
|
||||||
|
|
||||||
|
5. **Researcher**
|
||||||
|
- Can view and analyze experiment data
|
||||||
|
- Can access anonymized participant information
|
||||||
|
- Can export and analyze results
|
||||||
|
- Cannot modify study design or participant data
|
||||||
|
- Cannot run experiment trials
|
||||||
|
- Multiple researchers allowed
|
||||||
|
|
||||||
|
6. **Observer**
|
||||||
|
- Can view live experiments
|
||||||
|
- Can view anonymized participant data
|
||||||
|
- Can add annotations
|
||||||
|
- Cannot modify any study aspects
|
||||||
|
- Cannot access sensitive data
|
||||||
|
- Multiple observers allowed
|
||||||
|
|
||||||
|
### Permission Categories
|
||||||
|
|
||||||
|
1. **Study Management**
|
||||||
|
- Create/Delete Study (Owner only)
|
||||||
|
- Edit Study Settings
|
||||||
|
- Transfer Ownership (Owner only)
|
||||||
|
- Manage Study Metadata
|
||||||
|
|
||||||
|
2. **Participant Management**
|
||||||
|
- Add/Remove Participants
|
||||||
|
- View Participant Details (identifiable vs. anonymized)
|
||||||
|
- Edit Participant Information
|
||||||
|
- Manage Participant Consent Forms
|
||||||
|
|
||||||
|
3. **Experiment Design**
|
||||||
|
- Create/Edit Experiment Templates
|
||||||
|
- Define Steps and Actions
|
||||||
|
- Set Robot Behaviors
|
||||||
|
- Configure Data Collection
|
||||||
|
|
||||||
|
4. **Experiment Execution**
|
||||||
|
- Run Experiment Trials
|
||||||
|
- Control Robot Actions
|
||||||
|
- Monitor Live Sessions
|
||||||
|
- Add Real-time Annotations
|
||||||
|
|
||||||
|
5. **Data Access**
|
||||||
|
- View Raw Data
|
||||||
|
- View Anonymized Data
|
||||||
|
- Export Data
|
||||||
|
- Access Participant Identifiable Information
|
||||||
|
|
||||||
|
6. **User Management**
|
||||||
|
- Invite Users
|
||||||
|
- Assign Roles
|
||||||
|
- Remove Users
|
||||||
|
- Manage Permissions
|
||||||
|
|
||||||
|
### Role-Permission Matrix
|
||||||
|
|
||||||
|
| Permission Category | Owner | Admin | PI | Wizard | Researcher | Observer |
|
||||||
|
|-----------------------|-------|-------|-----|--------|------------|----------|
|
||||||
|
| Study Management | Full | Most | No | No | No | No |
|
||||||
|
| Participant Management| Full | Full | Full| Limited| Limited | View Only|
|
||||||
|
| Experiment Design | Full | Full | Full| No | No | No |
|
||||||
|
| Experiment Execution | Full | Full | Full| Full | View Only | View Only|
|
||||||
|
| Data Access | Full | Full | Full| Limited| Limited | Limited |
|
||||||
|
| User Management | Full | Most | No | No | No | No |
|
||||||
|
|
||||||
|
### Special Considerations
|
||||||
|
|
||||||
|
1. **Data Privacy**
|
||||||
|
- Identifiable participant information is only accessible to Owner, Admin, and PI roles
|
||||||
|
- All other roles see anonymized data
|
||||||
|
- Audit logs track all data access
|
||||||
|
|
||||||
|
2. **Role Hierarchy**
|
||||||
|
- Owner > Admin > PI > Wizard/Researcher > Observer
|
||||||
|
- Higher roles inherit permissions from lower roles
|
||||||
|
- Certain permissions (like study deletion) are restricted to specific roles
|
||||||
|
|
||||||
|
3. **Role Assignment**
|
||||||
|
- Users can have different roles in different studies
|
||||||
|
- One user cannot hold multiple roles in the same study
|
||||||
|
- Role changes are logged and require appropriate permissions
|
||||||
|
|
||||||
|
Participant Management: can create, update, delete participants, as well as view their personal information
|
||||||
|
- Admin: can do everything
|
||||||
|
- Principal Investigator: can do everything
|
||||||
|
- Wizard: can view participants, but cannot view their personal information
|
||||||
|
- Researcher: can view participants, but cannot view their personal information
|
||||||
|
|
||||||
|
Experiment Management: can create, update, delete experiments, as well as view their data and results.
|
||||||
|
|
||||||
|
- Admin: Can do everything
|
||||||
|
- Principal Investigator: Can do everything
|
||||||
|
- Wizard: Runs experiment trials, can view results
|
||||||
|
- Researcher: Can view results
|
||||||
|
|
||||||
|
Experiment Design: can create, update, delete steps and actions, as well as specify general parameters for the experiment.
|
||||||
|
|
||||||
|
- Admin: Can do everything
|
||||||
|
- Principal Investigator: Can do everything
|
||||||
|
- Wizard: Can create, update, delete steps and actions, as well as specify general parameters for the experiment
|
||||||
|
- Researcher: Can view steps and actions.
|
||||||
346
docs/ui-design.md
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
# UI Design & User Experience
|
||||||
|
|
||||||
|
## Design System
|
||||||
|
|
||||||
|
### Color System
|
||||||
|
|
||||||
|
Our color system is defined in CSS variables with both light and dark mode variants:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
/* Core colors */
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222 47% 11%;
|
||||||
|
|
||||||
|
/* Primary colors */
|
||||||
|
--primary: 217 91% 60%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
|
||||||
|
/* Card colors */
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222 47% 11%;
|
||||||
|
|
||||||
|
/* Additional semantic colors */
|
||||||
|
--muted: 210 40% 96%;
|
||||||
|
--muted-foreground: 215 16% 47%;
|
||||||
|
--accent: 210 40% 96%;
|
||||||
|
--accent-foreground: 222 47% 11%;
|
||||||
|
|
||||||
|
/* ... additional color definitions ... */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222 47% 11%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
/* ... dark mode variants ... */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
We use the Geist font family for its clean, modern appearance:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { GeistSans } from 'geist/font/sans';
|
||||||
|
|
||||||
|
<body className={cn(
|
||||||
|
"min-h-screen bg-background font-sans antialiased",
|
||||||
|
GeistSans.className
|
||||||
|
)}>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Spacing System
|
||||||
|
|
||||||
|
Consistent spacing using Tailwind's scale:
|
||||||
|
|
||||||
|
- `space-1`: 0.25rem (4px)
|
||||||
|
- `space-2`: 0.5rem (8px)
|
||||||
|
- `space-4`: 1rem (16px)
|
||||||
|
- `space-6`: 1.5rem (24px)
|
||||||
|
- `space-8`: 2rem (32px)
|
||||||
|
|
||||||
|
## Component Architecture
|
||||||
|
|
||||||
|
### Base Components
|
||||||
|
|
||||||
|
All base components are built on Radix UI primitives and styled with Tailwind:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Example Button Component
|
||||||
|
const Button = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
ButtonProps
|
||||||
|
>(({ className, variant, size, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2",
|
||||||
|
"disabled:opacity-50 disabled:pointer-events-none",
|
||||||
|
buttonVariants({ variant, size, className })
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layout Components
|
||||||
|
|
||||||
|
#### Page Layout
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function PageLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-screen w-full">
|
||||||
|
<AppSidebar />
|
||||||
|
<div className="flex w-0 flex-1 flex-col">
|
||||||
|
<Header />
|
||||||
|
<main className="flex-1 overflow-auto p-4">
|
||||||
|
<PageTransition>
|
||||||
|
{children}
|
||||||
|
</PageTransition>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Sidebar Navigation
|
||||||
|
|
||||||
|
The sidebar uses a floating design with dynamic content based on context:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function AppSidebar({ ...props }: SidebarProps) {
|
||||||
|
return (
|
||||||
|
<Sidebar
|
||||||
|
collapsible="icon"
|
||||||
|
variant="floating"
|
||||||
|
className="border-none"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SidebarHeader>
|
||||||
|
<StudySwitcher />
|
||||||
|
</SidebarHeader>
|
||||||
|
<SidebarContent>
|
||||||
|
<NavMain items={navItems} />
|
||||||
|
</SidebarContent>
|
||||||
|
<SidebarFooter>
|
||||||
|
<NavUser />
|
||||||
|
</SidebarFooter>
|
||||||
|
<SidebarRail />
|
||||||
|
</Sidebar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Form Components
|
||||||
|
|
||||||
|
Forms use React Hook Form with Zod validation:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const form = useForm<FormData>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: {
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Title</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{/* Additional form fields */}
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Responsive Design
|
||||||
|
|
||||||
|
### Breakpoints
|
||||||
|
|
||||||
|
We follow Tailwind's default breakpoints:
|
||||||
|
|
||||||
|
- `sm`: 640px
|
||||||
|
- `md`: 768px
|
||||||
|
- `lg`: 1024px
|
||||||
|
- `xl`: 1280px
|
||||||
|
- `2xl`: 1536px
|
||||||
|
|
||||||
|
### Mobile-First Approach
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function StudyCard({ study }: StudyCardProps) {
|
||||||
|
return (
|
||||||
|
<Card className="
|
||||||
|
w-full
|
||||||
|
p-4
|
||||||
|
sm:p-6
|
||||||
|
md:hover:shadow-lg
|
||||||
|
transition-all
|
||||||
|
duration-200
|
||||||
|
">
|
||||||
|
{/* Card content */}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Animation System
|
||||||
|
|
||||||
|
### Transition Utilities
|
||||||
|
|
||||||
|
Common transitions are defined in Tailwind config:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
transitionTimingFunction: {
|
||||||
|
'bounce-ease': 'cubic-bezier(0.68, -0.55, 0.265, 1.55)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Page Transitions
|
||||||
|
|
||||||
|
Using Framer Motion for smooth page transitions:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function PageTransition({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 20 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Loading States
|
||||||
|
|
||||||
|
### Skeleton Components
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function CardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<Skeleton className="h-7 w-[40%]" />
|
||||||
|
<Skeleton className="h-4 w-[60%]" />
|
||||||
|
<div className="pt-4">
|
||||||
|
<Skeleton className="h-4 w-[25%]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Loading Indicators
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function LoadingSpinner({ size = "default" }: { size?: "sm" | "default" | "lg" }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"animate-spin rounded-full border-2",
|
||||||
|
"border-background border-t-foreground",
|
||||||
|
{
|
||||||
|
"h-4 w-4": size === "sm",
|
||||||
|
"h-6 w-6": size === "default",
|
||||||
|
"h-8 w-8": size === "lg",
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
### ARIA Labels
|
||||||
|
|
||||||
|
All interactive components include proper ARIA labels:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function IconButton({ label, icon: Icon, ...props }: IconButtonProps) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
aria-label={label}
|
||||||
|
className="p-2 hover:bg-muted/50 rounded-full"
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
<span className="sr-only">{label}</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keyboard Navigation
|
||||||
|
|
||||||
|
Support for keyboard navigation in all interactive components:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function NavigationMenu() {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
role="navigation"
|
||||||
|
className="focus-within:outline-none"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
// Handle escape key
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Navigation items */}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Component Organization:**
|
||||||
|
- One component per file
|
||||||
|
- Clear prop interfaces
|
||||||
|
- Consistent file naming
|
||||||
|
|
||||||
|
2. **Style Organization:**
|
||||||
|
- Use Tailwind utility classes
|
||||||
|
- Extract common patterns to components
|
||||||
|
- Maintain consistent spacing
|
||||||
|
|
||||||
|
3. **Performance:**
|
||||||
|
- Lazy load non-critical components
|
||||||
|
- Use React.memo for expensive renders
|
||||||
|
- Implement proper loading states
|
||||||
|
|
||||||
|
4. **Accessibility:**
|
||||||
|
- Include ARIA labels
|
||||||
|
- Support keyboard navigation
|
||||||
|
- Maintain proper contrast ratios
|
||||||
|
|
||||||
|
5. **Testing:**
|
||||||
|
- Component unit tests
|
||||||
|
- Integration tests for flows
|
||||||
|
- Visual regression testing
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
import 'dotenv/config';
|
import { type Config } from "drizzle-kit";
|
||||||
import { config } from 'dotenv';
|
|
||||||
import { defineConfig } from 'drizzle-kit';
|
|
||||||
|
|
||||||
config({ path: '.env.local' });
|
import { env } from "~/env.mjs";
|
||||||
|
|
||||||
export default defineConfig({
|
export default {
|
||||||
out: './drizzle',
|
schema: "./src/server/db/schema.ts",
|
||||||
schema: './src/db/schema.ts',
|
out: "./drizzle",
|
||||||
dialect: 'postgresql',
|
dialect: "postgresql",
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: process.env.POSTGRES_URL!,
|
url: env.DATABASE_URL,
|
||||||
},
|
},
|
||||||
});
|
strict: false,
|
||||||
|
verbose: true,
|
||||||
|
migrations: {
|
||||||
|
table: "__drizzle_migrations",
|
||||||
|
schema: "public"
|
||||||
|
},
|
||||||
|
tablesFilter: ["hs_*"],
|
||||||
|
} satisfies Config;
|
||||||
|
|||||||
61
next.config.mjs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import "./src/env.mjs";
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const config = {
|
||||||
|
// Image configuration
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "**",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: "http",
|
||||||
|
hostname: "localhost",
|
||||||
|
port: "3000",
|
||||||
|
pathname: "/api/images/**",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dangerouslyAllowSVG: true,
|
||||||
|
contentDispositionType: 'attachment',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Package configuration
|
||||||
|
transpilePackages: ["postgres"],
|
||||||
|
|
||||||
|
// Enable experimental features
|
||||||
|
experimental: {
|
||||||
|
// Enable modern webpack features
|
||||||
|
webpackBuildWorker: true,
|
||||||
|
// Turbopack configuration (when using --turbo)
|
||||||
|
turbo: {
|
||||||
|
resolveAlias: {
|
||||||
|
// Handle server-only modules in Turbo
|
||||||
|
'server-only': 'server-only',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Webpack fallbacks (only used when not using Turbo)
|
||||||
|
webpack: (config, { isServer }) => {
|
||||||
|
if (!isServer) {
|
||||||
|
config.resolve.fallback = {
|
||||||
|
...config.resolve.fallback,
|
||||||
|
fs: false,
|
||||||
|
net: false,
|
||||||
|
tls: false,
|
||||||
|
crypto: false,
|
||||||
|
os: false,
|
||||||
|
path: false,
|
||||||
|
stream: false,
|
||||||
|
perf_hooks: false,
|
||||||
|
child_process: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
|
||||||
const nextConfig = {
|
|
||||||
// Ignore type errors due to problems with next.js and delete routes
|
|
||||||
typescript: {
|
|
||||||
ignoreBuildErrors: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = nextConfig
|
|
||||||
8724
package-lock.json
generated
Normal file
128
package.json
@@ -2,60 +2,104 @@
|
|||||||
"name": "hristudio",
|
"name": "hristudio",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"check": "next lint && tsc --noEmit",
|
||||||
"lint": "next lint",
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
|
"db:seed": "bun src/server/db/seed.ts",
|
||||||
|
"db:reset": "docker compose down -v && docker compose up -d && bun db:push && bun db:seed",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"db:seed": "tsx src/db/seed.ts",
|
"dev": "next dev --turbo",
|
||||||
"ngrok:start": "ngrok http --url=endless-pegasus-happily.ngrok-free.app 3000",
|
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||||
"db:drop": "tsx src/db/drop.ts",
|
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||||
"db:reset": "pnpm db:drop && pnpm db:push && pnpm db:seed",
|
"lint": "next lint",
|
||||||
"test:email": "tsx src/scripts/test-email.ts"
|
"lint:fix": "next lint --fix",
|
||||||
|
"preview": "next build && next start",
|
||||||
|
"start": "next start",
|
||||||
|
"docker:up": "colima start && docker compose up -d",
|
||||||
|
"docker:logs": "docker compose logs -f",
|
||||||
|
"docker:down": "docker compose down && colima stop",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clerk/nextjs": "^6.7.1",
|
"@auth/drizzle-adapter": "^1.7.4",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
"@aws-sdk/client-s3": "^3.735.0",
|
||||||
"@radix-ui/react-avatar": "^1.1.1",
|
"@aws-sdk/lib-storage": "^3.735.0",
|
||||||
"@radix-ui/react-dialog": "^1.1.2",
|
"@aws-sdk/s3-request-presigner": "^3.735.0",
|
||||||
"@radix-ui/react-icons": "^1.3.2",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@radix-ui/react-select": "^2.1.2",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@paralleldrive/cuid2": "^2.2.2",
|
||||||
"@radix-ui/react-tabs": "^1.1.1",
|
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||||
"@radix-ui/react-toast": "^1.2.2",
|
"@radix-ui/react-avatar": "^1.1.2",
|
||||||
"@types/nodemailer": "^6.4.17",
|
"@radix-ui/react-collapsible": "^1.1.2",
|
||||||
"@vercel/analytics": "^1.4.1",
|
"@radix-ui/react-dialog": "^1.1.5",
|
||||||
"@vercel/postgres": "^0.10.0",
|
"@radix-ui/react-dropdown-menu": "^2.1.5",
|
||||||
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
|
"@radix-ui/react-popover": "^1.1.5",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||||
|
"@radix-ui/react-select": "^2.1.5",
|
||||||
|
"@radix-ui/react-separator": "^1.1.1",
|
||||||
|
"@radix-ui/react-slider": "^1.2.2",
|
||||||
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
|
"@radix-ui/react-switch": "^1.1.3",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.3",
|
||||||
|
"@radix-ui/react-tooltip": "^1.1.7",
|
||||||
|
"@t3-oss/env-nextjs": "^0.10.1",
|
||||||
|
"@tanstack/react-query": "^5.50.0",
|
||||||
|
"@trpc/client": "^11.0.0-rc.446",
|
||||||
|
"@trpc/react-query": "^11.0.0-rc.446",
|
||||||
|
"@trpc/server": "^11.0.0-rc.446",
|
||||||
|
"@types/nodemailer": "^6.4.14",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.0.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dotenv": "^16.4.7",
|
"drizzle-orm": "^0.39.3",
|
||||||
"drizzle-orm": "^0.37.0",
|
"framer-motion": "^12.0.6",
|
||||||
"lucide-react": "^0.468.0",
|
"geist": "^1.3.1",
|
||||||
"next": "15.0.3",
|
"lucide-react": "^0.474.0",
|
||||||
"ngrok": "5.0.0-beta.2",
|
"next": "^15.1.7",
|
||||||
"nodemailer": "^6.9.16",
|
"next-auth": "^4.24.11",
|
||||||
"punycode": "^2.3.1",
|
"nodemailer": "^6.10.0",
|
||||||
|
"postgres": "^3.4.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"svix": "^1.42.0",
|
"react-easy-crop": "^5.2.0",
|
||||||
"tailwind-merge": "^2.5.5",
|
"react-hook-form": "^7.54.2",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"react-zoom-pan-pinch": "^3.7.0",
|
||||||
|
"reactflow": "^11.11.4",
|
||||||
|
"server-only": "^0.0.1",
|
||||||
|
"sonner": "^1.7.2",
|
||||||
|
"superjson": "^2.2.1",
|
||||||
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.1",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/react": "^18.3.13",
|
"@types/eslint": "^8.56.10",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/node": "^20.14.10",
|
||||||
"drizzle-kit": "^0.29.1",
|
"@types/react": "^18.3.3",
|
||||||
"eslint": "^9.16.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"eslint-config-next": "15.0.3",
|
"@typescript-eslint/eslint-plugin": "^8.1.0",
|
||||||
"postcss": "^8.4.49",
|
"@typescript-eslint/parser": "^8.1.0",
|
||||||
"tailwindcss": "^3.4.16",
|
"drizzle-kit": "^0.30.4",
|
||||||
"tsx": "^4.19.2",
|
"eslint": "^8.57.0",
|
||||||
"typescript": "^5.7.2"
|
"eslint-config-next": "^15.0.1",
|
||||||
|
"eslint-plugin-drizzle": "^0.2.3",
|
||||||
|
"postcss": "^8.4.39",
|
||||||
|
"prettier": "^3.3.2",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||||
|
"tailwindcss": "^3.4.3",
|
||||||
|
"typescript": "^5.5.3"
|
||||||
|
},
|
||||||
|
"ct3aMetadata": {
|
||||||
|
"initVersion": "7.38.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
Permissions:
|
|
||||||
|
|
||||||
Roles table, permissions table, roles_permissions table
|
|
||||||
user has a role, role has many permissions
|
|
||||||
user can have multiple roles
|
|
||||||
each role has many permissions, each action that the user can do is a permission
|
|
||||||
|
|
||||||
6101
pnpm-lock.yaml
generated
5
postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/** @type {import('postcss-load-config').Config} */
|
|
||||||
const config = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
4
prettier.config.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
|
||||||
|
export default {
|
||||||
|
plugins: ["prettier-plugin-tailwindcss"],
|
||||||
|
};
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 391 B |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB |
3
public/grid.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M1 0V100M21 0V100M41 0V100M61 0V100M81 0V100M0 1H100M0 21H100M0 41H100M0 61H100M0 81H100" stroke="currentColor" stroke-opacity="0.1" vector-effect="non-scaling-stroke"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 288 B |
|
Before Width: | Height: | Size: 2.0 MiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
223
public/root.tex
@@ -1,223 +0,0 @@
|
|||||||
% Standard Paper
|
|
||||||
\documentclass[letterpaper, 10 pt, conference]{ieeeconf}
|
|
||||||
|
|
||||||
% A4 Paper
|
|
||||||
%\documentclass[a4paper, 10pt, conference]{ieeeconf}
|
|
||||||
|
|
||||||
% Only needed for \thanks command
|
|
||||||
\IEEEoverridecommandlockouts
|
|
||||||
|
|
||||||
% Needed to meet printer requirements.
|
|
||||||
\overrideIEEEmargins
|
|
||||||
|
|
||||||
%In case you encounter the following error:
|
|
||||||
%Error 1010 The PDF file may be corrupt (unable to open PDF file) OR
|
|
||||||
%Error 1000 An error occurred while parsing a contents stream. Unable to analyze the PDF file.
|
|
||||||
%This is a known problem with pdfLaTeX conversion filter. The file cannot be opened with acrobat reader
|
|
||||||
%Please use one of the alternatives below to circumvent this error by uncommenting one or the other
|
|
||||||
%\pdfobjcompresslevel=0
|
|
||||||
%\pdfminorversion=4
|
|
||||||
|
|
||||||
% See the \addtolength command later in the file to balance the column lengths
|
|
||||||
% on the last page of the document
|
|
||||||
|
|
||||||
% The following packages can be found on http:\\www.ctan.org
|
|
||||||
\usepackage{graphicx} % for pdf, bitmapped graphics files
|
|
||||||
%\usepackage{epsfig} % for postscript graphics files
|
|
||||||
%\usepackage{mathptmx} % assumes new font selection scheme installed
|
|
||||||
%\usepackage{times} % assumes new font selection scheme installed
|
|
||||||
%\usepackage{amsmath} % assumes amsmath package installed
|
|
||||||
%\usepackage{amssymb} % assumes amsmath package installed
|
|
||||||
\usepackage{url}
|
|
||||||
\usepackage{float}
|
|
||||||
|
|
||||||
\hyphenation{analysis}
|
|
||||||
|
|
||||||
\title{\LARGE \bf HRIStudio: A Framework for Wizard-of-Oz Experiments in Human-Robot Interaction Studies}
|
|
||||||
|
|
||||||
\author{Sean O'Connor and L. Felipe Perrone$^{*}$
|
|
||||||
\thanks{$^{*}$Both authors are with the Department of Computer Science at
|
|
||||||
Bucknell University in Lewisburg, PA, USA. They can be reached at {\tt\small sso005@bucknell.edu} and {\tt\small perrone@bucknell.edu}}%
|
|
||||||
}
|
|
||||||
|
|
||||||
\begin{document}
|
|
||||||
|
|
||||||
\maketitle
|
|
||||||
\thispagestyle{empty}
|
|
||||||
\pagestyle{empty}
|
|
||||||
|
|
||||||
|
|
||||||
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
||||||
\begin{abstract}
|
|
||||||
|
|
||||||
Human-robot interaction (HRI) research plays a pivotal role in shaping how robots communicate and collaborate with humans. However, conducting HRI studies, particularly those employing the Wizard-of-Oz (WoZ) technique, can be challenging. WoZ user studies can have complexities at the technical and methodological levels that may render the results irreproducible. We propose to address these challenges with HRIStudio, a novel web-based platform designed to streamline the design, execution, and analysis of WoZ experiments. HRIStudio offers an intuitive interface for experiment creation, real-time control and monitoring during experimental runs, and comprehensive data logging and playback tools for analysis and reproducibility. By lowering technical barriers, promoting collaboration, and offering methodological guidelines, HRIStudio aims to make human-centered robotics research easier, and at the same time, empower researchers to develop scientifically rigorous user studies.
|
|
||||||
|
|
||||||
\end{abstract}
|
|
||||||
|
|
||||||
|
|
||||||
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
||||||
|
|
||||||
%% TODO: Update mockup pictures with photo of subject and robot
|
|
||||||
|
|
||||||
\section{Introduction}
|
|
||||||
|
|
||||||
Human-robot interaction (HRI) is an essential field of study for understanding how robots should communicate, collaborate, and coexist with people. The development of autonomous behaviors in social robot applications, however, offers a number of challenges. The Wizard-of-Oz (WoZ) technique has emerged as a valuable experimental paradigm to address these difficulties, as it allows experimenters to simulate a robot's autonomous behaviors. With WoZ, a human operator (the \emph{``wizard''}) can operate the robot remotely, essentially simulating its autonomous behavior during user studies. This enables the rapid prototyping and continuous refinement of human-robot interactions postponing to later the full development of complex robot behaviors.
|
|
||||||
|
|
||||||
While WoZ is a powerful paradigm, it does not eliminate all experimental challenges. Researchers may face barriers related to the use of specialized tools and methodologies involved in WoZ user studies and also find difficulties in creating fully reproducible experiments. Existing solutions often rely on low-level robot operating systems, limited proprietary platforms, or require extensive custom coding, which can restrict their use to domain experts with extensive technical backgrounds.
|
|
||||||
|
|
||||||
Through a comprehensive review of current literature, we have identified a pressing need for a platform that simplifies the process of designing, executing, analyzing, and recording WoZ-based user studies. To address this gap, we are developing \emph{HRIStudio}, a novel web-based platform that enables the intuitive configuration and operation of WoZ studies for HRI research. Our contribution leverages the \emph{Robot Operating System} (ROS) to handle the complexities of interfacing with different robotics platforms. HRIStudio presents users with a high-level, user-friendly interface for experimental design, live control and monitoring during execution runs (which we call \emph{live experiment sessions}), and comprehensive post-study analysis. The system offers drag-and-drop visual programming for describing experiments without extensive coding, real-time control and observation capabilities during live experiment sessions, as well as comprehensive data logging and playback tools for analysis and enhanced reproducibility. We expect that with these features, HRIStudio will make the application of the WoZ paradigm more systematic thereby increasing the scientific rigor of this type of HRI experiment. The following sections present a brief review of the relevant literature, outline the design of HRIStudio and its experimental workflow, and offer implementation details and future directions for this work.
|
|
||||||
|
|
||||||
\section{State-of-the-Art}
|
|
||||||
|
|
||||||
The importance of the WoZ paradigm for user studies in social robotics is illustrated by the several frameworks that have been developed to support it. We describe some of the most notable as follows.
|
|
||||||
|
|
||||||
\emph{Polonius}~\cite{Lu2011}, which is based on the modular ROS platform, offers a graphical user interface for wizards to define finite state machine scripts that drive the behavior of robots during experiments. \emph{NottReal}~\cite{Porcheron2020} was designed for WoZ studies of voice user interfaces. It provides scripting capabilities and visual feedback to simulate autonomous behavior for participants. \emph{WoZ4U}~\cite{Rietz2021} presents a user-friendly GUI that makes HRI studies more accessible to non-programmers. The tight hardware focus on Aldebaran's Pepper, however, constrains the tool's applicability. \emph{OpenWoZ}~\cite{Hoffman2016} proposes a runtime-configurable framework with a multi-client architecture, enabling evaluators to modify robot behaviors during experiments. The platform allows one with programming expertise to create standard, customized robot behaviors for user studies.
|
|
||||||
|
|
||||||
In addition to the aforementioned frameworks, we considered Riek's systematic analysis of published WoZ experiments, which stresses the need for increased methodological rigor, transparency and reproducibility of WoZ studies.~\cite{Riek2012} Altogether, the literature inspired us to design HRIStudio as a platform that offers comprehensive support for WoZ studies in social robotics. Our design goals include offering a platform that is as ``robot-agnostic'' as possible and which offers its users guidance to specify and execute WoZ studies that are methodologically sound and maximally reproducible. As such, HRIStudio aims to offer an easy user interface that allows for experiments to be scripted and executed easily and which allows for the aggregation of experimental data and other assets generated in a study.
|
|
||||||
|
|
||||||
\section{Overarching Design Goals}
|
|
||||||
|
|
||||||
We have identified several guiding design principles to maximize HRIStudio's effectiveness, usefulness, and usability. Foremost, we want HRIStudio to be accessible to users with and without deep robot programming expertise so that we may lower the barrier to entry for those conducting HRI studies. The platform should provide an intuitive graphical user interface that obviates the need for describing robot behaviors in a programming language. The user should be able to focus on describing sequences of robot behaviors without getting bogged down by all the details of specific robots. To this end, we determined that the framework should offer users the means by which to describe experiments and robot behaviors, while capturing and storing all data generated including text-based logs, audio, video, IRB materials, and user consent forms.
|
|
||||||
|
|
||||||
Furthermore, we determined that the framework should also support multiple user accounts and data sharing to enable collaborations between the members of a team and the dissemination of experiments across different teams. By incorporating these design goals, HRIStudio prioritizes experiment design, collaborative workflows, methodological rigor, and scientific reproducibility.
|
|
||||||
|
|
||||||
\section{Design of the Experimental Workflow}
|
|
||||||
|
|
||||||
\subsection{Organization of a user study}
|
|
||||||
|
|
||||||
With HRIStudio, we define a hierarchical organization of elements to express WoZ user studies for HRI research. An experimenter starts by creating and configuring a \emph{study} element, which will comprise multiple instantiations of one same experimental script encapsulated in an element called \emph{experiment}, which captures the experiences of a specific human subject with the robot designated in the script.
|
|
||||||
|
|
||||||
Each \emph{experiment} comprises a sequence of one or more \emph{step} elements. Each \emph{step} models a phase of the experiment and aggregates a sequence of \emph{action} elements, which are fine-grained, specific tasks to be executed either by the wizard or by the robot. An \emph{action} targeted at the wizard provides guidance and maximizes the chances of consistent behavior. An \emph{action} targeted at the robot causes it to execute movements or verbal interactions, or causes it to wait for a human subject's input or response.
|
|
||||||
|
|
||||||
The system executes the \emph{actions} in an experimental script asynchronously and in an event-driven manner, guiding the wizard's behavior and allowing them to simulate the robot's autonomous intelligence by responding to the human subject in real time based on the human's actions and reactions. This event-driven approach allows for flexible and spontaneous reactions by the wizard, enabling a more natural and intelligent interaction with the human subject. In contrast, a time-driven script with rigid, imposed timing would show a lack of intelligence and autonomy on the part of the robot.
|
|
||||||
|
|
||||||
In order to enforce consistency across multiple runs of the \emph{experiment}, HRIStudio uses specifications encoded in the \emph{study} element to inform the wizard on how to constrain their behavior to a set of possible types of interventions. Although every experiment is potentially unique due to the unlikely perfect match of reactions between human subjects, this mechanism allows for annotating the data feed and capturing the nuances of each unique interaction.
|
|
||||||
|
|
||||||
Figure~\ref{fig:userstudy} illustrates this hierarchy of elements with a practical example. We argue that this hierarchical structure for the experimental procedure in a user study benefits methodological rigor and reproducibility while affording the researcher the ability to design complex HRI studies while guiding the wizard to follow a consistent set of instructions.
|
|
||||||
|
|
||||||
\begin{figure}[ht]
|
|
||||||
\vskip -0.4cm
|
|
||||||
\begin{center}
|
|
||||||
\includegraphics[width=0.4\paperwidth]{assets/diagrams/userstudy}
|
|
||||||
\vskip -0.5cm
|
|
||||||
\caption{A sample user study.}
|
|
||||||
\label{fig:userstudy}
|
|
||||||
\end{center}
|
|
||||||
\vskip -0.7cm
|
|
||||||
\end{figure}
|
|
||||||
|
|
||||||
\subsection{System interfaces}
|
|
||||||
|
|
||||||
HRIStudio features a user-friendly graphical interface for designing WoZ experiments. This interface provides a visual programming system that allows one to build their experiments using a drag-and-drop approach. The core of the experiment creation process offers a library of actions including common tasks and behaviors executed in the experiment such as robot movements, speech synthesis, and instructions for the wizard. One can drag and drop action components onto a canvas and arrange them into sequences that define study, experiment, steps, and action components. The interface provides configuration options that allow researchers to customize parameters in each element. This configuration system offers contextual help and documentation to guide researchers through the process while providing examples or best practices for designing studies.
|
|
||||||
|
|
||||||
\subsection{Live experiment operation}
|
|
||||||
|
|
||||||
During live experiment sessions, HRIStudio offers multiple synchronized views for experiment execution and observation, and data collection. The wizard's \emph{Execute} view gives the wizard control over the robot's actions and behaviors. Displaying the current step of the experiment along with associated actions, this interface facilitates intuitive navigation through the structural elements of the experiments and allows for the creation of annotations on a timeline. The wizard can advance through actions sequentially or manually trigger specific actions based on contextual cues or responses from the human subject. During the execution of an experiment, the interface gives the wizard manual controls to insert unscripted robot movements, speech synthesis, and other functions dynamically. These events are recorded in persistent media within the sequence of actions in the experimental script.
|
|
||||||
|
|
||||||
The observer's \emph{Execute} view supports live monitoring, note-taking, and potential interventions by additional researchers involved in the experiment. This feature ensures the option of continuous oversight without disrupting the experience of human subjects or the wizard's control. Collaboration on an experiment is made possible by allowing multiple observers to concurrently access the \emph{Execute} view.
|
|
||||||
|
|
||||||
\subsection{Data logging, playback, and annotation}
|
|
||||||
|
|
||||||
Throughout the live experiment session, the platform automatically logs various data streams, including timestamped records of all executed actions and experimental events, exposed robot sensor data, and audio and video recordings of the participant's interactions with the robot. Logged data is stored in JavaScript Object Notation (JSON) encrypted files in secure storage, enabling efficient post-experiment data analysis to ensure the privacy of human subjects.
|
|
||||||
|
|
||||||
After a live experiment session, researchers may use a \emph{Playback} view to inspect the recorded data streams and develop a holistic understanding of the experiment's progression. This interface supports features such as playback of recorded data such as audio, video, and sensor data streams, scrubbing of recorded data with the ability to mark and note significant events or observations, and export options for selected data segments or annotations.
|
|
||||||
|
|
||||||
\section{Implementation}
|
|
||||||
|
|
||||||
The realization of the proposed platform is a work in progress. So far, we have made significant advances on the design of the overall framework and of its several components while exploring underlying technologies, wireframing user views and interfaces, and establishing a development roadmap.
|
|
||||||
|
|
||||||
\subsection{Core technologies used}
|
|
||||||
|
|
||||||
We are leveraging the \emph{Next.js React} \cite{next} framework for building our framework as a web application. Next.js provides server-side rendering, improved performance, and enhanced security. By making HRIStudio a web application, we achieve independence from hardware and operating system. We are building into the framework support for API routes and integration with \emph{TypeScript Remote Procedure Call} (tRPC), which simplifies the development of APIs for interfacing with the ROS interface.
|
|
||||||
|
|
||||||
For the robot control layer, we utilize ROS as the communication and control interface. ROS offers a modular and extensible architecture, enabling seamless integration with a multitude of consumer and research robotics platforms. Thanks to the widespread adoption of ROS in the robotics community, HRIStudio will be able to support a wide range robots out-of-the-box by leveraging the efforts of the ROS community for new robot platforms.
|
|
||||||
\vspace{-0.3cm}
|
|
||||||
\subsection{High-level architecture}
|
|
||||||
|
|
||||||
We have designed our system as a full-stack web application. The frontend handles user interface components such as the experiment \emph{Design} view, the experiment \emph{Execute} view, and the \emph{Playback} view. The backend API logic manages experiment data, user authentication, and communication with a ROS interface component. In its turn, the ROS interface is implemented as a separate C++ node and translates high-level actions from the web application into low-level robot commands, sensor data, and protocols, abstracting the complexities of different robotics platforms. This modular architecture leverages the benefits of Next.js' server-side rendering, improved performance, and security, while enabling integration with various robotic platforms through ROS. Fig.~\ref{fig:systemarch} shows the structure of the application.
|
|
||||||
|
|
||||||
\begin{figure}
|
|
||||||
\begin{center}
|
|
||||||
\includegraphics[width=0.35\paperwidth]{assets/diagrams/systemarch}
|
|
||||||
\vskip -0.5cm
|
|
||||||
\caption{The high-level system architecture of HRIStudio.}
|
|
||||||
\label{fig:systemarch}
|
|
||||||
\vskip -0.8cm
|
|
||||||
\end{center}
|
|
||||||
\end{figure}
|
|
||||||
|
|
||||||
\subsection{User interface mockups}
|
|
||||||
|
|
||||||
A significant portion of our efforts have been dedicated to designing intuitive and user-friendly interface mockups for the platform's key components. We have created wireframes and prototypes for the study \emph{Dashboard}, \emph{Design} view, \emph{Execute} view, and the \emph{Playback} view.
|
|
||||||
|
|
||||||
The study \emph{Dashboard} mockups (see Figure~\ref{fig:dashboard}) display an intuitive overview of a project's status, including platform information, collaborators, completed and upcoming trials, subjects, and a list of pending issues. This will allow a researcher to quickly see what needs to be done, or easily navigate to a previous trial's data for analysis.
|
|
||||||
|
|
||||||
\begin{figure}
|
|
||||||
% \vskip -.2cm
|
|
||||||
\centering
|
|
||||||
\includegraphics[width=0.35\paperwidth]{assets/mockups/dashboard}
|
|
||||||
\vskip -0.3cm
|
|
||||||
\caption{A sample project's \emph{Dashboard} view within HRIStudio.}
|
|
||||||
\label{fig:dashboard}
|
|
||||||
\vskip -.2cm
|
|
||||||
\end{figure}
|
|
||||||
|
|
||||||
The \emph{Design} view mockups depicted in Figure~\ref{fig:design} feature a visual programming canvas where researchers can construct their experiments by dragging and dropping pre-defined action components. These components represent common tasks and behaviors, such as robot movements, speech synthesis, and instructions for the wizard. The mockups also include configuration panels for customizing the parameters of each action component.
|
|
||||||
|
|
||||||
\begin{figure}
|
|
||||||
\vskip -0.1cm
|
|
||||||
\centering
|
|
||||||
\includegraphics[width=0.35\paperwidth]{assets/mockups/design}
|
|
||||||
\vskip -0.3cm
|
|
||||||
\caption{A sample project's \emph{Design} view in HRIStudio.}
|
|
||||||
\label{fig:design}
|
|
||||||
\vskip -.3cm
|
|
||||||
\end{figure}
|
|
||||||
|
|
||||||
For the \emph{Execute} view, we have designed mockups that provide synchronized views for the wizard and observers. The wizard's view (see Figure~\ref{fig:execute}) presents an intuitive step-based interface that walks the wizard through the experiment as specified by the designer, triggering actions, and controlling the robot, while the observer view facilitates real-time monitoring and note taking.
|
|
||||||
|
|
||||||
\begin{figure}
|
|
||||||
\vskip -0.3cm
|
|
||||||
\centering
|
|
||||||
\includegraphics[width=0.35\paperwidth]{assets/mockups/execute}
|
|
||||||
\vskip -0.3cm
|
|
||||||
\caption{The wizard's \emph{Execute} view during a live experiment.}
|
|
||||||
\label{fig:execute}
|
|
||||||
% \vskip -0.9cm
|
|
||||||
\end{figure}
|
|
||||||
|
|
||||||
Fig.~\ref{fig:playback} shows \emph{Playback} mockups for synchronized playback of recorded data streams, including audio, video, and applicable sensor data. The features include visual and textual annotations, scrubbing capabilities, and data export options to support comprehensive post-experiment analysis and reproducibility.
|
|
||||||
|
|
||||||
\begin{figure}
|
|
||||||
\centering
|
|
||||||
\includegraphics[width=0.35\paperwidth]{assets/mockups/playback}
|
|
||||||
\vskip -0.3cm
|
|
||||||
\caption{The \emph{Playback} view of an experiment within a study.}
|
|
||||||
\label{fig:playback}
|
|
||||||
\vskip -0.4cm
|
|
||||||
\end{figure}
|
|
||||||
|
|
||||||
\subsection{Development roadmap}
|
|
||||||
|
|
||||||
While the UI mockups have laid a solid foundation, we anticipate challenges in transforming these designs into a fully functional platform, such as integrating the Next.js web application with the ROS interface and handling bi-directional communication between the two. We plan to leverage tRPC for real-time data exchange and robot control.
|
|
||||||
|
|
||||||
Another key challenge is developing the \emph{Design} view's visual programming environment, and encoding procedures into a shareable format. We will explore existing visual programming libraries and develop custom components for intuitive experiment construction.
|
|
||||||
|
|
||||||
Implementing robust data logging and synchronized playback of audio, video, and sensor data while ensuring efficient storage and retrieval is also crucial.
|
|
||||||
|
|
||||||
To address these challenges, our development roadmap includes:
|
|
||||||
\begin{itemize}
|
|
||||||
\item Establishing a stable Next.js codebase with tRPC integration,
|
|
||||||
\item Implementing a ROS interface node for robot communication,
|
|
||||||
\item Developing the visual experiment designer,
|
|
||||||
\item Integrating data logging for capturing experimental data streams,
|
|
||||||
\item Building playback and annotation tools with export capabilities,
|
|
||||||
\item Creating tutorials and documentation for researcher adoption.
|
|
||||||
\end{itemize}
|
|
||||||
|
|
||||||
This roadmap identifies some of the challenges ahead. We expect that this plan will fully realize HRIStudio into a functional and accessible tool for conducting WoZ experiments. We hope for this tool to become a significant aid in HRI research, empowering researchers and fostering collaboration within the community.
|
|
||||||
|
|
||||||
\bibliography{refs}
|
|
||||||
\bibliographystyle{plain}
|
|
||||||
|
|
||||||
\end{document}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 128 B |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 385 B |
24
scripts/init-minio.sh
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Install MinIO client if not already installed
|
||||||
|
if ! command -v mc &> /dev/null; then
|
||||||
|
echo "Installing MinIO client..."
|
||||||
|
curl -O https://dl.min.io/client/mc/release/darwin-amd64/mc
|
||||||
|
chmod +x mc
|
||||||
|
sudo mv mc /usr/local/bin/
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Configure MinIO client
|
||||||
|
mc alias set local http://localhost:9000 minioadmin minioadmin
|
||||||
|
|
||||||
|
# Create bucket if it doesn't exist
|
||||||
|
if ! mc ls local/uploads &> /dev/null; then
|
||||||
|
echo "Creating uploads bucket..."
|
||||||
|
mc mb local/uploads
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set bucket policy to public
|
||||||
|
echo "Setting bucket policy..."
|
||||||
|
mc policy set public local/uploads
|
||||||
|
|
||||||
|
echo "MinIO initialization complete!"
|
||||||
6
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import NextAuth from "next-auth";
|
||||||
|
import { authOptions } from "~/server/auth";
|
||||||
|
|
||||||
|
const handler = NextAuth(authOptions);
|
||||||
|
|
||||||
|
export { handler as GET, handler as POST };
|
||||||
64
src/app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { hash } from "bcryptjs";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { users } from "~/server/db/schema";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
const registerSchema = z.object({
|
||||||
|
firstName: z.string().min(1, "First name is required"),
|
||||||
|
lastName: z.string().min(1, "Last name is required"),
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const form = await req.formData();
|
||||||
|
const data = {
|
||||||
|
firstName: form.get("firstName"),
|
||||||
|
lastName: form.get("lastName"),
|
||||||
|
email: form.get("email"),
|
||||||
|
password: form.get("password"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsed = registerSchema.safeParse(data);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid input" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { firstName, lastName, email, password } = parsed.data;
|
||||||
|
|
||||||
|
const exists = await db.query.users.findFirst({
|
||||||
|
where: (users, { eq }) => eq(users.email, email),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "User already exists" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await hash(password, 10);
|
||||||
|
|
||||||
|
await db.insert(users).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email,
|
||||||
|
password: hashedPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Something went wrong" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/app/api/images/[key]/route.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getObjectFromS3 } from "~/server/storage/s3";
|
||||||
|
import { Readable } from "stream";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: { key: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Ensure params.key is awaited
|
||||||
|
const { key } = params;
|
||||||
|
const decodedKey = decodeURIComponent(key);
|
||||||
|
console.log("Fetching image with key:", decodedKey);
|
||||||
|
|
||||||
|
const response = await getObjectFromS3(decodedKey);
|
||||||
|
console.log("S3 response received:", {
|
||||||
|
contentType: response.ContentType,
|
||||||
|
contentLength: response.ContentLength,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.Body) {
|
||||||
|
console.error("No image data in response body");
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Image data not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = response.Body as Readable;
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
chunks.push(Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
const buffer = Buffer.concat(chunks);
|
||||||
|
|
||||||
|
console.log("Image buffer created, size:", buffer.length);
|
||||||
|
|
||||||
|
// Ensure we set the correct image content type
|
||||||
|
const contentType = response.ContentType ?? 'image/jpeg';
|
||||||
|
|
||||||
|
// Create response headers
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Content-Length": buffer.length.toString(),
|
||||||
|
"Cache-Control": "public, max-age=31536000, immutable",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type",
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Sending response with headers:", headers);
|
||||||
|
|
||||||
|
// Return the response with explicit headers object
|
||||||
|
return new Response(buffer, {
|
||||||
|
status: 200,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error serving image:", error);
|
||||||
|
if ((error as any)?.name === "NoSuchKey") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Image not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
// @ts-nocheck
|
|
||||||
/* eslint-disable */
|
|
||||||
/* tslint:disable */
|
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { NextRequest } from "next/server";
|
|
||||||
import { db } from "~/db";
|
|
||||||
import { invitationsTable } from "~/db/schema";
|
|
||||||
import { PERMISSIONS, checkPermissions } from "~/lib/permissions-server";
|
|
||||||
import { ApiError, createApiResponse } from "~/lib/api-utils";
|
|
||||||
|
|
||||||
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
|
|
||||||
try {
|
|
||||||
const { id } = params;
|
|
||||||
const invitationId = parseInt(id, 10);
|
|
||||||
|
|
||||||
if (isNaN(invitationId)) {
|
|
||||||
return ApiError.BadRequest("Invalid invitation ID");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the invitation to check the study ID
|
|
||||||
const invitation = await db
|
|
||||||
.select()
|
|
||||||
.from(invitationsTable)
|
|
||||||
.where(eq(invitationsTable.id, invitationId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!invitation[0]) {
|
|
||||||
return ApiError.NotFound("Invitation");
|
|
||||||
}
|
|
||||||
|
|
||||||
const permissionCheck = await checkPermissions({
|
|
||||||
studyId: invitation[0].studyId,
|
|
||||||
permission: PERMISSIONS.MANAGE_ROLES
|
|
||||||
});
|
|
||||||
|
|
||||||
if (permissionCheck.error) {
|
|
||||||
return permissionCheck.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(invitationsTable)
|
|
||||||
.where(eq(invitationsTable.id, invitationId));
|
|
||||||
|
|
||||||
return createApiResponse({ message: "Invitation deleted successfully" });
|
|
||||||
} catch (error) {
|
|
||||||
return ApiError.ServerError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { eq, and } from "drizzle-orm";
|
|
||||||
import { NextRequest } from "next/server";
|
|
||||||
import { auth } from "@clerk/nextjs/server";
|
|
||||||
import { db } from "~/db";
|
|
||||||
import { invitationsTable, userRolesTable } from "~/db/schema";
|
|
||||||
import { ApiError, createApiResponse } from "~/lib/api-utils";
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest, { params }: { params: { token: string } }) {
|
|
||||||
const { userId } = await auth();
|
|
||||||
const { token } = params;
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return ApiError.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Find the invitation
|
|
||||||
const [invitation] = await db
|
|
||||||
.select()
|
|
||||||
.from(invitationsTable)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(invitationsTable.token, token),
|
|
||||||
eq(invitationsTable.accepted, false)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!invitation) {
|
|
||||||
return ApiError.NotFound("Invitation");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if invitation has expired
|
|
||||||
if (new Date() > invitation.expiresAt) {
|
|
||||||
return ApiError.BadRequest("Invitation has expired");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assign role and mark invitation as accepted in a transaction
|
|
||||||
await db.transaction(async (tx) => {
|
|
||||||
// Assign role
|
|
||||||
await tx
|
|
||||||
.insert(userRolesTable)
|
|
||||||
.values({
|
|
||||||
userId: userId,
|
|
||||||
roleId: invitation.roleId,
|
|
||||||
studyId: invitation.studyId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mark invitation as accepted
|
|
||||||
await tx
|
|
||||||
.update(invitationsTable)
|
|
||||||
.set({
|
|
||||||
accepted: true,
|
|
||||||
acceptedByUserId: userId,
|
|
||||||
})
|
|
||||||
.where(eq(invitationsTable.id, invitation.id));
|
|
||||||
});
|
|
||||||
|
|
||||||
return createApiResponse({ message: "Invitation accepted successfully" });
|
|
||||||
} catch (error) {
|
|
||||||
return ApiError.ServerError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { auth } from "@clerk/nextjs/server";
|
|
||||||
import { db } from "~/db";
|
|
||||||
import { invitationsTable, studyTable, rolesTable } from "~/db/schema";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { randomBytes } from "crypto";
|
|
||||||
import { sendInvitationEmail } from "~/lib/email";
|
|
||||||
import { PERMISSIONS, checkPermissions } from "~/lib/permissions-server";
|
|
||||||
import { ApiError, createApiResponse } from "~/lib/api-utils";
|
|
||||||
|
|
||||||
// Helper to generate a secure random token
|
|
||||||
function generateToken(): string {
|
|
||||||
return randomBytes(32).toString('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
try {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const studyId = url.searchParams.get("studyId");
|
|
||||||
|
|
||||||
if (!studyId) {
|
|
||||||
return ApiError.BadRequest("Study ID is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
const permissionCheck = await checkPermissions({
|
|
||||||
studyId: parseInt(studyId),
|
|
||||||
permission: PERMISSIONS.MANAGE_ROLES
|
|
||||||
});
|
|
||||||
|
|
||||||
if (permissionCheck.error) {
|
|
||||||
return permissionCheck.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all invitations for the study, including role names
|
|
||||||
const invitations = await db
|
|
||||||
.select({
|
|
||||||
id: invitationsTable.id,
|
|
||||||
email: invitationsTable.email,
|
|
||||||
accepted: invitationsTable.accepted,
|
|
||||||
expiresAt: invitationsTable.expiresAt,
|
|
||||||
createdAt: invitationsTable.createdAt,
|
|
||||||
roleName: rolesTable.name,
|
|
||||||
})
|
|
||||||
.from(invitationsTable)
|
|
||||||
.innerJoin(rolesTable, eq(invitationsTable.roleId, rolesTable.id))
|
|
||||||
.where(eq(invitationsTable.studyId, parseInt(studyId)));
|
|
||||||
|
|
||||||
return createApiResponse(invitations);
|
|
||||||
} catch (error) {
|
|
||||||
return ApiError.ServerError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
const { email, studyId, roleId } = await request.json();
|
|
||||||
|
|
||||||
const permissionCheck = await checkPermissions({
|
|
||||||
studyId,
|
|
||||||
permission: PERMISSIONS.MANAGE_ROLES
|
|
||||||
});
|
|
||||||
|
|
||||||
if (permissionCheck.error) {
|
|
||||||
return permissionCheck.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { userId } = permissionCheck;
|
|
||||||
|
|
||||||
// Get study details
|
|
||||||
const study = await db
|
|
||||||
.select()
|
|
||||||
.from(studyTable)
|
|
||||||
.where(eq(studyTable.id, studyId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!study[0]) {
|
|
||||||
return ApiError.NotFound("Study");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the role exists
|
|
||||||
const role = await db
|
|
||||||
.select()
|
|
||||||
.from(rolesTable)
|
|
||||||
.where(eq(rolesTable.id, roleId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!role[0]) {
|
|
||||||
return ApiError.BadRequest("Invalid role");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate invitation token
|
|
||||||
const token = generateToken();
|
|
||||||
const expiresAt = new Date();
|
|
||||||
expiresAt.setDate(expiresAt.getDate() + 7); // 7 days expiration
|
|
||||||
|
|
||||||
// Create invitation
|
|
||||||
const [invitation] = await db
|
|
||||||
.insert(invitationsTable)
|
|
||||||
.values({
|
|
||||||
email,
|
|
||||||
studyId,
|
|
||||||
roleId,
|
|
||||||
token,
|
|
||||||
invitedById: userId,
|
|
||||||
expiresAt,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
// Send invitation email
|
|
||||||
await sendInvitationEmail({
|
|
||||||
to: email,
|
|
||||||
inviterName: "A researcher", // TODO: Get inviter name
|
|
||||||
studyTitle: study[0].title,
|
|
||||||
role: role[0].name,
|
|
||||||
token,
|
|
||||||
});
|
|
||||||
|
|
||||||
return createApiResponse(invitation);
|
|
||||||
} catch (error) {
|
|
||||||
return ApiError.ServerError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { auth } from "@clerk/nextjs/server";
|
|
||||||
import { db } from "~/db";
|
|
||||||
import { participantsTable } from "~/db/schema";
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
const { userId } = await auth();
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return new NextResponse("Unauthorized", { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const studyId = url.searchParams.get("studyId");
|
|
||||||
|
|
||||||
if (!studyId) {
|
|
||||||
return new NextResponse("Study ID is required", { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const participantList = await db
|
|
||||||
.select()
|
|
||||||
.from(participantsTable)
|
|
||||||
.where(eq(participantsTable.studyId, parseInt(studyId)));
|
|
||||||
|
|
||||||
return NextResponse.json(participantList);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const { userId } = await auth();
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return new NextResponse("Unauthorized", { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { name, studyId } = await request.json();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const participant = await db
|
|
||||||
.insert(participantsTable)
|
|
||||||
.values({
|
|
||||||
name,
|
|
||||||
studyId,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
return NextResponse.json(participant[0]);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error adding participant:", error);
|
|
||||||
return new NextResponse("Internal Server Error", { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { auth } from "@clerk/nextjs/server";
|
|
||||||
import { ApiError, createApiResponse } from "~/lib/api-utils";
|
|
||||||
import { db } from "~/db";
|
|
||||||
import { userRolesTable, rolePermissionsTable, permissionsTable } from "~/db/schema";
|
|
||||||
import { eq, and } from "drizzle-orm";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const { userId } = await auth();
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return ApiError.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const permissions = await db
|
|
||||||
.selectDistinct({
|
|
||||||
code: permissionsTable.code,
|
|
||||||
})
|
|
||||||
.from(userRolesTable)
|
|
||||||
.innerJoin(rolePermissionsTable, eq(rolePermissionsTable.roleId, userRolesTable.roleId))
|
|
||||||
.innerJoin(permissionsTable, eq(permissionsTable.id, rolePermissionsTable.permissionId))
|
|
||||||
.where(eq(userRolesTable.userId, userId));
|
|
||||||
|
|
||||||
return createApiResponse(permissions.map(p => p.code));
|
|
||||||
} catch (error) {
|
|
||||||
return ApiError.ServerError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
88
src/app/api/plugins/install/route.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getPlugin } from "~/lib/plugin-store/service";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { installedPlugins } from "~/server/db/schema/store";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
// POST /api/plugins/install - Install a plugin
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const schema = z.object({
|
||||||
|
robotId: z.string(),
|
||||||
|
repositoryId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { robotId, repositoryId } = schema.parse(body);
|
||||||
|
|
||||||
|
// Get plugin details
|
||||||
|
const plugin = await getPlugin(robotId);
|
||||||
|
if (!plugin) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Plugin not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already installed
|
||||||
|
const existing = await db.query.installedPlugins.findFirst({
|
||||||
|
where: eq(installedPlugins.robotId, robotId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Plugin already installed" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install plugin
|
||||||
|
const installed = await db.insert(installedPlugins).values({
|
||||||
|
robotId,
|
||||||
|
repositoryId,
|
||||||
|
name: plugin.name,
|
||||||
|
version: plugin.version,
|
||||||
|
enabled: true,
|
||||||
|
config: {},
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
return NextResponse.json(installed[0]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to install plugin:", error);
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid request body", details: error.errors },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to install plugin" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/plugins/install - Uninstall a plugin
|
||||||
|
export async function DELETE(req: Request) {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const robotId = url.searchParams.get("robotId");
|
||||||
|
|
||||||
|
if (!robotId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Robot ID is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(installedPlugins).where(eq(installedPlugins.robotId, robotId));
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to uninstall plugin:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to uninstall plugin" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { auth } from "@clerk/nextjs/server";
|
|
||||||
import { db } from "~/db";
|
|
||||||
import { rolesTable } from "~/db/schema";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const { userId } = await auth();
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return new NextResponse("Unauthorized", { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const roles = await db
|
|
||||||
.select({
|
|
||||||
id: rolesTable.id,
|
|
||||||
name: rolesTable.name,
|
|
||||||
description: rolesTable.description,
|
|
||||||
})
|
|
||||||
.from(rolesTable);
|
|
||||||
|
|
||||||
return NextResponse.json(roles);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching roles:", error);
|
|
||||||
return new NextResponse("Internal Server Error", { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { db } from "~/db";
|
|
||||||
import { participantsTable } from "~/db/schema";
|
|
||||||
import { PERMISSIONS, checkPermissions } from "~/lib/permissions-server";
|
|
||||||
import { ApiError, createApiResponse } from "~/lib/api-utils";
|
|
||||||
import { auth } from "@clerk/nextjs/server";
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
request: Request,
|
|
||||||
context: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
const { userId } = await auth();
|
|
||||||
const { id } = await Promise.resolve(context.params);
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return ApiError.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const studyId = parseInt(id);
|
|
||||||
|
|
||||||
if (isNaN(studyId)) {
|
|
||||||
return ApiError.BadRequest("Invalid study ID");
|
|
||||||
}
|
|
||||||
|
|
||||||
const permissionCheck = await checkPermissions({
|
|
||||||
studyId,
|
|
||||||
permission: PERMISSIONS.VIEW_PARTICIPANT_NAMES,
|
|
||||||
});
|
|
||||||
|
|
||||||
const participants = await db
|
|
||||||
.select()
|
|
||||||
.from(participantsTable)
|
|
||||||
.where(eq(participantsTable.studyId, studyId));
|
|
||||||
|
|
||||||
if (permissionCheck.error) {
|
|
||||||
const anonymizedParticipants = participants.map((participant, index) => ({
|
|
||||||
...participant,
|
|
||||||
name: `Participant ${String.fromCharCode(65 + index)}`,
|
|
||||||
}));
|
|
||||||
return createApiResponse(anonymizedParticipants);
|
|
||||||
}
|
|
||||||
|
|
||||||
return createApiResponse(participants);
|
|
||||||
} catch (error) {
|
|
||||||
return ApiError.ServerError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(
|
|
||||||
request: Request,
|
|
||||||
context: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
const { userId } = await auth();
|
|
||||||
const { id } = await Promise.resolve(context.params);
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return ApiError.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const studyId = parseInt(id);
|
|
||||||
const { name } = await request.json();
|
|
||||||
|
|
||||||
if (isNaN(studyId)) {
|
|
||||||
return ApiError.BadRequest("Invalid study ID");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!name || typeof name !== "string") {
|
|
||||||
return ApiError.BadRequest("Name is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
const permissionCheck = await checkPermissions({
|
|
||||||
studyId,
|
|
||||||
permission: PERMISSIONS.CREATE_PARTICIPANT,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (permissionCheck.error) {
|
|
||||||
return permissionCheck.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const participant = await db
|
|
||||||
.insert(participantsTable)
|
|
||||||
.values({
|
|
||||||
name,
|
|
||||||
studyId,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
return createApiResponse(participant[0]);
|
|
||||||
} catch (error) {
|
|
||||||
return ApiError.ServerError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(
|
|
||||||
request: Request,
|
|
||||||
context: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
const { userId } = await auth();
|
|
||||||
const { id } = await Promise.resolve(context.params);
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return ApiError.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const studyId = parseInt(id);
|
|
||||||
const { participantId } = await request.json();
|
|
||||||
|
|
||||||
if (isNaN(studyId)) {
|
|
||||||
return ApiError.BadRequest("Invalid study ID");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!participantId || typeof participantId !== "number") {
|
|
||||||
return ApiError.BadRequest("Participant ID is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
const permissionCheck = await checkPermissions({
|
|
||||||
studyId,
|
|
||||||
permission: PERMISSIONS.DELETE_PARTICIPANT,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (permissionCheck.error) {
|
|
||||||
return permissionCheck.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(participantsTable)
|
|
||||||
.where(eq(participantsTable.id, participantId));
|
|
||||||
|
|
||||||
return createApiResponse({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
return ApiError.ServerError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { eq, and } from "drizzle-orm";
|
|
||||||
import { db } from "~/db";
|
|
||||||
import { studyTable, userRolesTable, rolePermissionsTable, permissionsTable } from "~/db/schema";
|
|
||||||
import { PERMISSIONS, checkPermissions } from "~/lib/permissions-server";
|
|
||||||
import { ApiError, createApiResponse } from "~/lib/api-utils";
|
|
||||||
|
|
||||||
export async function GET(request: Request, { params }: { params: { id: string } }) {
|
|
||||||
try {
|
|
||||||
const id = await Promise.resolve(params.id);
|
|
||||||
const studyId = parseInt(id);
|
|
||||||
|
|
||||||
if (isNaN(studyId)) {
|
|
||||||
return ApiError.BadRequest("Invalid study ID");
|
|
||||||
}
|
|
||||||
|
|
||||||
const permissionCheck = await checkPermissions({
|
|
||||||
studyId,
|
|
||||||
permission: PERMISSIONS.VIEW_STUDY
|
|
||||||
});
|
|
||||||
|
|
||||||
if (permissionCheck.error) {
|
|
||||||
return permissionCheck.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get study with permissions
|
|
||||||
const studyWithPermissions = await db
|
|
||||||
.selectDistinct({
|
|
||||||
id: studyTable.id,
|
|
||||||
title: studyTable.title,
|
|
||||||
description: studyTable.description,
|
|
||||||
createdAt: studyTable.createdAt,
|
|
||||||
updatedAt: studyTable.updatedAt,
|
|
||||||
userId: studyTable.userId,
|
|
||||||
permissionCode: permissionsTable.code,
|
|
||||||
})
|
|
||||||
.from(studyTable)
|
|
||||||
.leftJoin(userRolesTable, eq(userRolesTable.studyId, studyTable.id))
|
|
||||||
.leftJoin(rolePermissionsTable, eq(rolePermissionsTable.roleId, userRolesTable.roleId))
|
|
||||||
.leftJoin(permissionsTable, eq(permissionsTable.id, rolePermissionsTable.permissionId))
|
|
||||||
.where(eq(studyTable.id, studyId));
|
|
||||||
|
|
||||||
if (!studyWithPermissions.length) {
|
|
||||||
return ApiError.NotFound("Study");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group permissions
|
|
||||||
const study = {
|
|
||||||
...studyWithPermissions[0],
|
|
||||||
permissions: studyWithPermissions
|
|
||||||
.map(s => s.permissionCode)
|
|
||||||
.filter((code): code is string => code !== null)
|
|
||||||
};
|
|
||||||
|
|
||||||
return createApiResponse(study);
|
|
||||||
} catch (error) {
|
|
||||||
return ApiError.ServerError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { sql } from "drizzle-orm";
|
|
||||||
import { db } from "~/db";
|
|
||||||
import { participantsTable } from "~/db/schema";
|
|
||||||
import { PERMISSIONS, checkPermissions } from "~/lib/permissions-server";
|
|
||||||
import { ApiError, createApiResponse } from "~/lib/api-utils";
|
|
||||||
import { auth } from "@clerk/nextjs/server";
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
request: Request,
|
|
||||||
context: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
const { userId } = await auth();
|
|
||||||
const { id } = await Promise.resolve(context.params);
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return ApiError.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const studyId = parseInt(id);
|
|
||||||
|
|
||||||
if (isNaN(studyId)) {
|
|
||||||
return ApiError.BadRequest("Invalid study ID");
|
|
||||||
}
|
|
||||||
|
|
||||||
const permissionCheck = await checkPermissions({
|
|
||||||
studyId,
|
|
||||||
permission: PERMISSIONS.VIEW_STUDY,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (permissionCheck.error) {
|
|
||||||
return permissionCheck.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get participant count using SQL count
|
|
||||||
const [{ count }] = await db
|
|
||||||
.select({
|
|
||||||
count: sql<number>`count(*)::int`,
|
|
||||||
})
|
|
||||||
.from(participantsTable)
|
|
||||||
.where(eq(participantsTable.studyId, studyId));
|
|
||||||
|
|
||||||
// TODO: Add actual trial and form counts when those tables are added
|
|
||||||
const stats = {
|
|
||||||
participantCount: count,
|
|
||||||
completedTrialsCount: 0,
|
|
||||||
pendingFormsCount: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
return createApiResponse(stats);
|
|
||||||
} catch (error) {
|
|
||||||
return ApiError.ServerError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { eq, and } from "drizzle-orm";
|
|
||||||
import { db } from "~/db";
|
|
||||||
import { userRolesTable } from "~/db/schema";
|
|
||||||
import { PERMISSIONS, checkPermissions } from "~/lib/permissions-server";
|
|
||||||
import { ApiError, createApiResponse } from "~/lib/api-utils";
|
|
||||||
|
|
||||||
export async function PUT(
|
|
||||||
request: Request,
|
|
||||||
context: { params: { id: string; userId: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id, userId } = await Promise.resolve(context.params);
|
|
||||||
const studyId = parseInt(id);
|
|
||||||
|
|
||||||
if (isNaN(studyId)) {
|
|
||||||
return ApiError.BadRequest("Invalid study ID");
|
|
||||||
}
|
|
||||||
|
|
||||||
const permissionCheck = await checkPermissions({
|
|
||||||
studyId,
|
|
||||||
permission: PERMISSIONS.MANAGE_ROLES
|
|
||||||
});
|
|
||||||
|
|
||||||
if (permissionCheck.error) {
|
|
||||||
return permissionCheck.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { roleId } = await request.json();
|
|
||||||
|
|
||||||
if (!roleId || typeof roleId !== "number") {
|
|
||||||
return ApiError.BadRequest("Role ID is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update user's role in the study
|
|
||||||
await db.transaction(async (tx) => {
|
|
||||||
// Delete existing roles
|
|
||||||
await tx
|
|
||||||
.delete(userRolesTable)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(userRolesTable.userId, userId),
|
|
||||||
eq(userRolesTable.studyId, studyId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Assign new role
|
|
||||||
await tx
|
|
||||||
.insert(userRolesTable)
|
|
||||||
.values({
|
|
||||||
userId,
|
|
||||||
roleId,
|
|
||||||
studyId,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return createApiResponse({ message: "Role updated successfully" });
|
|
||||||
} catch (error) {
|
|
||||||
return ApiError.ServerError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { eq, and } from "drizzle-orm";
|
|
||||||
import { db } from "~/db";
|
|
||||||
import { userRolesTable, usersTable, rolesTable } from "~/db/schema";
|
|
||||||
import { PERMISSIONS, checkPermissions } from "~/lib/permissions-server";
|
|
||||||
import { ApiError, createApiResponse } from "~/lib/api-utils";
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
request: Request,
|
|
||||||
context: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = await Promise.resolve(context.params);
|
|
||||||
const studyId = parseInt(id);
|
|
||||||
|
|
||||||
if (isNaN(studyId)) {
|
|
||||||
return ApiError.BadRequest("Invalid study ID");
|
|
||||||
}
|
|
||||||
|
|
||||||
const permissionCheck = await checkPermissions({
|
|
||||||
studyId,
|
|
||||||
permission: PERMISSIONS.VIEW_STUDY
|
|
||||||
});
|
|
||||||
|
|
||||||
if (permissionCheck.error) {
|
|
||||||
return permissionCheck.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all users in the study with their roles
|
|
||||||
const studyUsers = await db
|
|
||||||
.select({
|
|
||||||
id: usersTable.id,
|
|
||||||
email: usersTable.email,
|
|
||||||
name: usersTable.name,
|
|
||||||
imageUrl: usersTable.imageUrl,
|
|
||||||
roleId: rolesTable.id,
|
|
||||||
roleName: rolesTable.name,
|
|
||||||
})
|
|
||||||
.from(userRolesTable)
|
|
||||||
.innerJoin(usersTable, eq(usersTable.id, userRolesTable.userId))
|
|
||||||
.innerJoin(rolesTable, eq(rolesTable.id, userRolesTable.roleId))
|
|
||||||
.where(eq(userRolesTable.studyId, studyId));
|
|
||||||
|
|
||||||
// Group roles by user
|
|
||||||
const users = studyUsers.reduce((acc, curr) => {
|
|
||||||
const existingUser = acc.find(u => u.id === curr.id);
|
|
||||||
if (!existingUser) {
|
|
||||||
acc.push({
|
|
||||||
id: curr.id,
|
|
||||||
email: curr.email,
|
|
||||||
name: curr.name,
|
|
||||||
imageUrl: curr.imageUrl,
|
|
||||||
roles: [{
|
|
||||||
id: curr.roleId,
|
|
||||||
name: curr.roleName,
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
} else if (curr.roleName && !existingUser.roles.some(r => r.id === curr.roleId)) {
|
|
||||||
existingUser.roles.push({
|
|
||||||
id: curr.roleId,
|
|
||||||
name: curr.roleName,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, [] as Array<{
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
name: string | null;
|
|
||||||
imageUrl: string | null;
|
|
||||||
roles: Array<{ id: number; name: string }>;
|
|
||||||
}>);
|
|
||||||
|
|
||||||
return createApiResponse(users);
|
|
||||||
} catch (error) {
|
|
||||||
return ApiError.ServerError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
import { eq, and, or } from "drizzle-orm";
|
|
||||||
import { db } from "~/db";
|
|
||||||
import { studyTable, userRolesTable, rolePermissionsTable, permissionsTable, rolesTable } from "~/db/schema";
|
|
||||||
import { PERMISSIONS, checkPermissions } from "~/lib/permissions-server";
|
|
||||||
import { ApiError, createApiResponse, getEnvironment } from "~/lib/api-utils";
|
|
||||||
import { auth } from "@clerk/nextjs/server";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const { userId } = await auth();
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return ApiError.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const currentEnvironment = getEnvironment();
|
|
||||||
|
|
||||||
// Get all studies where user has any role
|
|
||||||
const studiesWithPermissions = await db
|
|
||||||
.selectDistinct({
|
|
||||||
id: studyTable.id,
|
|
||||||
title: studyTable.title,
|
|
||||||
description: studyTable.description,
|
|
||||||
createdAt: studyTable.createdAt,
|
|
||||||
updatedAt: studyTable.updatedAt,
|
|
||||||
userId: studyTable.userId,
|
|
||||||
permissionCode: permissionsTable.code,
|
|
||||||
roleName: rolesTable.name,
|
|
||||||
})
|
|
||||||
.from(studyTable)
|
|
||||||
.innerJoin(
|
|
||||||
userRolesTable,
|
|
||||||
and(
|
|
||||||
eq(userRolesTable.studyId, studyTable.id),
|
|
||||||
eq(userRolesTable.userId, userId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.innerJoin(rolesTable, eq(rolesTable.id, userRolesTable.roleId))
|
|
||||||
.leftJoin(rolePermissionsTable, eq(rolePermissionsTable.roleId, userRolesTable.roleId))
|
|
||||||
.leftJoin(permissionsTable, eq(permissionsTable.id, rolePermissionsTable.permissionId))
|
|
||||||
.where(eq(studyTable.environment, currentEnvironment));
|
|
||||||
|
|
||||||
// Group permissions and roles by study
|
|
||||||
const studies = studiesWithPermissions.reduce((acc, curr) => {
|
|
||||||
const existingStudy = acc.find(s => s.id === curr.id);
|
|
||||||
if (!existingStudy) {
|
|
||||||
acc.push({
|
|
||||||
id: curr.id,
|
|
||||||
title: curr.title,
|
|
||||||
description: curr.description,
|
|
||||||
createdAt: curr.createdAt,
|
|
||||||
updatedAt: curr.updatedAt,
|
|
||||||
userId: curr.userId,
|
|
||||||
permissions: curr.permissionCode ? [curr.permissionCode] : [],
|
|
||||||
roles: curr.roleName ? [curr.roleName] : []
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (curr.permissionCode && !existingStudy.permissions.includes(curr.permissionCode)) {
|
|
||||||
existingStudy.permissions.push(curr.permissionCode);
|
|
||||||
}
|
|
||||||
if (curr.roleName && !existingStudy.roles.includes(curr.roleName)) {
|
|
||||||
existingStudy.roles.push(curr.roleName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, [] as Array<{
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
description: string | null;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date | null;
|
|
||||||
userId: string;
|
|
||||||
permissions: string[];
|
|
||||||
roles: string[];
|
|
||||||
}>);
|
|
||||||
|
|
||||||
return createApiResponse(studies);
|
|
||||||
} catch (error) {
|
|
||||||
return ApiError.ServerError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const { userId } = await auth();
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return ApiError.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { title, description } = await request.json();
|
|
||||||
const currentEnvironment = getEnvironment();
|
|
||||||
|
|
||||||
// Create study and assign admin role in a transaction
|
|
||||||
const result = await db.transaction(async (tx) => {
|
|
||||||
// Create the study
|
|
||||||
const [study] = await tx
|
|
||||||
.insert(studyTable)
|
|
||||||
.values({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
userId: userId,
|
|
||||||
environment: currentEnvironment,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
// Look up the ADMIN role
|
|
||||||
const [adminRole] = await tx
|
|
||||||
.select()
|
|
||||||
.from(rolesTable)
|
|
||||||
.where(eq(rolesTable.name, 'admin'))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!adminRole) {
|
|
||||||
throw new Error('Admin role not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assign admin role
|
|
||||||
await tx
|
|
||||||
.insert(userRolesTable)
|
|
||||||
.values({
|
|
||||||
userId: userId,
|
|
||||||
roleId: adminRole.id,
|
|
||||||
studyId: study.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get all permissions for this role
|
|
||||||
const permissions = await tx
|
|
||||||
.select({
|
|
||||||
permissionCode: permissionsTable.code
|
|
||||||
})
|
|
||||||
.from(rolePermissionsTable)
|
|
||||||
.innerJoin(permissionsTable, eq(permissionsTable.id, rolePermissionsTable.permissionId))
|
|
||||||
.where(eq(rolePermissionsTable.roleId, adminRole.id));
|
|
||||||
|
|
||||||
return {
|
|
||||||
...study,
|
|
||||||
permissions: permissions.map(p => p.permissionCode)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return createApiResponse(result);
|
|
||||||
} catch (error) {
|
|
||||||
return ApiError.ServerError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
34
src/app/api/trpc/[trpc]/route.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||||
|
import { type NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { env } from "~/env";
|
||||||
|
import { appRouter } from "~/server/api/root";
|
||||||
|
import { createTRPCContext } from "~/server/api/trpc";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
|
||||||
|
* handling a HTTP request (e.g. when you make requests from Client Components).
|
||||||
|
*/
|
||||||
|
const createContext = async (req: NextRequest) => {
|
||||||
|
return createTRPCContext({
|
||||||
|
headers: req.headers,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handler = (req: NextRequest) =>
|
||||||
|
fetchRequestHandler({
|
||||||
|
endpoint: "/api/trpc",
|
||||||
|
req,
|
||||||
|
router: appRouter,
|
||||||
|
createContext: () => createContext(req),
|
||||||
|
onError:
|
||||||
|
env.NODE_ENV === "development"
|
||||||
|
? ({ path, error }) => {
|
||||||
|
console.error(
|
||||||
|
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
export { handler as GET, handler as POST };
|
||||||
42
src/app/api/upload/route.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getServerAuthSession } from "~/server/auth";
|
||||||
|
import { uploadToS3 } from "~/server/storage/s3";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await getServerAuthSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the form data
|
||||||
|
const formData = await req.formData();
|
||||||
|
const file = formData.get("file") as File;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "No file provided" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a unique key for the file
|
||||||
|
const key = `${session.user.id}/${nanoid()}.${file.type.split("/")[1]}`;
|
||||||
|
|
||||||
|
// Convert file to buffer and upload
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
const url = await uploadToS3(buffer, key, file.type);
|
||||||
|
|
||||||
|
console.log("File uploaded successfully:", key);
|
||||||
|
|
||||||
|
return NextResponse.json({ url });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error handling upload:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import { Webhook } from 'svix';
|
|
||||||
import { headers } from 'next/headers';
|
|
||||||
import { WebhookEvent } from '@clerk/nextjs/server';
|
|
||||||
import { db } from '~/db';
|
|
||||||
import { usersTable } from '~/db/schema';
|
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
// Get the headers
|
|
||||||
const headerPayload = headers();
|
|
||||||
const svix_id = headerPayload.get("svix-id");
|
|
||||||
const svix_timestamp = headerPayload.get("svix-timestamp");
|
|
||||||
const svix_signature = headerPayload.get("svix-signature");
|
|
||||||
|
|
||||||
// If there are no headers, error out
|
|
||||||
if (!svix_id || !svix_timestamp || !svix_signature) {
|
|
||||||
return new Response('Error occured -- no svix headers', {
|
|
||||||
status: 400
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the body
|
|
||||||
const payload = await req.json();
|
|
||||||
const body = JSON.stringify(payload);
|
|
||||||
|
|
||||||
// Create a new Svix instance with your webhook secret
|
|
||||||
const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET || '');
|
|
||||||
|
|
||||||
let evt: WebhookEvent;
|
|
||||||
|
|
||||||
// Verify the payload with the headers
|
|
||||||
try {
|
|
||||||
evt = wh.verify(body, {
|
|
||||||
"svix-id": svix_id,
|
|
||||||
"svix-timestamp": svix_timestamp,
|
|
||||||
"svix-signature": svix_signature,
|
|
||||||
}) as WebhookEvent;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error verifying webhook:', err);
|
|
||||||
return new Response('Error occured', {
|
|
||||||
status: 400
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle the webhook
|
|
||||||
const eventType = evt.type;
|
|
||||||
console.log(`Webhook received: ${eventType}`);
|
|
||||||
|
|
||||||
if (eventType === 'user.created' || eventType === 'user.updated') {
|
|
||||||
const { id, email_addresses, first_name, last_name, image_url } = evt.data;
|
|
||||||
const primaryEmail = email_addresses?.[0]?.email_address;
|
|
||||||
|
|
||||||
// Create or update user in our database
|
|
||||||
await db.insert(usersTable).values({
|
|
||||||
id,
|
|
||||||
email: primaryEmail,
|
|
||||||
name: [first_name, last_name].filter(Boolean).join(' ') || null,
|
|
||||||
imageUrl: image_url,
|
|
||||||
}).onConflictDoUpdate({
|
|
||||||
target: usersTable.id,
|
|
||||||
set: {
|
|
||||||
email: primaryEmail,
|
|
||||||
name: [first_name, last_name].filter(Boolean).join(' ') || null,
|
|
||||||
imageUrl: image_url,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`${eventType === 'user.created' ? 'Created' : 'Updated'} user in database: ${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response('', { status: 200 });
|
|
||||||
}
|
|
||||||
78
src/app/auth/signin/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getServerAuthSession } from "~/server/auth";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import { SignInForm } from "~/components/auth/sign-in-form";
|
||||||
|
import { Logo } from "~/components/logo";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Sign In | HRIStudio",
|
||||||
|
description: "Sign in to your account",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function SignInPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||||
|
}) {
|
||||||
|
const session = await getServerAuthSession();
|
||||||
|
if (session) {
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = await searchParams;
|
||||||
|
const error = params?.error ? String(params.error) : null;
|
||||||
|
const showError = error === "CredentialsSignin";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
|
||||||
|
<Logo
|
||||||
|
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
|
||||||
|
iconClassName="text-primary"
|
||||||
|
/>
|
||||||
|
<div className="w-full max-w-[800px] px-4 py-8">
|
||||||
|
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
|
||||||
|
<CardContent className="grid p-0 md:grid-cols-2">
|
||||||
|
<div className="p-6 md:p-8">
|
||||||
|
<div className="mb-6 space-y-2">
|
||||||
|
<CardTitle className="text-2xl font-bold tracking-tight">
|
||||||
|
Welcome back
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
Sign in to your account to continue
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<SignInForm error={showError} />
|
||||||
|
</div>
|
||||||
|
<div className="relative hidden h-full md:block">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<Logo
|
||||||
|
className="pointer-events-none"
|
||||||
|
iconClassName="h-32 w-32 mr-0 text-primary/40"
|
||||||
|
textClassName="sr-only"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<p className="mt-6 text-center text-sm text-muted-foreground">
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<Link
|
||||||
|
href={`/auth/signup${params?.callbackUrl ? `?callbackUrl=${params.callbackUrl}` : ''}`}
|
||||||
|
className="underline underline-offset-4 hover:text-primary"
|
||||||
|
>
|
||||||
|
Sign up
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
src/app/auth/signup/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getServerAuthSession } from "~/server/auth";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import { SignUpForm } from "~/components/auth/sign-up-form";
|
||||||
|
import { Logo } from "~/components/logo";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Sign Up | HRIStudio",
|
||||||
|
description: "Create a new account",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function SignUpPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||||
|
}) {
|
||||||
|
const session = await getServerAuthSession();
|
||||||
|
if (session) {
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = await searchParams;
|
||||||
|
const error = params?.error ? String(params.error) : null;
|
||||||
|
const showError = error === "CredentialsSignin";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
|
||||||
|
<Logo
|
||||||
|
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
|
||||||
|
iconClassName="text-primary"
|
||||||
|
/>
|
||||||
|
<div className="w-full max-w-[800px] px-4 py-8">
|
||||||
|
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
|
||||||
|
<CardContent className="grid p-0 md:grid-cols-2">
|
||||||
|
<div className="p-6 md:p-8">
|
||||||
|
<div className="mb-6 space-y-2">
|
||||||
|
<CardTitle className="text-2xl font-bold tracking-tight">
|
||||||
|
Create an account
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
Get started with HRIStudio
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<SignUpForm error={showError} />
|
||||||
|
</div>
|
||||||
|
<div className="relative hidden h-full md:block">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<Logo
|
||||||
|
className="pointer-events-none"
|
||||||
|
iconClassName="h-32 w-32 mr-0 text-primary/40"
|
||||||
|
textClassName="sr-only"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<p className="mt-6 text-center text-sm text-muted-foreground">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Link
|
||||||
|
href={`/auth/signin${params?.callbackUrl ? `?callbackUrl=${params.callbackUrl}` : ''}`}
|
||||||
|
className="underline underline-offset-4 hover:text-primary"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
280
src/app/dashboard/account/page.tsx
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useSession } from "next-auth/react"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { ImageIcon, Loader2 } from "lucide-react"
|
||||||
|
import Image from "next/image"
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card"
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "~/components/ui/form"
|
||||||
|
import { Input } from "~/components/ui/input"
|
||||||
|
import { PageContent } from "~/components/layout/page-content"
|
||||||
|
import { PageHeader } from "~/components/layout/page-header"
|
||||||
|
import { api } from "~/trpc/react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { uploadFile } from "~/lib/upload"
|
||||||
|
import { ImageCropModal } from "~/components/ui/image-crop-modal"
|
||||||
|
|
||||||
|
const accountFormSchema = z.object({
|
||||||
|
firstName: z.string().min(1, "First name is required"),
|
||||||
|
lastName: z.string().min(1, "Last name is required"),
|
||||||
|
email: z.string().email("Invalid email address").optional(),
|
||||||
|
image: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type AccountFormValues = z.infer<typeof accountFormSchema>
|
||||||
|
|
||||||
|
export default function AccountPage() {
|
||||||
|
const { data: session, update: updateSession } = useSession()
|
||||||
|
const [cropFile, setCropFile] = useState<File | null>(null)
|
||||||
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
|
const [previewImage, setPreviewImage] = useState<string | null>(null)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// Debug full session object
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("[Debug] Full session object:", JSON.stringify(session, null, 2));
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
const form = useForm<AccountFormValues>({
|
||||||
|
resolver: zodResolver(accountFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
firstName: session?.user?.name?.split(" ")[0] ?? "",
|
||||||
|
lastName: session?.user?.name?.split(" ")[1] ?? "",
|
||||||
|
email: session?.user?.email ?? "",
|
||||||
|
image: session?.user?.image ?? undefined,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateUser = api.user.update.useMutation();
|
||||||
|
|
||||||
|
const onSubmit = async (data: AccountFormValues) => {
|
||||||
|
try {
|
||||||
|
console.log("[1] Starting update with form data:", data);
|
||||||
|
|
||||||
|
// 1. Update database
|
||||||
|
const result = await updateUser.mutateAsync({
|
||||||
|
firstName: data.firstName,
|
||||||
|
lastName: data.lastName,
|
||||||
|
image: data.image ?? null,
|
||||||
|
});
|
||||||
|
console.log("[2] Database updated:", result);
|
||||||
|
|
||||||
|
// 2. Show success message
|
||||||
|
toast.success("Profile updated");
|
||||||
|
console.log("[3] Showing success toast");
|
||||||
|
|
||||||
|
// 3. Force a hard reload of the page
|
||||||
|
console.log("[4] Forcing page reload");
|
||||||
|
window.location.reload();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[X] Update failed:", error);
|
||||||
|
toast.error("Failed to update profile");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCrop = async (blob: Blob) => {
|
||||||
|
try {
|
||||||
|
setIsUploading(true);
|
||||||
|
|
||||||
|
const file = new File([blob], cropFile?.name ?? "avatar.jpg", {
|
||||||
|
type: cropFile?.type ?? "image/jpeg",
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageUrl = await uploadFile(file);
|
||||||
|
|
||||||
|
setPreviewImage(imageUrl);
|
||||||
|
form.setValue("image", imageUrl);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error uploading file:", error);
|
||||||
|
toast.error("Error uploading image");
|
||||||
|
setPreviewImage(null);
|
||||||
|
form.setValue("image", undefined);
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
setCropFile(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Account Settings"
|
||||||
|
description="Manage your profile information"
|
||||||
|
/>
|
||||||
|
<PageContent>
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Profile</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Update your profile picture and information
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||||
|
<div className="flex flex-col gap-8 sm:flex-row">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="image"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col items-start justify-start">
|
||||||
|
<FormLabel className="group flex size-32 cursor-pointer items-center justify-center overflow-hidden rounded-full border bg-muted transition-colors hover:bg-muted/80">
|
||||||
|
{previewImage ? (
|
||||||
|
<div className="relative size-full overflow-hidden rounded-full">
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/60 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<p className="text-xs font-medium text-white">Change Image</p>
|
||||||
|
</div>
|
||||||
|
<Image
|
||||||
|
src={previewImage}
|
||||||
|
alt="Avatar"
|
||||||
|
fill
|
||||||
|
sizes="128px"
|
||||||
|
className="object-cover"
|
||||||
|
priority
|
||||||
|
onError={(e) => {
|
||||||
|
console.error("Error loading image:", previewImage);
|
||||||
|
e.currentTarget.style.display = "none";
|
||||||
|
setPreviewImage(null);
|
||||||
|
form.setValue("image", undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ImageIcon className="size-8 text-muted-foreground transition-colors group-hover:text-muted-foreground/80" />
|
||||||
|
)}
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
setCropFile(file);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isUploading}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Click to upload a new profile picture
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="firstName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>First Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter your first name"
|
||||||
|
{...field}
|
||||||
|
disabled={updateUser.isPending}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="lastName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Last Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter your last name"
|
||||||
|
{...field}
|
||||||
|
disabled={updateUser.isPending}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="Enter your email"
|
||||||
|
{...field}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Email cannot be changed
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={updateUser.isPending || isUploading}
|
||||||
|
>
|
||||||
|
{(updateUser.isPending || isUploading) && (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</PageContent>
|
||||||
|
{cropFile && (
|
||||||
|
<ImageCropModal
|
||||||
|
file={cropFile}
|
||||||
|
aspect={1}
|
||||||
|
onCrop={handleCrop}
|
||||||
|
onCancel={() => setCropFile(null)}
|
||||||
|
className="sm:max-w-md"
|
||||||
|
cropBoxClassName="rounded-full border-2 border-primary shadow-2xl"
|
||||||
|
overlayClassName="bg-background/80 backdrop-blur-sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
41
src/app/dashboard/experiments/[id]/designer/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ExperimentDesigner } from "~/components/experiments/experiment-designer";
|
||||||
|
import { PageContent } from "~/components/layout/page-content";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { PanelLeft } from "lucide-react";
|
||||||
|
import { useSidebar } from "~/components/ui/sidebar";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
export default function ExperimentDesignerPage() {
|
||||||
|
const { state, setOpen } = useSidebar();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
||||||
|
<PageHeader className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Experiment Designer</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Design your experiment workflow by dragging and connecting actions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setOpen(state === "expanded" ? false : true)}
|
||||||
|
>
|
||||||
|
<PanelLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
<PageContent className={cn(
|
||||||
|
"flex-1 overflow-hidden p-0",
|
||||||
|
// Adjust margin when app sidebar is collapsed
|
||||||
|
state === "collapsed" && "ml-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]",
|
||||||
|
state === "expanded" && "ml-[calc(var(--sidebar-width))]"
|
||||||
|
)}>
|
||||||
|
<ExperimentDesigner />
|
||||||
|
</PageContent>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,26 +1,104 @@
|
|||||||
import { Sidebar } from "~/components/sidebar";
|
"use client"
|
||||||
import { Breadcrumb } from "~/components/breadcrumb";
|
|
||||||
import { ActiveStudyProvider } from "~/context/active-study";
|
import { useEffect } from "react"
|
||||||
import { StudyProvider } from "~/context/StudyContext";
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useSession } from "next-auth/react"
|
||||||
|
import { api } from "~/trpc/react"
|
||||||
|
|
||||||
|
import { AppSidebar } from "~/components/navigation/app-sidebar"
|
||||||
|
import { Header } from "~/components/navigation/header"
|
||||||
|
import { SidebarProvider } from "~/components/ui/sidebar"
|
||||||
|
import { StudyProvider } from "~/components/providers/study-provider"
|
||||||
|
import { PluginStoreProvider } from "~/components/providers/plugin-store-provider"
|
||||||
|
import { PageTransition } from "~/components/layout/page-transition"
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
|
const { data: session, status } = useSession()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// Get user's studies
|
||||||
|
const { data: studies, isLoading: isLoadingStudies } = api.study.getMyStudies.useQuery(
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
enabled: status === "authenticated",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === "unauthenticated") {
|
||||||
|
router.replace("/auth/signin")
|
||||||
|
}
|
||||||
|
}, [status, router])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Only redirect if we've loaded studies and user has none, and we're not already on onboarding
|
||||||
|
if (!isLoadingStudies && studies && studies.length === 0 && !window.location.pathname.includes("/onboarding")) {
|
||||||
|
router.replace("/onboarding")
|
||||||
|
}
|
||||||
|
}, [studies, isLoadingStudies, router])
|
||||||
|
|
||||||
|
// Show nothing while loading
|
||||||
|
if (status === "loading" || isLoadingStudies) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show nothing if not authenticated (will redirect)
|
||||||
|
if (!session) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show nothing if no studies (will redirect to onboarding)
|
||||||
|
if (studies && studies.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActiveStudyProvider>
|
<SidebarProvider>
|
||||||
|
<PluginStoreProvider>
|
||||||
<StudyProvider>
|
<StudyProvider>
|
||||||
<div className="flex h-screen">
|
<div className="flex h-full min-h-screen w-full bg-muted/40 dark:bg-background relative">
|
||||||
<Sidebar />
|
{/* Background Elements */}
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<div className="pointer-events-none fixed inset-0 z-0">
|
||||||
<main className="flex-1 overflow-y-auto p-6">
|
{/* Base Gradient */}
|
||||||
<Breadcrumb />
|
<div className="absolute inset-0 bg-gradient-to-b from-background via-primary/10 to-background" />
|
||||||
|
|
||||||
|
{/* Gradient Orb */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="absolute h-[1200px] w-[1200px] left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||||
|
<div className="absolute inset-0 rounded-full bg-gradient-to-r from-primary/30 via-secondary/30 to-background opacity-60 blur-3xl dark:opacity-40 animate-gradient" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dotted Pattern */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-[0.35] dark:opacity-[0.15]"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `
|
||||||
|
radial-gradient(circle at 1px 1px, hsl(var(--primary)/0.5) 1px, transparent 0)
|
||||||
|
`,
|
||||||
|
backgroundSize: '32px 32px',
|
||||||
|
maskImage: 'linear-gradient(to bottom, transparent, black 10%, black 90%, transparent)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<AppSidebar className="z-20" />
|
||||||
|
<div className="flex w-0 flex-1 flex-col z-10">
|
||||||
|
<Header/>
|
||||||
|
<main className="flex-1 overflow-auto p-4">
|
||||||
|
<PageTransition>
|
||||||
{children}
|
{children}
|
||||||
|
</PageTransition>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</StudyProvider>
|
</StudyProvider>
|
||||||
</ActiveStudyProvider>
|
</PluginStoreProvider>
|
||||||
);
|
</SidebarProvider>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -1,159 +1,68 @@
|
|||||||
'use client';
|
import { Beaker, Plus, Users } from "lucide-react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"
|
||||||
|
import { PageContent } from "~/components/layout/page-content"
|
||||||
|
import { PageHeader } from "~/components/layout/page-header"
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
export default function DashboardPage() {
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { BookOpen, Settings2 } from "lucide-react";
|
|
||||||
import { useToast } from "~/hooks/use-toast";
|
|
||||||
import { getApiUrl } from "~/lib/fetch-utils";
|
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
|
||||||
import { useActiveStudy } from "~/context/active-study";
|
|
||||||
|
|
||||||
interface DashboardStats {
|
|
||||||
studyCount: number;
|
|
||||||
activeInvitationCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
|
||||||
const [stats, setStats] = useState<DashboardStats>({
|
|
||||||
studyCount: 0,
|
|
||||||
activeInvitationCount: 0,
|
|
||||||
});
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const router = useRouter();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { studies, setActiveStudy } = useActiveStudy();
|
|
||||||
|
|
||||||
const fetchStats = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(getApiUrl('/api/studies'));
|
|
||||||
if (!response.ok) throw new Error("Failed to fetch studies");
|
|
||||||
const { data } = await response.json();
|
|
||||||
setStats({
|
|
||||||
studyCount: data.length,
|
|
||||||
activeInvitationCount: 0
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching stats:", error);
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to load dashboard statistics",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [toast]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchStats();
|
|
||||||
}, [fetchStats]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<>
|
||||||
<div className="flex items-center justify-between">
|
<PageHeader
|
||||||
<div>
|
title="Dashboard"
|
||||||
<Skeleton className="h-8 w-[200px] mb-2" />
|
description="Welcome to your research platform."
|
||||||
<Skeleton className="h-4 w-[300px]" />
|
/>
|
||||||
</div>
|
<PageContent>
|
||||||
<Skeleton className="h-10 w-[140px]" />
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-3">
|
|
||||||
{[1, 2].map((i) => (
|
|
||||||
<Card key={i}>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<Skeleton className="h-4 w-[100px]" />
|
|
||||||
<Skeleton className="h-4 w-4" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Skeleton className="h-7 w-[50px] mb-1" />
|
|
||||||
<Skeleton className="h-3 w-[120px]" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<Skeleton className="h-5 w-[120px] mb-2" />
|
|
||||||
<Skeleton className="h-4 w-[200px]" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex gap-4">
|
|
||||||
<Skeleton className="h-10 w-[140px]" />
|
|
||||||
<Skeleton className="h-10 w-[120px]" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Dashboard</h2>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Welcome back to your research dashboard
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={() => router.push('/dashboard/studies/new')}>
|
|
||||||
Create New Study
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-3">
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">
|
<CardTitle className="text-sm font-medium">Total Studies</CardTitle>
|
||||||
Total Studies
|
<Beaker className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardTitle>
|
|
||||||
<BookOpen className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{stats.studyCount}</div>
|
<div className="text-2xl font-bold">0</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Active research studies
|
Active research studies
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">
|
<CardTitle className="text-sm font-medium">Total Participants</CardTitle>
|
||||||
Pending Invitations
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardTitle>
|
|
||||||
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{stats.activeInvitationCount}</div>
|
<div className="text-2xl font-bold">0</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Awaiting responses
|
Across all studies
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle>Quick Actions</CardTitle>
|
<CardTitle className="text-sm font-medium">Quick Actions</CardTitle>
|
||||||
<CardDescription>Common tasks and actions</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex gap-4">
|
<CardContent>
|
||||||
<Button onClick={() => router.push('/dashboard/studies/new')}>
|
<Button asChild variant="outline" className="w-full">
|
||||||
|
<Link href="/dashboard/studies/new">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Create New Study
|
Create New Study
|
||||||
</Button>
|
</Link>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => router.push('/dashboard/settings')}
|
|
||||||
>
|
|
||||||
<Settings2 className="w-4 h-4 mr-2" />
|
|
||||||
Settings
|
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Recent Activity</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No recent activity to show.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</PageContent>
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { PlusIcon, Trash2Icon } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter
|
|
||||||
} from "~/components/ui/card";
|
|
||||||
import { Input } from "~/components/ui/input";
|
|
||||||
import { Label } from "~/components/ui/label";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue
|
|
||||||
} from "~/components/ui/select";
|
|
||||||
import { usePermissions } from "~/hooks/usePermissions";
|
|
||||||
import { getApiUrl } from "~/lib/fetch-utils";
|
|
||||||
|
|
||||||
interface Study {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Participant {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
studyId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Participants() {
|
|
||||||
const [studies, setStudies] = useState<Study[]>([]);
|
|
||||||
const [participants, setParticipants] = useState<Participant[]>([]);
|
|
||||||
const [selectedStudyId, setSelectedStudyId] = useState<number | null>(null);
|
|
||||||
const [participantName, setParticipantName] = useState("");
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const { hasPermission } = usePermissions();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchStudies();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchStudies = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(getApiUrl('/api/studies'));
|
|
||||||
const data = await response.json();
|
|
||||||
setStudies(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching studies:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchParticipants = async (studyId: number) => {
|
|
||||||
try {
|
|
||||||
console.log(`Fetching participants for studyId: ${studyId}`);
|
|
||||||
const response = await fetch(getApiUrl(`/api/participants?studyId=${studyId}`));
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
setParticipants(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching participants:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStudyChange = (studyId: string) => {
|
|
||||||
const id = parseInt(studyId); // Convert the string to a number
|
|
||||||
setSelectedStudyId(id);
|
|
||||||
fetchParticipants(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addParticipant = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!selectedStudyId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(getApiUrl('/api/participants'), {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: participantName,
|
|
||||||
studyId: selectedStudyId,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const newParticipant = await response.json();
|
|
||||||
setParticipants([...participants, newParticipant]);
|
|
||||||
setParticipantName("");
|
|
||||||
} else {
|
|
||||||
console.error('Error adding participant:', response.statusText);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error adding participant:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteParticipant = async (id: number) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(getApiUrl('/api/participants/${id}'), {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setParticipants(participants.filter(participant => participant.id !== id));
|
|
||||||
} else {
|
|
||||||
console.error('Error deleting participant:', response.statusText);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting participant:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <div>Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-4xl mx-auto">
|
|
||||||
<div className="flex justify-between items-center mb-8">
|
|
||||||
<h1 className="text-3xl font-bold">Participants</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="mb-8">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Study Selection</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Select a study to manage its participants
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="study">Select Study</Label>
|
|
||||||
<Select onValueChange={handleStudyChange}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a study" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{studies.map((study) => (
|
|
||||||
<SelectItem key={study.id} value={study.id.toString()}>
|
|
||||||
{study.title}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="mb-8">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Add New Participant</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Add a new participant to the selected study
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form onSubmit={addParticipant} className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name">Participant Name</Label>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
id="name"
|
|
||||||
value={participantName}
|
|
||||||
onChange={(e) => setParticipantName(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button type="submit" disabled={!selectedStudyId}>
|
|
||||||
<PlusIcon className="w-4 h-4 mr-2" />
|
|
||||||
Add Participant
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid gap-4">
|
|
||||||
{participants.map((participant) => (
|
|
||||||
<Card key={participant.id}>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div>
|
|
||||||
<CardTitle>{participant.name}</CardTitle>
|
|
||||||
<CardDescription className="mt-1.5">
|
|
||||||
Participant ID: {participant.id}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
{hasPermission('DELETE_PARTICIPANT') && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="text-destructive"
|
|
||||||
onClick={() => deleteParticipant(participant.id)}
|
|
||||||
>
|
|
||||||
<Trash2Icon className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardFooter className="text-sm text-muted-foreground">
|
|
||||||
Study ID: {participant.studyId}
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
{participants.length === 0 && selectedStudyId && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="py-8">
|
|
||||||
<p className="text-center text-muted-foreground">
|
|
||||||
No participants found for this study. Add one above to get started.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
{!selectedStudyId && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="py-8">
|
|
||||||
<p className="text-center text-muted-foreground">
|
|
||||||
Please select a study to view its participants.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
32
src/app/dashboard/store/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { api } from "~/trpc/server";
|
||||||
|
import { PluginBrowser } from "~/components/store/plugin-browser";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { PageContent } from "~/components/layout/page-content";
|
||||||
|
import { AddRepositoryDialog } from "~/components/store/add-repository-dialog";
|
||||||
|
import { getCaller } from "~/trpc/server";
|
||||||
|
|
||||||
|
export default async function StorePage() {
|
||||||
|
// Fetch both plugins and repositories using tRPC
|
||||||
|
const caller = await getCaller();
|
||||||
|
const [plugins, repositories] = await Promise.all([
|
||||||
|
caller.pluginStore.getPlugins(),
|
||||||
|
caller.pluginStore.getRepositories(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Robot Store"
|
||||||
|
description="Browse and manage robot plugins"
|
||||||
|
>
|
||||||
|
<AddRepositoryDialog />
|
||||||
|
</PageHeader>
|
||||||
|
<PageContent>
|
||||||
|
<PluginBrowser
|
||||||
|
repositories={repositories}
|
||||||
|
initialPlugins={plugins}
|
||||||
|
/>
|
||||||
|
</PageContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/app/dashboard/studies/[id]/delete-study-button.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "~/components/ui/alert-dialog";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
|
||||||
|
interface DeleteStudyButtonProps {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeleteStudyButton({ id }: DeleteStudyButtonProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const { mutate: deleteStudy, isLoading } = api.study.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
router.push("/studies");
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="destructive">
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This action cannot be undone. This will permanently delete the study
|
||||||
|
and all associated data.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => deleteStudy({ id })}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? "Deleting..." : "Delete"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
src/app/dashboard/studies/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { StudyForm, type StudyFormValues } from "~/components/studies/study-form";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { PageContent } from "~/components/layout/page-content";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { use } from "react";
|
||||||
|
import { CardSkeleton } from "~/components/ui/skeleton";
|
||||||
|
|
||||||
|
export default function EditStudyPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const resolvedParams = use(params);
|
||||||
|
const id = Number(resolvedParams.id);
|
||||||
|
|
||||||
|
const { data: study, isLoading: isLoadingStudy } = api.study.getById.useQuery(
|
||||||
|
{ id }
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutate: updateStudy, isPending: isUpdating } = api.study.update.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
router.push(`/dashboard/studies/${id}`);
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSubmit(data: StudyFormValues) {
|
||||||
|
updateStudy({ id, ...data });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoadingStudy) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Edit Study"
|
||||||
|
description="Loading study details..."
|
||||||
|
/>
|
||||||
|
<PageContent className="max-w-2xl">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Study Details</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Please wait while we load the study information.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CardSkeleton />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</PageContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!study) {
|
||||||
|
return <div>Study not found</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Edit Study"
|
||||||
|
description="Update study details"
|
||||||
|
/>
|
||||||
|
<PageContent className="max-w-2xl">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Study Details</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Update the information for your study.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<StudyForm
|
||||||
|
defaultValues={{ title: study.title, description: study.description ?? "" }}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
isSubmitting={isUpdating}
|
||||||
|
submitLabel="Save Changes"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</PageContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { PageContent } from "~/components/layout/page-content";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { use } from "react";
|
||||||
|
import { ExperimentDesigner } from "~/components/experiments/experiment-designer";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
|
import { useToast } from "~/hooks/use-toast";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { type Step } from "~/lib/experiments/types";
|
||||||
|
|
||||||
|
export default function EditExperimentPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string; experimentId: string }>;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const resolvedParams = use(params);
|
||||||
|
const studyId = Number(resolvedParams.id);
|
||||||
|
const experimentId = Number(resolvedParams.experimentId);
|
||||||
|
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [steps, setSteps] = useState<Step[]>([]);
|
||||||
|
|
||||||
|
const { data: study } = api.study.getById.useQuery({ id: studyId });
|
||||||
|
const { data: experiment, isLoading } = api.experiment.getById.useQuery({ id: experimentId });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (experiment) {
|
||||||
|
setTitle(experiment.title);
|
||||||
|
setDescription(experiment.description ?? "");
|
||||||
|
setSteps(experiment.steps);
|
||||||
|
}
|
||||||
|
}, [experiment]);
|
||||||
|
|
||||||
|
const { mutate: updateExperiment, isPending: isUpdating } = api.experiment.update.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Experiment updated successfully",
|
||||||
|
});
|
||||||
|
router.push(`/dashboard/studies/${studyId}/experiments/${experimentId}`);
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const canEdit = study && ["OWNER", "ADMIN", "PRINCIPAL_INVESTIGATOR"]
|
||||||
|
.map(r => r.toLowerCase())
|
||||||
|
.includes(study.role.toLowerCase());
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Loading..."
|
||||||
|
description="Please wait while we load the experiment details"
|
||||||
|
/>
|
||||||
|
<PageContent>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className="animate-pulse">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="h-6 w-1/3 bg-muted rounded" />
|
||||||
|
<div className="h-4 w-1/2 bg-muted rounded mt-2" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-4 w-1/4 bg-muted rounded" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</PageContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!study || !experiment) {
|
||||||
|
return <div>Not found</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canEdit) {
|
||||||
|
return <div>You do not have permission to edit this experiment.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Edit Experiment"
|
||||||
|
description={`Update experiment details for ${study.title}`}
|
||||||
|
/>
|
||||||
|
<PageContent>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Experiment Details</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Update the basic information for your experiment.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="title" className="text-sm font-medium">
|
||||||
|
Title
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
placeholder="Enter experiment title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="description" className="text-sm font-medium">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
placeholder="Enter experiment description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Design Experiment</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Use the designer below to update your experiment flow.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<ExperimentDesigner
|
||||||
|
defaultSteps={steps}
|
||||||
|
onChange={setSteps}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push(`/dashboard/studies/${studyId}/experiments/${experimentId}`)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
updateExperiment({
|
||||||
|
id: experimentId,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
steps,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isUpdating || !title}
|
||||||
|
>
|
||||||
|
{isUpdating ? "Saving..." : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { PageContent } from "~/components/layout/page-content";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Pencil as PencilIcon, Play, Archive } from "lucide-react";
|
||||||
|
import { use } from "react";
|
||||||
|
import { ExperimentDesigner } from "~/components/experiments/experiment-designer";
|
||||||
|
|
||||||
|
export default function ExperimentDetailsPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string; experimentId: string }>;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const resolvedParams = use(params);
|
||||||
|
const studyId = Number(resolvedParams.id);
|
||||||
|
const experimentId = Number(resolvedParams.experimentId);
|
||||||
|
|
||||||
|
const { data: study } = api.study.getById.useQuery({ id: studyId });
|
||||||
|
const { data: experiment, isLoading } = api.experiment.getById.useQuery({ id: experimentId });
|
||||||
|
|
||||||
|
const { mutate: updateExperiment } = api.experiment.update.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const canEdit = study && ["OWNER", "ADMIN", "PRINCIPAL_INVESTIGATOR"]
|
||||||
|
.map(r => r.toLowerCase())
|
||||||
|
.includes(study.role.toLowerCase());
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Loading..."
|
||||||
|
description="Please wait while we load the experiment details"
|
||||||
|
/>
|
||||||
|
<PageContent>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className="animate-pulse">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="h-6 w-1/3 bg-muted rounded" />
|
||||||
|
<div className="h-4 w-1/2 bg-muted rounded mt-2" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-4 w-1/4 bg-muted rounded" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</PageContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!study || !experiment) {
|
||||||
|
return <div>Not found</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title={experiment.title}
|
||||||
|
description={experiment.description ?? "No description provided"}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={
|
||||||
|
experiment.status === "active" ? "default" :
|
||||||
|
experiment.status === "archived" ? "secondary" :
|
||||||
|
"outline"
|
||||||
|
}>
|
||||||
|
{experiment.status}
|
||||||
|
</Badge>
|
||||||
|
{canEdit && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => router.push(`/dashboard/studies/${studyId}/experiments/${experimentId}/edit`)}
|
||||||
|
>
|
||||||
|
<PencilIcon className="h-4 w-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
{experiment.status === "draft" ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => updateExperiment({
|
||||||
|
id: experimentId,
|
||||||
|
title: experiment.title,
|
||||||
|
description: experiment.description,
|
||||||
|
status: "active",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4 mr-2" />
|
||||||
|
Activate
|
||||||
|
</Button>
|
||||||
|
) : experiment.status === "active" ? (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => updateExperiment({
|
||||||
|
id: experimentId,
|
||||||
|
title: experiment.title,
|
||||||
|
description: experiment.description,
|
||||||
|
status: "archived",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Archive className="h-4 w-4 mr-2" />
|
||||||
|
Archive
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PageHeader>
|
||||||
|
<PageContent>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Experiment Flow</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
View the steps and actions in this experiment.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<ExperimentDesigner
|
||||||
|
defaultSteps={experiment.steps}
|
||||||
|
onChange={canEdit ? (steps) => {
|
||||||
|
updateExperiment({
|
||||||
|
id: experimentId,
|
||||||
|
title: experiment.title,
|
||||||
|
description: experiment.description,
|
||||||
|
steps,
|
||||||
|
});
|
||||||
|
} : undefined}
|
||||||
|
readOnly={!canEdit}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</PageContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
src/app/dashboard/studies/[id]/experiments/new/page.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { PageContent } from "~/components/layout/page-content";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { use } from "react";
|
||||||
|
import { ExperimentDesigner } from "~/components/experiments/experiment-designer";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
|
import { useToast } from "~/hooks/use-toast";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { type Step } from "~/lib/experiments/types";
|
||||||
|
|
||||||
|
export default function NewExperimentPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const resolvedParams = use(params);
|
||||||
|
const studyId = Number(resolvedParams.id);
|
||||||
|
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [steps, setSteps] = useState<Step[]>([]);
|
||||||
|
|
||||||
|
const { data: study } = api.study.getById.useQuery({ id: studyId });
|
||||||
|
|
||||||
|
const { mutate: createExperiment, isPending: isCreating } = api.experiment.create.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Experiment created successfully",
|
||||||
|
});
|
||||||
|
router.push(`/dashboard/studies/${studyId}/experiments/${data.id}`);
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const canCreateExperiments = study && ["OWNER", "ADMIN", "PRINCIPAL_INVESTIGATOR"]
|
||||||
|
.map(r => r.toLowerCase())
|
||||||
|
.includes(study.role.toLowerCase());
|
||||||
|
|
||||||
|
if (!study) {
|
||||||
|
return <div>Study not found</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canCreateExperiments) {
|
||||||
|
return <div>You do not have permission to create experiments in this study.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Create Experiment"
|
||||||
|
description={`Design a new experiment for ${study.title}`}
|
||||||
|
/>
|
||||||
|
<PageContent>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Experiment Details</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter the basic information for your experiment.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="title" className="text-sm font-medium">
|
||||||
|
Title
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
placeholder="Enter experiment title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="description" className="text-sm font-medium">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
placeholder="Enter experiment description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Design Experiment</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Use the designer below to create your experiment flow.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<ExperimentDesigner
|
||||||
|
onChange={setSteps}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push(`/dashboard/studies/${studyId}/experiments`)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
createExperiment({
|
||||||
|
studyId,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
steps,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isCreating || !title}
|
||||||
|
>
|
||||||
|
{isCreating ? "Creating..." : "Create Experiment"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
src/app/dashboard/studies/[id]/experiments/page.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { PageContent } from "~/components/layout/page-content";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { Plus as PlusIcon, FlaskConical } from "lucide-react";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { use } from "react";
|
||||||
|
|
||||||
|
export default function ExperimentsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const resolvedParams = use(params);
|
||||||
|
const studyId = Number(resolvedParams.id);
|
||||||
|
|
||||||
|
const { data: study } = api.study.getById.useQuery({ id: studyId });
|
||||||
|
const { data: experiments, isLoading } = api.experiment.getByStudyId.useQuery({ studyId });
|
||||||
|
|
||||||
|
const canCreateExperiments = study && ["OWNER", "ADMIN", "PRINCIPAL_INVESTIGATOR"]
|
||||||
|
.map(r => r.toLowerCase())
|
||||||
|
.includes(study.role.toLowerCase());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Experiments"
|
||||||
|
description={study ? `Manage experiments for ${study.title}` : "Loading..."}
|
||||||
|
>
|
||||||
|
{canCreateExperiments && (
|
||||||
|
<Button
|
||||||
|
onClick={() => router.push(`/dashboard/studies/${studyId}/experiments/new`)}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-4 w-4 mr-2" />
|
||||||
|
New Experiment
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</PageHeader>
|
||||||
|
<PageContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="grid gap-6">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<Card key={i} className="animate-pulse">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="h-6 w-1/3 bg-muted rounded" />
|
||||||
|
<div className="h-4 w-1/2 bg-muted rounded mt-2" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-4 w-1/4 bg-muted rounded" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : !experiments || experiments.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FlaskConical className="h-5 w-5" />
|
||||||
|
No Experiments
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{canCreateExperiments
|
||||||
|
? "Get started by creating your first experiment."
|
||||||
|
: "No experiments have been created for this study yet."}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-6">
|
||||||
|
{experiments.map((experiment) => (
|
||||||
|
<Card
|
||||||
|
key={experiment.id}
|
||||||
|
className="hover:bg-muted/50 cursor-pointer transition-colors"
|
||||||
|
onClick={() => router.push(`/dashboard/studies/${studyId}/experiments/${experiment.id}`)}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle>{experiment.title}</CardTitle>
|
||||||
|
<Badge variant={
|
||||||
|
experiment.status === "active" ? "default" :
|
||||||
|
experiment.status === "archived" ? "secondary" :
|
||||||
|
"outline"
|
||||||
|
}>
|
||||||
|
{experiment.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
{experiment.description || "No description provided"}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Version {experiment.version} • {experiment.steps.length} steps
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PageContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useActiveStudy } from "~/context/active-study";
|
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
|
||||||
|
|
||||||
export default function StudyLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const { id } = useParams();
|
|
||||||
const { studies, activeStudy, setActiveStudy, isLoading } = useActiveStudy();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (studies.length > 0 && id) {
|
|
||||||
const study = studies.find(s => s.id === parseInt(id as string));
|
|
||||||
if (study && (!activeStudy || activeStudy.id !== study.id)) {
|
|
||||||
setActiveStudy(study);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [id, studies, activeStudy, setActiveStudy]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="h-6">
|
|
||||||
<Skeleton className="h-4 w-[250px]" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-[400px]" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return children;
|
|
||||||
}
|
|
||||||
@@ -1,182 +1,108 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useParams } from "next/navigation";
|
import { api } from "~/trpc/react";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { PageContent } from "~/components/layout/page-content";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { useToast } from "~/hooks/use-toast";
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
||||||
import { Plus, Users, FileText, BarChart, PlayCircle } from "lucide-react";
|
import { Pencil as PencilIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import { use } from "react";
|
||||||
import { useActiveStudy } from "~/context/active-study";
|
import { StudyOverview } from "~/components/studies/study-overview";
|
||||||
import { getApiUrl } from "~/lib/fetch-utils";
|
import { StudyParticipants } from "~/components/studies/study-participants";
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
import { StudyMembers } from "~/components/studies/study-members";
|
||||||
|
import { StudyMetadata } from "~/components/studies/study-metadata";
|
||||||
|
import { StudyActivity } from "~/components/studies/study-activity";
|
||||||
|
import { StudyDetailsSkeleton } from "~/components/ui/skeleton";
|
||||||
|
|
||||||
interface StudyStats {
|
export default function StudyPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
participantCount: number;
|
const router = useRouter();
|
||||||
formCount: number;
|
const searchParams = useSearchParams();
|
||||||
trialCount: number;
|
const resolvedParams = use(params);
|
||||||
}
|
const id = Number(resolvedParams.id);
|
||||||
|
const activeTab = searchParams.get("tab") ?? "overview";
|
||||||
|
|
||||||
export default function StudyDashboard() {
|
const { data: study, isLoading: isLoadingStudy } = api.study.getById.useQuery({ id });
|
||||||
const [stats, setStats] = useState<StudyStats>({
|
|
||||||
participantCount: 0,
|
|
||||||
formCount: 0,
|
|
||||||
trialCount: 0,
|
|
||||||
});
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const { id } = useParams();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { activeStudy } = useActiveStudy();
|
|
||||||
|
|
||||||
const fetchStats = useCallback(async () => {
|
if (isLoadingStudy) {
|
||||||
try {
|
|
||||||
const response = await fetch(getApiUrl(`/api/studies/${id}/stats`));
|
|
||||||
if (!response.ok) throw new Error("Failed to fetch stats");
|
|
||||||
const { data } = await response.json();
|
|
||||||
setStats({
|
|
||||||
participantCount: data?.participantCount ?? 0,
|
|
||||||
formCount: data?.formCount ?? 0,
|
|
||||||
trialCount: data?.trialCount ?? 0
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching stats:", error);
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to load study statistics",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
// Set default values on error
|
|
||||||
setStats({
|
|
||||||
participantCount: 0,
|
|
||||||
formCount: 0,
|
|
||||||
trialCount: 0
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [toast, id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchStats();
|
|
||||||
}, [fetchStats]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<>
|
||||||
<div className="flex items-center justify-between">
|
<PageHeader
|
||||||
<div>
|
title="Loading..."
|
||||||
<Skeleton className="h-8 w-[200px] mb-2" />
|
description="Please wait while we load the study details"
|
||||||
<Skeleton className="h-4 w-[300px]" />
|
/>
|
||||||
</div>
|
<PageContent>
|
||||||
</div>
|
<Tabs defaultValue="overview" className="space-y-4">
|
||||||
|
<TabsList>
|
||||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-3">
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||||
{[1, 2, 3].map((i) => (
|
<TabsTrigger value="participants">Participants</TabsTrigger>
|
||||||
<Card key={i}>
|
<TabsTrigger value="members">Members</TabsTrigger>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<TabsTrigger value="metadata">Metadata</TabsTrigger>
|
||||||
<Skeleton className="h-4 w-[100px]" />
|
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||||
<Skeleton className="h-4 w-4" />
|
</TabsList>
|
||||||
</CardHeader>
|
<TabsContent value="overview">
|
||||||
<CardContent>
|
<StudyDetailsSkeleton />
|
||||||
<Skeleton className="h-7 w-[50px] mb-1" />
|
</TabsContent>
|
||||||
<Skeleton className="h-3 w-[120px]" />
|
</Tabs>
|
||||||
</CardContent>
|
</PageContent>
|
||||||
</Card>
|
</>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<Skeleton className="h-5 w-[120px] mb-2" />
|
|
||||||
<Skeleton className="h-4 w-[200px]" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex gap-4">
|
|
||||||
<Skeleton className="h-10 w-[140px]" />
|
|
||||||
<Skeleton className="h-10 w-[120px]" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!study) {
|
||||||
|
return <div>Study not found</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canEdit = study.role === "admin";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<>
|
||||||
<div className="flex items-center justify-between">
|
<PageHeader
|
||||||
<div>
|
title={study.title}
|
||||||
<h2 className="text-2xl font-bold tracking-tight">{activeStudy?.title}</h2>
|
description={study.description ?? "No description provided"}
|
||||||
<p className="text-muted-foreground">
|
>
|
||||||
Overview of your study's progress and statistics
|
{canEdit && (
|
||||||
</p>
|
<Button
|
||||||
</div>
|
variant="outline"
|
||||||
</div>
|
size="sm"
|
||||||
|
onClick={() => router.push(`/dashboard/studies/${id}/edit`)}
|
||||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-3">
|
>
|
||||||
<Card>
|
<PencilIcon className="h-4 w-4 mr-2" />
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
Edit Study
|
||||||
<CardTitle className="text-sm font-medium">
|
|
||||||
Participants
|
|
||||||
</CardTitle>
|
|
||||||
<Users className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{stats.participantCount}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Total enrolled participants
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">
|
|
||||||
Forms
|
|
||||||
</CardTitle>
|
|
||||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{stats.formCount}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Active study forms
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">
|
|
||||||
Trials
|
|
||||||
</CardTitle>
|
|
||||||
<BarChart className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{stats.trialCount}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Completed trials
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Quick Actions</CardTitle>
|
|
||||||
<CardDescription>Common tasks and actions for this study</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex gap-4">
|
|
||||||
<Button asChild>
|
|
||||||
<Link href={`/dashboard/studies/${id}/participants/new`}>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
Add Participant
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" asChild>
|
)}
|
||||||
<Link href={`/dashboard/studies/${id}/trials/new`}>
|
</PageHeader>
|
||||||
<PlayCircle className="w-4 h-4 mr-2" />
|
<PageContent>
|
||||||
Start Trial
|
<Tabs defaultValue={activeTab} className="space-y-4">
|
||||||
</Link>
|
<TabsList>
|
||||||
</Button>
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||||
</CardContent>
|
<TabsTrigger value="participants">Participants</TabsTrigger>
|
||||||
</Card>
|
<TabsTrigger value="members">Members</TabsTrigger>
|
||||||
</div>
|
<TabsTrigger value="metadata">Metadata</TabsTrigger>
|
||||||
|
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="overview" className="space-y-4">
|
||||||
|
<StudyOverview study={study} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="participants" className="space-y-4">
|
||||||
|
<StudyParticipants studyId={id} role={study.role} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="members" className="space-y-4">
|
||||||
|
<StudyMembers studyId={id} role={study.role} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="metadata" className="space-y-4">
|
||||||
|
<StudyMetadata studyId={id} role={study.role} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="activity" className="space-y-4">
|
||||||
|
<StudyActivity studyId={id} role={study.role} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</PageContent>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { PageContent } from "~/components/layout/page-content";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { ParticipantForm, type ParticipantFormValues } from "~/components/participants/participant-form";
|
||||||
|
import { use } from "react";
|
||||||
|
import { useToast } from "~/hooks/use-toast";
|
||||||
|
import { ROLES } from "~/lib/permissions/constants";
|
||||||
|
import { CardSkeleton } from "~/components/ui/skeleton";
|
||||||
|
|
||||||
|
export default function EditParticipantPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string; participantId: string }>;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const resolvedParams = use(params);
|
||||||
|
const studyId = Number(resolvedParams.id);
|
||||||
|
const participantId = Number(resolvedParams.participantId);
|
||||||
|
|
||||||
|
const { data: study } = api.study.getById.useQuery({ id: studyId });
|
||||||
|
const { data: participant, isLoading } = api.participant.getById.useQuery({ id: participantId });
|
||||||
|
|
||||||
|
const { mutate: updateParticipant, isPending: isUpdating } = api.participant.update.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Participant updated successfully",
|
||||||
|
});
|
||||||
|
router.push(`/dashboard/studies/${studyId}/participants/${participantId}`);
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSubmit(data: ParticipantFormValues) {
|
||||||
|
updateParticipant({
|
||||||
|
id: participantId,
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Edit Participant"
|
||||||
|
description="Loading participant information..."
|
||||||
|
/>
|
||||||
|
<PageContent>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Participant Details</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Please wait while we load the participant information.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CardSkeleton />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</PageContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!study || !participant) {
|
||||||
|
return <div>Not found</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has permission to edit participants
|
||||||
|
const canManageParticipants = [ROLES.OWNER, ROLES.ADMIN, ROLES.PRINCIPAL_INVESTIGATOR]
|
||||||
|
.map(r => r.toLowerCase())
|
||||||
|
.includes(study.role.toLowerCase());
|
||||||
|
|
||||||
|
if (!canManageParticipants) {
|
||||||
|
return <div>You do not have permission to edit participants in this study.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Edit Participant"
|
||||||
|
description={`Update participant details for ${study.title}`}
|
||||||
|
/>
|
||||||
|
<PageContent>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Participant Details</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Update the participant's information. Fields marked with * are required.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ParticipantForm
|
||||||
|
defaultValues={{
|
||||||
|
identifier: participant.identifier ?? "",
|
||||||
|
email: participant.email ?? "",
|
||||||
|
firstName: participant.firstName ?? "",
|
||||||
|
lastName: participant.lastName ?? "",
|
||||||
|
notes: participant.notes ?? "",
|
||||||
|
status: participant.status,
|
||||||
|
}}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
isSubmitting={isUpdating}
|
||||||
|
submitLabel="Save Changes"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</PageContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { PageContent } from "~/components/layout/page-content";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { Pencil as PencilIcon, Trash as TrashIcon } from "lucide-react";
|
||||||
|
import { use } from "react";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { useToast } from "~/hooks/use-toast";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "~/components/ui/alert-dialog";
|
||||||
|
import { ROLES } from "~/lib/permissions/constants";
|
||||||
|
|
||||||
|
export default function ParticipantDetailsPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string; participantId: string }>;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const resolvedParams = use(params);
|
||||||
|
const studyId = Number(resolvedParams.id);
|
||||||
|
const participantId = Number(resolvedParams.participantId);
|
||||||
|
|
||||||
|
const { data: study } = api.study.getById.useQuery({ id: studyId });
|
||||||
|
const { data: participant, isLoading } = api.participant.getById.useQuery({ id: participantId });
|
||||||
|
|
||||||
|
const { mutate: deleteParticipant, isPending: isDeleting } = api.participant.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Participant deleted successfully",
|
||||||
|
});
|
||||||
|
router.push(`/dashboard/studies/${studyId}/participants`);
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!study || !participant) {
|
||||||
|
return <div>Not found</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canViewIdentifiableInfo = [ROLES.OWNER, ROLES.ADMIN, ROLES.PRINCIPAL_INVESTIGATOR]
|
||||||
|
.map(r => r.toLowerCase())
|
||||||
|
.includes(study.role.toLowerCase());
|
||||||
|
const canManageParticipants = [ROLES.OWNER, ROLES.ADMIN, ROLES.PRINCIPAL_INVESTIGATOR]
|
||||||
|
.map(r => r.toLowerCase())
|
||||||
|
.includes(study.role.toLowerCase());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Participant Details"
|
||||||
|
description={`View participant details for ${study.title}`}
|
||||||
|
>
|
||||||
|
{canManageParticipants && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
router.push(`/dashboard/studies/${studyId}/participants/${participantId}/edit`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PencilIcon className="h-4 w-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="destructive" size="sm" disabled={isDeleting}>
|
||||||
|
<TrashIcon className="h-4 w-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This action cannot be undone. This will permanently delete the participant and all
|
||||||
|
associated data.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => deleteParticipant({ id: participantId })}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
{isDeleting ? "Deleting..." : "Delete"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PageHeader>
|
||||||
|
<PageContent>
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Basic Information</CardTitle>
|
||||||
|
{!canViewIdentifiableInfo && (
|
||||||
|
<CardDescription className="text-yellow-600">
|
||||||
|
Some information is redacted based on your role.
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<dl className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">Identifier</dt>
|
||||||
|
<dd className="mt-1 text-sm">
|
||||||
|
{canViewIdentifiableInfo ? participant.identifier || "—" : "REDACTED"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">Status</dt>
|
||||||
|
<dd className="mt-1">
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{participant.status}
|
||||||
|
</Badge>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">Name</dt>
|
||||||
|
<dd className="mt-1 text-sm">
|
||||||
|
{canViewIdentifiableInfo
|
||||||
|
? participant.firstName && participant.lastName
|
||||||
|
? `${participant.firstName} ${participant.lastName}`
|
||||||
|
: "—"
|
||||||
|
: "REDACTED"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">Email</dt>
|
||||||
|
<dd className="mt-1 text-sm">
|
||||||
|
{canViewIdentifiableInfo ? participant.email || "—" : "REDACTED"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Notes</CardTitle>
|
||||||
|
<CardDescription>Additional information about this participant</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">
|
||||||
|
{participant.notes || "No notes available."}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</PageContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,112 +1,102 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useRouter } from "next/navigation";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { api } from "~/trpc/react";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { PageContent } from "~/components/layout/page-content";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Button } from "~/components/ui/button";
|
import { ParticipantForm, type ParticipantFormValues } from "~/components/participants/participant-form";
|
||||||
import { Input } from "~/components/ui/input";
|
import { use } from "react";
|
||||||
import { Label } from "~/components/ui/label";
|
|
||||||
import { useToast } from "~/hooks/use-toast";
|
import { useToast } from "~/hooks/use-toast";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ROLES } from "~/lib/permissions/constants";
|
||||||
import Link from "next/link";
|
|
||||||
import { useActiveStudy } from "~/context/active-study";
|
|
||||||
import { hasPermission } from "~/lib/permissions-client";
|
|
||||||
import { PERMISSIONS } from "~/lib/permissions";
|
|
||||||
import { getApiUrl } from "~/lib/fetch-utils";
|
|
||||||
|
|
||||||
export default function NewParticipant() {
|
function generateIdentifier(studyId: number, count: number) {
|
||||||
const [name, setName] = useState("");
|
// Format: P001, P002, etc. with study prefix
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const paddedCount = String(count + 1).padStart(3, '0');
|
||||||
const { id } = useParams();
|
return `P${paddedCount}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewParticipantPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { activeStudy } = useActiveStudy();
|
const resolvedParams = use(params);
|
||||||
|
const studyId = Number(resolvedParams.id);
|
||||||
|
|
||||||
useEffect(() => {
|
const { data: study } = api.study.getById.useQuery({ id: studyId });
|
||||||
if (!activeStudy || !hasPermission(activeStudy.permissions, PERMISSIONS.CREATE_PARTICIPANT)) {
|
const { data: participantCount = 0 } = api.participant.getCount.useQuery(
|
||||||
router.push(`/dashboard/studies/${id}`);
|
{ studyId },
|
||||||
}
|
{ enabled: !!study }
|
||||||
}, [activeStudy, id, router]);
|
);
|
||||||
|
|
||||||
if (!activeStudy || !hasPermission(activeStudy.permissions, PERMISSIONS.CREATE_PARTICIPANT)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsSubmitting(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(getApiUrl(`/api/studies/${id}/participants`), {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ name }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to create participant");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const { mutate: createParticipant, isPending: isCreating } = api.participant.create.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
title: "Success",
|
title: "Success",
|
||||||
description: "Participant created successfully",
|
description: "Participant added successfully",
|
||||||
});
|
});
|
||||||
|
router.push(`/dashboard/studies/${studyId}/participants`);
|
||||||
router.push(`/dashboard/studies/${id}/participants`);
|
router.refresh();
|
||||||
} catch (error) {
|
},
|
||||||
console.error("Error creating participant:", error);
|
onError: (error) => {
|
||||||
toast({
|
toast({
|
||||||
title: "Error",
|
title: "Error",
|
||||||
description: "Failed to create participant",
|
description: error.message,
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
} finally {
|
},
|
||||||
setIsSubmitting(false);
|
});
|
||||||
|
|
||||||
|
function onSubmit(data: ParticipantFormValues) {
|
||||||
|
createParticipant({
|
||||||
|
studyId,
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!study) {
|
||||||
|
return <div>Study not found</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has permission to add participants
|
||||||
|
const canAddParticipants = [ROLES.OWNER, ROLES.ADMIN, ROLES.PRINCIPAL_INVESTIGATOR]
|
||||||
|
.map(r => r.toLowerCase())
|
||||||
|
.includes(study.role.toLowerCase());
|
||||||
|
|
||||||
|
if (!canAddParticipants) {
|
||||||
|
return <div>You do not have permission to add participants to this study.</div>;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<>
|
||||||
<div className="flex items-center justify-between">
|
<PageHeader
|
||||||
<Button
|
title="Add Participant"
|
||||||
variant="ghost"
|
description={`Add a new participant to ${study.title}`}
|
||||||
className="gap-2"
|
/>
|
||||||
asChild
|
<PageContent>
|
||||||
>
|
|
||||||
<Link href={`/dashboard/studies/${id}/participants`}>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
Back to Participants
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Add New Participant</CardTitle>
|
<CardTitle>Participant Details</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Create a new participant for {activeStudy?.title}
|
Enter the participant's information. Fields marked with * are required.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<ParticipantForm
|
||||||
<div className="space-y-2">
|
defaultValues={{
|
||||||
<Label htmlFor="name">Participant Name</Label>
|
identifier: generateIdentifier(studyId, participantCount),
|
||||||
<Input
|
email: "",
|
||||||
id="name"
|
firstName: "",
|
||||||
value={name}
|
lastName: "",
|
||||||
onChange={(e) => setName(e.target.value)}
|
notes: "",
|
||||||
placeholder="Enter participant name"
|
status: "active",
|
||||||
required
|
}}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
isSubmitting={isCreating}
|
||||||
|
submitLabel="Add Participant"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
|
||||||
{isSubmitting ? "Creating..." : "Create Participant"}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</PageContent>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,345 +1,36 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useRouter } from "next/navigation";
|
||||||
import { useParams } from "next/navigation";
|
import { api } from "~/trpc/react";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
import { Button } from "~/components/ui/button";
|
import { PageContent } from "~/components/layout/page-content";
|
||||||
import { useToast } from "~/hooks/use-toast";
|
import { use } from "react";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { StudyParticipants } from "~/components/studies/study-participants";
|
||||||
import { useActiveStudy } from "~/context/active-study";
|
|
||||||
import { hasPermission } from "~/lib/permissions-client";
|
|
||||||
import { PERMISSIONS } from "~/lib/permissions";
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "~/components/ui/alert-dialog";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "~/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "~/components/ui/table";
|
|
||||||
import { getApiUrl } from "~/lib/fetch-utils";
|
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
|
||||||
import { Input } from "~/components/ui/input";
|
|
||||||
import { Label } from "~/components/ui/label";
|
|
||||||
|
|
||||||
interface Participant {
|
export default function ParticipantsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
id: number;
|
const router = useRouter();
|
||||||
name: string;
|
const resolvedParams = use(params);
|
||||||
studyId: number;
|
const studyId = Number(resolvedParams.id);
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ParticipantsList() {
|
const { data: study, isLoading } = api.study.getById.useQuery({ id: studyId });
|
||||||
const [participants, setParticipants] = useState<Participant[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isAddingParticipant, setIsAddingParticipant] = useState(false);
|
|
||||||
const [newParticipantName, setNewParticipantName] = useState("");
|
|
||||||
const { id } = useParams();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { activeStudy } = useActiveStudy();
|
|
||||||
|
|
||||||
const canCreateParticipant = activeStudy && hasPermission(activeStudy.permissions, PERMISSIONS.CREATE_PARTICIPANT);
|
|
||||||
const canDeleteParticipant = activeStudy && hasPermission(activeStudy.permissions, PERMISSIONS.DELETE_PARTICIPANT);
|
|
||||||
const canViewNames = activeStudy && hasPermission(activeStudy.permissions, PERMISSIONS.VIEW_PARTICIPANT_NAMES);
|
|
||||||
|
|
||||||
const fetchParticipants = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(getApiUrl(`/api/studies/${id}/participants`));
|
|
||||||
if (!response.ok) throw new Error("Failed to fetch participants");
|
|
||||||
const data = await response.json();
|
|
||||||
setParticipants(data.data || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching participants:", error);
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to load participants",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [id, toast]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchParticipants();
|
|
||||||
}, [fetchParticipants]);
|
|
||||||
|
|
||||||
const handleDelete = async (participantId: number) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(getApiUrl(`/api/studies/${id}/participants`), {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ participantId }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error("Failed to delete participant");
|
|
||||||
|
|
||||||
setParticipants(participants.filter(p => p.id !== participantId));
|
|
||||||
toast({
|
|
||||||
title: "Success",
|
|
||||||
description: "Participant deleted successfully",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error deleting participant:", error);
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to delete participant",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddParticipant = async () => {
|
|
||||||
if (!newParticipantName.trim()) return;
|
|
||||||
|
|
||||||
setIsAddingParticipant(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch(getApiUrl(`/api/studies/${id}/participants`), {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ name: newParticipantName }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error("Failed to add participant");
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
setParticipants([...participants, data.data]);
|
|
||||||
setNewParticipantName("");
|
|
||||||
toast({
|
|
||||||
title: "Success",
|
|
||||||
description: "Participant added successfully",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error adding participant:", error);
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to add participant",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsAddingParticipant(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return <div>Loading...</div>;
|
||||||
<div className="space-y-6">
|
}
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<Skeleton className="h-8 w-[200px] mb-2" />
|
|
||||||
<Skeleton className="h-4 w-[300px]" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-10 w-[140px]" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
if (!study) {
|
||||||
<CardHeader>
|
return <div>Study not found</div>;
|
||||||
<Skeleton className="h-5 w-[150px] mb-2" />
|
|
||||||
<Skeleton className="h-4 w-[250px]" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="rounded-md border">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead><Skeleton className="h-4 w-[120px]" /></TableHead>
|
|
||||||
<TableHead><Skeleton className="h-4 w-[100px]" /></TableHead>
|
|
||||||
<TableHead className="w-[100px]"><Skeleton className="h-4 w-[60px]" /></TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<TableRow key={i}>
|
|
||||||
<TableCell><Skeleton className="h-4 w-[150px]" /></TableCell>
|
|
||||||
<TableCell><Skeleton className="h-4 w-[100px]" /></TableCell>
|
|
||||||
<TableCell><Skeleton className="h-8 w-8" /></TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Participants</h2>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Manage study participants and their data
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{canCreateParticipant && (
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Add Participant
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Add Participant</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Add a new participant to {activeStudy?.title}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name">Participant Name</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
placeholder="Enter participant name"
|
|
||||||
value={newParticipantName}
|
|
||||||
onChange={(e) => setNewParticipantName(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
onClick={handleAddParticipant}
|
|
||||||
disabled={isAddingParticipant || !newParticipantName.trim()}
|
|
||||||
>
|
|
||||||
{isAddingParticipant ? "Adding..." : "Add Participant"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Study Participants</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
All participants enrolled in {activeStudy?.title}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{participants.length > 0 ? (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Name</TableHead>
|
|
||||||
<TableHead>Added</TableHead>
|
|
||||||
{canDeleteParticipant && <TableHead className="w-[100px]">Actions</TableHead>}
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{participants.map((participant) => (
|
|
||||||
<TableRow key={participant.id}>
|
|
||||||
<TableCell>
|
|
||||||
{canViewNames ? participant.name : `Participant ${participant.id}`}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{new Date(participant.createdAt).toLocaleDateString()}
|
|
||||||
</TableCell>
|
|
||||||
{canDeleteParticipant && (
|
|
||||||
<TableCell>
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Delete Participant</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Are you sure you want to delete this participant? This action cannot be undone.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={() => handleDelete(participant.id)}
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
) : (
|
|
||||||
<div className="py-8 text-center text-muted-foreground">
|
|
||||||
No participants added yet
|
|
||||||
{canCreateParticipant && (
|
|
||||||
<>
|
<>
|
||||||
.{" "}
|
<PageHeader
|
||||||
<Dialog>
|
title="Participants"
|
||||||
<DialogTrigger asChild>
|
description={`Manage participants for ${study.title}`}
|
||||||
<Button variant="link" className="px-2 py-0">
|
|
||||||
Add your first participant
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Add Participant</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Add a new participant to {activeStudy?.title}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name-empty">Participant Name</Label>
|
|
||||||
<Input
|
|
||||||
id="name-empty"
|
|
||||||
placeholder="Enter participant name"
|
|
||||||
value={newParticipantName}
|
|
||||||
onChange={(e) => setNewParticipantName(e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<PageContent>
|
||||||
</div>
|
<StudyParticipants studyId={studyId} role={study.role} />
|
||||||
<DialogFooter>
|
</PageContent>
|
||||||
<Button
|
|
||||||
onClick={handleAddParticipant}
|
|
||||||
disabled={isAddingParticipant || !newParticipantName.trim()}
|
|
||||||
>
|
|
||||||
{isAddingParticipant ? "Adding..." : "Add Participant"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
</>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useParams, useRouter } from "next/navigation";
|
|
||||||
import { SettingsTab } from "~/components/studies/settings-tab";
|
|
||||||
import { UsersTab } from "~/components/studies/users-tab";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { PERMISSIONS } from "~/lib/permissions-client";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { Settings2Icon, UsersIcon } from "lucide-react";
|
|
||||||
import { cn } from "~/lib/utils";
|
|
||||||
import { getApiUrl } from "~/lib/fetch-utils";
|
|
||||||
import { Card, CardContent } from "~/components/ui/card";
|
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
|
||||||
|
|
||||||
interface Study {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
description: string | null;
|
|
||||||
permissions: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function StudySettings() {
|
|
||||||
const [study, setStudy] = useState<Study | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [activeTab, setActiveTab] = useState<'settings' | 'users'>('settings');
|
|
||||||
const { id } = useParams();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchStudy = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(getApiUrl(`/api/studies/${id}`));
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 403) {
|
|
||||||
router.push('/dashboard/studies');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw new Error("Failed to fetch study");
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Check if user has any required permissions
|
|
||||||
const requiredPermissions = [PERMISSIONS.EDIT_STUDY, PERMISSIONS.MANAGE_ROLES];
|
|
||||||
const hasAccess = data.data.permissions.some(p => requiredPermissions.includes(p));
|
|
||||||
|
|
||||||
if (!hasAccess) {
|
|
||||||
router.push('/dashboard/studies');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setStudy(data.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching study:", error);
|
|
||||||
setError(error instanceof Error ? error.message : "Failed to load study");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchStudy();
|
|
||||||
}, [id, router]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<Skeleton className="h-8 w-[150px] mb-2" />
|
|
||||||
<Skeleton className="h-4 w-[250px]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-6">
|
|
||||||
<div className="w-48 flex flex-col gap-2">
|
|
||||||
<Skeleton className="h-10 w-full" />
|
|
||||||
<Skeleton className="h-10 w-full" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="py-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Skeleton className="h-4 w-[200px]" />
|
|
||||||
<Skeleton className="h-10 w-full" />
|
|
||||||
<Skeleton className="h-10 w-full" />
|
|
||||||
<Skeleton className="h-10 w-2/3" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !study) {
|
|
||||||
return <div>Error: {error}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Settings</h2>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Manage study settings and team members
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-6">
|
|
||||||
<div className="w-48 flex flex-col gap-2">
|
|
||||||
<Button
|
|
||||||
variant={activeTab === 'settings' ? 'secondary' : 'ghost'}
|
|
||||||
className="justify-start"
|
|
||||||
onClick={() => setActiveTab('settings')}
|
|
||||||
>
|
|
||||||
<Settings2Icon className="mr-2 h-4 w-4" />
|
|
||||||
Settings
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={activeTab === 'users' ? 'secondary' : 'ghost'}
|
|
||||||
className="justify-start"
|
|
||||||
onClick={() => setActiveTab('users')}
|
|
||||||
>
|
|
||||||
<UsersIcon className="mr-2 h-4 w-4" />
|
|
||||||
Users
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className={cn(activeTab === 'settings' ? 'block' : 'hidden')}>
|
|
||||||
<SettingsTab study={study} />
|
|
||||||
</div>
|
|
||||||
<div className={cn(activeTab === 'users' ? 'block' : 'hidden')}>
|
|
||||||
<UsersTab studyId={study.id} permissions={study.permissions} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,137 +1,50 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { StudyForm, type StudyFormValues } from "~/components/studies/study-form";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { PageContent } from "~/components/layout/page-content";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { Input } from "~/components/ui/input";
|
|
||||||
import { Label } from "~/components/ui/label";
|
|
||||||
import { Textarea } from "~/components/ui/textarea";
|
|
||||||
import { useToast } from "~/hooks/use-toast";
|
|
||||||
import { ArrowLeft, Settings2Icon } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useActiveStudy } from "~/context/active-study";
|
|
||||||
import { getApiUrl } from "~/lib/fetch-utils";
|
|
||||||
|
|
||||||
export default function NewStudy() {
|
export default function NewStudyPage() {
|
||||||
const [title, setTitle] = useState("");
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
|
||||||
const { refreshStudies } = useActiveStudy();
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const { mutate: createStudy, isPending: isCreating } = api.study.create.useMutation({
|
||||||
e.preventDefault();
|
onSuccess: (data) => {
|
||||||
setIsSubmitting(true);
|
router.push(`/dashboard/studies/${data.id}`);
|
||||||
|
router.refresh();
|
||||||
try {
|
|
||||||
const response = await fetch(getApiUrl('/api/studies'), {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ title, description }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
function onSubmit(data: StudyFormValues) {
|
||||||
throw new Error("Failed to create study");
|
createStudy(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "Success",
|
|
||||||
description: "Study created successfully",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Refresh studies list and redirect to the new study
|
|
||||||
await refreshStudies();
|
|
||||||
router.push(`/dashboard/studies/${data.data.id}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error creating study:", error);
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to create study",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<>
|
||||||
<div className="flex items-center justify-between">
|
<PageHeader
|
||||||
<div>
|
title="New Study"
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Create New Study</h2>
|
description="Create a new study"
|
||||||
<p className="text-muted-foreground">
|
/>
|
||||||
Set up a new research study and configure its settings
|
<PageContent>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="gap-2"
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<Link href="/dashboard">
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
Back to Dashboard
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-6">
|
|
||||||
<div className="w-48 flex flex-col gap-2">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
className="justify-start"
|
|
||||||
>
|
|
||||||
<Settings2Icon className="mr-2 h-4 w-4" />
|
|
||||||
Basic Settings
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1">
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Study Details</CardTitle>
|
<CardTitle>Study Details</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Configure the basic settings for your new study
|
Enter the information for your new study.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<StudyForm
|
||||||
<div className="space-y-2">
|
defaultValues={{ title: "", description: "" }}
|
||||||
<Label htmlFor="title">Study Title</Label>
|
onSubmit={onSubmit}
|
||||||
<Input
|
isSubmitting={isCreating}
|
||||||
id="title"
|
submitLabel="Create Study"
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder="Enter study title"
|
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="description">Description</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
placeholder="Enter study description"
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
|
||||||
{isSubmitting ? "Creating..." : "Create Study"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</PageContent>
|
||||||
</div>
|
</>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,237 +1,68 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { PlusIcon, Trash2Icon, Settings2, ArrowRight } from "lucide-react";
|
import { api } from "~/trpc/react";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { PageContent } from "~/components/layout/page-content";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { useToast } from "~/hooks/use-toast";
|
import { Plus as PlusIcon } from "lucide-react";
|
||||||
import { PERMISSIONS, hasPermission } from "~/lib/permissions-client";
|
import { StudyListSkeleton } from "~/components/ui/skeleton";
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
AlertDialogFooter
|
|
||||||
} from "~/components/ui/alert-dialog";
|
|
||||||
import { getApiUrl } from "~/lib/fetch-utils";
|
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
|
||||||
import { useActiveStudy } from "~/context/active-study";
|
|
||||||
|
|
||||||
interface Study {
|
export default function StudiesPage() {
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
description: string | null;
|
|
||||||
userId: string;
|
|
||||||
environment: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date | null;
|
|
||||||
permissions: string[];
|
|
||||||
roles: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRoleName(role: string): string {
|
|
||||||
return role
|
|
||||||
.split('_')
|
|
||||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
||||||
.join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Studies() {
|
|
||||||
const [studies, setStudies] = useState<Study[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
const { data: studies, isLoading } = api.study.getMyStudies.useQuery();
|
||||||
const { setActiveStudy } = useActiveStudy();
|
|
||||||
|
|
||||||
const fetchStudies = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(getApiUrl("/api/studies"));
|
|
||||||
if (!response.ok) throw new Error("Failed to fetch studies");
|
|
||||||
const { data } = await response.json();
|
|
||||||
setStudies(data.map((study: any) => ({
|
|
||||||
...study,
|
|
||||||
createdAt: new Date(study.createdAt),
|
|
||||||
updatedAt: study.updatedAt ? new Date(study.updatedAt) : null
|
|
||||||
})));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching studies:", error);
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to load studies",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [toast]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchStudies();
|
|
||||||
}, [fetchStudies]);
|
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(getApiUrl(`/api/studies/${id}`), {
|
|
||||||
method: "DELETE",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error("Failed to delete study");
|
|
||||||
|
|
||||||
setStudies(studies.filter(study => study.id !== id));
|
|
||||||
toast({
|
|
||||||
title: "Success",
|
|
||||||
description: "Study deleted successfully",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error deleting study:", error);
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to delete study",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEnterStudy = (study: Study) => {
|
|
||||||
setActiveStudy(study);
|
|
||||||
router.push(`/dashboard/studies/${study.id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<>
|
||||||
<div className="flex items-center justify-between">
|
<PageHeader
|
||||||
<div>
|
title="Studies"
|
||||||
<Skeleton className="h-8 w-[150px] mb-2" />
|
description="Manage your research studies"
|
||||||
<Skeleton className="h-4 w-[300px]" />
|
>
|
||||||
</div>
|
<Button
|
||||||
<Skeleton className="h-10 w-[140px]" />
|
onClick={() => router.push("/dashboard/studies/new")}
|
||||||
</div>
|
size="sm"
|
||||||
|
>
|
||||||
<div className="grid gap-4">
|
<PlusIcon className="h-4 w-4 mr-2" />
|
||||||
{[1, 2, 3].map((i) => (
|
New Study
|
||||||
<Card key={i}>
|
</Button>
|
||||||
<CardContent className="p-6">
|
</PageHeader>
|
||||||
<div className="flex items-start justify-between">
|
<PageContent>
|
||||||
<div className="space-y-2">
|
{isLoading ? (
|
||||||
<Skeleton className="h-5 w-[200px] mb-1" />
|
<StudyListSkeleton />
|
||||||
<Skeleton className="h-4 w-[300px] mb-1" />
|
) : !studies || studies.length === 0 ? (
|
||||||
<Skeleton className="h-4 w-[150px]" />
|
<Card>
|
||||||
</div>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-2">
|
<CardTitle>No Studies</CardTitle>
|
||||||
<Skeleton className="h-9 w-[100px]" />
|
<CardDescription>
|
||||||
<Skeleton className="h-9 w-9" />
|
You haven't created any studies yet. Click the button above to create your first study.
|
||||||
</div>
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-6">
|
||||||
|
{studies.map((study) => (
|
||||||
|
<Card
|
||||||
|
key={study.id}
|
||||||
|
className="hover:bg-muted/50 cursor-pointer transition-colors"
|
||||||
|
onClick={() => router.push(`/dashboard/studies/${study.id}`)}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{study.title}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{study.description || "No description provided"}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Your role: {study.role}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Studies</h2>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Manage your research studies and experiments
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{hasPermission(studies[0]?.permissions || [], PERMISSIONS.CREATE_STUDY) && (
|
|
||||||
<Button onClick={() => router.push('/dashboard/studies/new')}>
|
|
||||||
<PlusIcon className="w-4 h-4 mr-2" />
|
|
||||||
Create New Study
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</PageContent>
|
||||||
|
</>
|
||||||
<div className="grid gap-4">
|
|
||||||
{studies.length > 0 ? (
|
|
||||||
studies.map((study) => (
|
|
||||||
<Card key={study.id}>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h3 className="font-semibold leading-none tracking-tight">
|
|
||||||
{study.title}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{study.description || "No description provided."}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm">
|
|
||||||
<span className="text-muted-foreground">Your Roles: </span>
|
|
||||||
<span className="text-foreground">
|
|
||||||
{study.roles?.map(formatRoleName).join(", ")}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleEnterStudy(study)}
|
|
||||||
>
|
|
||||||
Enter Study
|
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
{(hasPermission(study.permissions, PERMISSIONS.EDIT_STUDY) ||
|
|
||||||
hasPermission(study.permissions, PERMISSIONS.MANAGE_ROLES)) && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => router.push(`/dashboard/studies/${study.id}/settings`)}
|
|
||||||
>
|
|
||||||
<Settings2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{hasPermission(study.permissions, PERMISSIONS.DELETE_STUDY) && (
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button variant="ghost" size="sm">
|
|
||||||
<Trash2Icon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Delete Study</AlertDialogTitle>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<p>
|
|
||||||
Are you sure you want to delete this study? This action cannot be undone.
|
|
||||||
</p>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={() => handleDelete(study.id)}
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="py-8">
|
|
||||||
<p className="text-center text-muted-foreground">
|
|
||||||
No studies found{hasPermission(studies[0]?.permissions || [], PERMISSIONS.CREATE_STUDY) ? ". Create your first study above" : ""}.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,101 +2,108 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 210 50% 98%;
|
--background: 200 30% 97%;
|
||||||
--foreground: 215 25% 27%;
|
--foreground: 200 50% 20%;
|
||||||
--card: 210 50% 98%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 215 25% 27%;
|
--card-foreground: 200 50% 20%;
|
||||||
--popover: 210 50% 98%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 215 25% 27%;
|
--popover-foreground: 200 50% 20%;
|
||||||
--primary: 215 60% 40%;
|
--primary: 200 85% 45%;
|
||||||
--primary-foreground: 210 50% 98%;
|
--primary-foreground: 0 0% 100%;
|
||||||
--secondary: 210 55% 92%;
|
--secondary: 200 30% 96%;
|
||||||
--secondary-foreground: 215 25% 27%;
|
--secondary-foreground: 200 50% 20%;
|
||||||
--muted: 210 55% 92%;
|
--muted: 200 30% 96%;
|
||||||
--muted-foreground: 215 20% 50%;
|
--muted-foreground: 200 30% 40%;
|
||||||
--accent: 210 55% 92%;
|
--accent: 200 85% 45%;
|
||||||
--accent-foreground: 215 25% 27%;
|
--accent-foreground: 0 0% 100%;
|
||||||
--destructive: 0 84% 60%;
|
--destructive: 0 84.2% 60.2%;
|
||||||
--destructive-foreground: 210 50% 98%;
|
--destructive-foreground: 0 0% 100%;
|
||||||
--border: 214 32% 91%;
|
--border: 200 30% 90%;
|
||||||
--input: 214 32% 91%;
|
--input: 200 30% 90%;
|
||||||
--ring: 215 60% 40%;
|
--ring: 200 85% 45%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
|
--chart-1: 200 85% 45%;
|
||||||
|
--chart-2: 142 71% 45%;
|
||||||
|
--chart-3: 217 91% 60%;
|
||||||
|
--chart-4: 47 95% 57%;
|
||||||
|
--chart-5: 0 84% 60%;
|
||||||
|
|
||||||
/* Update gradient variables */
|
/* Sidebar specific colors */
|
||||||
--gradient-start: 210 50% 96%;
|
--sidebar-background: 0 0% 100%;
|
||||||
--gradient-end: 210 50% 98%;
|
--sidebar-foreground: 200 50% 20%;
|
||||||
|
--sidebar-muted: 200 30% 40%;
|
||||||
/* Updated sidebar variables for a clean, light look */
|
--sidebar-muted-foreground: 200 30% 40%;
|
||||||
--sidebar-background: 210 50% 98%;
|
--sidebar-accent: 200 30% 96%;
|
||||||
--sidebar-foreground: 215 25% 27%;
|
--sidebar-accent-foreground: 200 50% 20%;
|
||||||
--sidebar-muted: 215 20% 50%;
|
--sidebar-border: 200 30% 90%;
|
||||||
--sidebar-hover: 210 50% 94%;
|
--sidebar-ring: 200 85% 45%;
|
||||||
--sidebar-border: 214 32% 91%;
|
--sidebar-hover: 200 40% 96%;
|
||||||
--sidebar-separator: 214 32% 91%;
|
--sidebar-active: var(--primary);
|
||||||
--sidebar-active: 210 50% 92%;
|
--sidebar-active-foreground: var(--primary-foreground);
|
||||||
|
|
||||||
--card-level-1: 210 50% 95%;
|
|
||||||
--card-level-2: 210 50% 90%;
|
|
||||||
--card-level-3: 210 50% 85%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
@media (prefers-color-scheme: dark) {
|
||||||
--background: 220 20% 15%;
|
:root {
|
||||||
--foreground: 220 20% 90%;
|
--background: 200 30% 8%;
|
||||||
--card: 220 20% 15%;
|
--foreground: 200 20% 96%;
|
||||||
--card-foreground: 220 20% 90%;
|
--card: 200 25% 15%;
|
||||||
--popover: 220 20% 15%;
|
--card-foreground: 200 15% 85%;
|
||||||
--popover-foreground: 220 20% 90%;
|
--popover: 200 50% 8%;
|
||||||
--primary: 220 60% 50%;
|
--popover-foreground: 200 20% 96%;
|
||||||
--primary-foreground: 220 20% 90%;
|
--primary: 200 70% 40%;
|
||||||
--secondary: 220 30% 20%;
|
--primary-foreground: 0 0% 100%;
|
||||||
--secondary-foreground: 220 20% 90%;
|
--secondary: 200 30% 20%;
|
||||||
--muted: 220 30% 20%;
|
--secondary-foreground: 200 20% 96%;
|
||||||
--muted-foreground: 220 20% 70%;
|
--muted: 200 30% 20%;
|
||||||
--accent: 220 30% 20%;
|
--muted-foreground: 200 30% 65%;
|
||||||
--accent-foreground: 220 20% 90%;
|
--accent: 200 70% 40%;
|
||||||
--destructive: 0 62% 40%;
|
--accent-foreground: 0 0% 100%;
|
||||||
--destructive-foreground: 220 20% 90%;
|
--destructive: 0 62.8% 30.6%;
|
||||||
--border: 220 30% 20%;
|
--destructive-foreground: 0 0% 100%;
|
||||||
--input: 220 30% 20%;
|
--border: 200 30% 20%;
|
||||||
--ring: 220 60% 50%;
|
--input: 200 30% 20%;
|
||||||
|
--ring: 200 70% 40%;
|
||||||
|
--chart-1: 200 70% 40%;
|
||||||
|
--chart-2: 142 71% 45%;
|
||||||
|
--chart-3: 217 91% 60%;
|
||||||
|
--chart-4: 47 95% 57%;
|
||||||
|
--chart-5: 0 84% 60%;
|
||||||
|
|
||||||
/* Update gradient variables for dark mode */
|
/* Sidebar specific colors - dark mode */
|
||||||
--gradient-start: 220 20% 12%;
|
--sidebar-background: 200 30% 12%;
|
||||||
--gradient-end: 220 20% 15%;
|
--sidebar-foreground: 200 20% 96%;
|
||||||
|
--sidebar-muted: 200 30% 65%;
|
||||||
/* Updated sidebar variables for dark mode */
|
--sidebar-muted-foreground: 200 30% 65%;
|
||||||
--sidebar-background-top: 220 20% 15%;
|
--sidebar-accent: 200 30% 20%;
|
||||||
--sidebar-background-bottom: 220 20% 15%;
|
--sidebar-accent-foreground: 200 20% 96%;
|
||||||
--sidebar-foreground: 220 20% 90%;
|
--sidebar-border: 200 30% 20%;
|
||||||
--sidebar-muted: 220 20% 60%;
|
--sidebar-ring: 200 70% 40%;
|
||||||
--sidebar-hover: 220 20% 20%;
|
--sidebar-hover: 200 25% 20%;
|
||||||
--sidebar-border: 220 20% 25%;
|
--sidebar-active: var(--primary);
|
||||||
--sidebar-separator: 220 20% 22%;
|
--sidebar-active-foreground: var(--primary-foreground);
|
||||||
|
}
|
||||||
--card-level-1: 220 20% 12%;
|
|
||||||
--card-level-2: 220 20% 10%;
|
|
||||||
--card-level-3: 220 20% 8%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Add these utility classes */
|
.auth-gradient {
|
||||||
.card-level-1 {
|
@apply relative bg-background;
|
||||||
background-color: hsl(var(--card-level-1));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-level-2 {
|
.auth-gradient::before {
|
||||||
background-color: hsl(var(--card-level-2));
|
@apply absolute inset-0 -z-10 bg-[radial-gradient(circle_at_top,_var(--tw-gradient-stops))] from-primary/20 via-background to-background content-[''];
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-level-3 {
|
.auth-gradient::after {
|
||||||
background-color: hsl(var(--card-level-3));
|
@apply absolute inset-0 -z-10 bg-[url('/grid.svg')] bg-center opacity-10 content-[''];
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
@apply relative overflow-hidden border border-border/50 bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-input {
|
||||||
|
@apply h-10 bg-background/50 backdrop-blur supports-[backdrop-filter]:bg-background/30;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,22 +113,151 @@ body {
|
|||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
|
font-feature-settings:
|
||||||
|
"rlig" 1,
|
||||||
|
"calt" 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sidebar specific styles */
|
/* Sidebar and Header shared styles */
|
||||||
.sidebar-separator {
|
[data-sidebar="sidebar"],
|
||||||
@apply my-3 border-t border-[hsl(var(--sidebar-separator))] opacity-60;
|
[data-nav="header"] {
|
||||||
|
@apply relative isolate rounded-lg border-[hsl(var(--sidebar-border))] bg-transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-dropdown-content {
|
[data-sidebar="sidebar"]::before,
|
||||||
@apply bg-[hsl(var(--sidebar-background))] border-[hsl(var(--sidebar-border))];
|
[data-nav="header"]::before {
|
||||||
|
@apply absolute inset-0 -z-10 rounded-lg backdrop-blur-2xl content-[''];
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-button {
|
/* Light mode adjustments */
|
||||||
@apply hover:bg-[hsl(var(--sidebar-hover))] text-[hsl(var(--sidebar-foreground))];
|
:root [data-sidebar="sidebar"]::before,
|
||||||
|
:root [data-nav="header"]::before {
|
||||||
|
@apply bg-white/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-button[data-active="true"] {
|
/* Dark mode adjustments */
|
||||||
@apply bg-[hsl(var(--sidebar-active))] font-medium;
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root [data-sidebar="sidebar"]::before,
|
||||||
|
:root [data-nav="header"]::before {
|
||||||
|
@apply bg-black/30;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sidebar="sidebar"] {
|
||||||
|
@apply border-r p-2 text-[hsl(var(--sidebar-foreground))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix collapsed sidebar spacing */
|
||||||
|
[data-sidebar="sidebar"][data-collapsible="icon"] {
|
||||||
|
@apply p-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sidebar="sidebar"][data-collapsible="icon"] [data-sidebar="menu"] {
|
||||||
|
@apply p-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix study selector and user bar in collapsed mode */
|
||||||
|
[data-sidebar="sidebar"][data-collapsible="icon"] [data-sidebar="header"],
|
||||||
|
[data-sidebar="sidebar"][data-collapsible="icon"] [data-sidebar="footer"] {
|
||||||
|
@apply p-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sidebar="sidebar"][data-collapsible="icon"]
|
||||||
|
[data-sidebar="header"]
|
||||||
|
[data-sidebar="menu-button"],
|
||||||
|
[data-sidebar="sidebar"][data-collapsible="icon"]
|
||||||
|
[data-sidebar="footer"]
|
||||||
|
[data-sidebar="menu-button"] {
|
||||||
|
@apply !h-8 !w-8 !p-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sidebar="sidebar"][data-collapsible="icon"]
|
||||||
|
[data-sidebar="header"]
|
||||||
|
[data-sidebar="menu-button"]
|
||||||
|
> div,
|
||||||
|
[data-sidebar="sidebar"][data-collapsible="icon"]
|
||||||
|
[data-sidebar="footer"]
|
||||||
|
[data-sidebar="menu-button"]
|
||||||
|
> div {
|
||||||
|
@apply flex !h-8 !w-8 items-center justify-center !p-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sidebar="sidebar"][data-collapsible="icon"]
|
||||||
|
[data-sidebar="header"]
|
||||||
|
[data-sidebar="menu-button"]
|
||||||
|
[role="img"],
|
||||||
|
[data-sidebar="sidebar"][data-collapsible="icon"]
|
||||||
|
[data-sidebar="footer"]
|
||||||
|
[data-sidebar="menu-button"]
|
||||||
|
[role="img"] {
|
||||||
|
@apply !h-8 !w-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Regular menu button styles */
|
||||||
|
[data-sidebar="menu-button"] {
|
||||||
|
@apply mt-2 rounded-lg px-2 py-2.5 text-[hsl(var(--sidebar-foreground))] transition-all duration-200 first:mt-0 hover:bg-[hsl(var(--sidebar-hover))] hover:text-[hsl(var(--sidebar-active))];
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sidebar="menu-button"][data-active="true"] {
|
||||||
|
@apply bg-[hsl(var(--sidebar-active))]/10 font-medium text-[hsl(var(--sidebar-active))] ring-1 ring-inset ring-[hsl(var(--sidebar-active))]/20 hover:bg-[hsl(var(--sidebar-active))]/15 hover:ring-[hsl(var(--sidebar-active))]/30;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sidebar="group-label"] {
|
||||||
|
@apply text-[hsl(var(--sidebar-muted))];
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sidebar="menu-action"],
|
||||||
|
[data-sidebar="group-action"] {
|
||||||
|
@apply rounded-lg px-2 py-2.5 text-[hsl(var(--sidebar-muted))] transition-all duration-200 hover:bg-[hsl(var(--sidebar-hover))] hover:text-[hsl(var(--sidebar-active))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card elevation utilities */
|
||||||
|
.card-level-1 {
|
||||||
|
@apply bg-[hsl(var(--card))] shadow-sm transition-shadow duration-200 hover:shadow;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-level-2 {
|
||||||
|
@apply bg-[hsl(var(--card))] shadow transition-shadow duration-200 hover:shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-level-3 {
|
||||||
|
@apply bg-[hsl(var(--card))] shadow-md transition-shadow duration-200 hover:shadow-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient Animations */
|
||||||
|
@keyframes gradient-move {
|
||||||
|
0% {
|
||||||
|
transform: scale(1) rotate(0deg);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: scale(1.05) rotate(90deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(0.95) rotate(180deg);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: scale(1.05) rotate(270deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1) rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-gradient {
|
||||||
|
animation: gradient-move 30s ease-in-out infinite;
|
||||||
|
transform-origin: center;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.step {
|
||||||
|
counter-increment: step;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step:before {
|
||||||
|
@apply absolute inline-flex h-9 w-9 items-center justify-center rounded-full bg-muted text-center -indent-px font-mono text-base font-medium;
|
||||||
|
@apply ml-[-50px] mt-[-4px];
|
||||||
|
content: counter(step);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import { ImageResponse } from "next/og";
|
|
||||||
|
|
||||||
export const runtime = "edge";
|
|
||||||
export const contentType = "image/svg+xml";
|
|
||||||
export const size = {
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Icon() {
|
|
||||||
return new ImageResponse(
|
|
||||||
(
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
background: "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M12 8V4H8" />
|
|
||||||
<rect width="16" height="12" x="4" y="8" rx="2" />
|
|
||||||
<path d="M2 14h2" />
|
|
||||||
<path d="M20 14h2" />
|
|
||||||
<path d="M15 13v2" />
|
|
||||||
<path d="M9 13v2" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
{
|
|
||||||
...size,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { useUser } from "@clerk/nextjs";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import { Logo } from "~/components/logo";
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "~/components/ui/alert-dialog";
|
|
||||||
|
|
||||||
interface InvitationAcceptContentProps {
|
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InvitationAcceptContent({ token }: InvitationAcceptContentProps) {
|
|
||||||
const { isLoaded, isSignedIn } = useUser();
|
|
||||||
const router = useRouter();
|
|
||||||
const [isAccepting, setIsAccepting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleAcceptInvitation = async () => {
|
|
||||||
setIsAccepting(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/invitations/accept/${token}`, {
|
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
throw new Error(errorText || "Failed to accept invitation");
|
|
||||||
}
|
|
||||||
|
|
||||||
router.push("/dashboard");
|
|
||||||
} catch (error) {
|
|
||||||
setError(error instanceof Error ? error.message : "Failed to accept invitation");
|
|
||||||
setIsAccepting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isLoaded) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50/50">
|
|
||||||
<div className="w-full max-w-md px-4 py-8">
|
|
||||||
<div className="flex flex-col items-center mb-8">
|
|
||||||
<div className="mb-6">
|
|
||||||
<Logo className="h-10" />
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-500 text-center">
|
|
||||||
A platform for managing human-robot interaction studies
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="shadow-lg">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Research Study Invitation</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
You've been invited to collaborate on a research study.
|
|
||||||
{!isSignedIn && " Please sign in or create an account to continue."}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{error && (
|
|
||||||
<div className="mb-4 p-4 text-sm text-red-800 bg-red-100 rounded-lg">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isSignedIn ? (
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button className="w-full" disabled={isAccepting}>
|
|
||||||
Accept Invitation
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Accept Research Study Invitation</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Are you sure you want to accept this invitation? You will be added as a collaborator to the research study.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={handleAcceptInvitation}
|
|
||||||
disabled={isAccepting}
|
|
||||||
>
|
|
||||||
{isAccepting ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Accepting...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Accept"
|
|
||||||
)}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
className="w-full"
|
|
||||||
onClick={() => router.push(`/sign-in?redirect_url=${encodeURIComponent(`/invite/accept/${token}`)}`)}
|
|
||||||
>
|
|
||||||
Sign In
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full"
|
|
||||||
onClick={() => router.push(`/sign-up?redirect_url=${encodeURIComponent(`/invite/accept/${token}`)}`)}
|
|
||||||
>
|
|
||||||
Create Account
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { Suspense } from "react";
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import { InvitationAcceptContent } from "./invitation-accept-content";
|
|
||||||
|
|
||||||
interface InvitationAcceptPageProps {
|
|
||||||
params: { token: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function InvitationAcceptPage({ params }: InvitationAcceptPageProps) {
|
|
||||||
const token = await Promise.resolve(params.token);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<InvitationAcceptContent token={token} />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
328
src/app/invite/page.tsx
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useSession, signOut } from "next-auth/react";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { useToast } from "~/hooks/use-toast";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { Logo } from "~/components/logo";
|
||||||
|
|
||||||
|
export default function InvitePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const token = searchParams.get("token");
|
||||||
|
|
||||||
|
// Don't fetch invitation data until we're authenticated
|
||||||
|
const { data: invitation, isLoading: isLoadingInvitation } = api.study.getInvitation.useQuery(
|
||||||
|
{ token: token! },
|
||||||
|
{
|
||||||
|
enabled: !!token && status === "authenticated",
|
||||||
|
retry: false,
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutate: acceptInvitation, isLoading: isAccepting } = api.study.acceptInvitation.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "You have successfully joined the study.",
|
||||||
|
});
|
||||||
|
router.push(`/dashboard/studies/${invitation?.studyId}`);
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show loading state for missing token
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
|
||||||
|
<Logo
|
||||||
|
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
|
||||||
|
iconClassName="text-primary"
|
||||||
|
/>
|
||||||
|
<div className="w-full max-w-[800px] px-4 py-8">
|
||||||
|
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
|
||||||
|
<CardContent className="grid p-0 md:grid-cols-2">
|
||||||
|
<div className="p-6 md:p-8">
|
||||||
|
<div className="mb-6 space-y-2">
|
||||||
|
<CardTitle className="text-2xl font-bold tracking-tight">
|
||||||
|
Invalid Invitation
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
No invitation token provided. Please check your invitation link.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button asChild className="w-full">
|
||||||
|
<Link href="/dashboard">Return to Dashboard</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="relative hidden h-full md:block">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<Logo
|
||||||
|
className="pointer-events-none"
|
||||||
|
iconClassName="h-32 w-32 mr-0 text-primary/40"
|
||||||
|
textClassName="sr-only"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show authentication required state
|
||||||
|
if (status === "unauthenticated") {
|
||||||
|
return (
|
||||||
|
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
|
||||||
|
<Logo
|
||||||
|
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
|
||||||
|
iconClassName="text-primary"
|
||||||
|
/>
|
||||||
|
<div className="w-full max-w-[800px] px-4 py-8">
|
||||||
|
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
|
||||||
|
<CardContent className="grid p-0 md:grid-cols-2">
|
||||||
|
<div className="p-6 md:p-8">
|
||||||
|
<div className="mb-6 space-y-2">
|
||||||
|
<CardTitle className="text-2xl font-bold tracking-tight">
|
||||||
|
Study Invitation
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
Sign in or create an account to view and accept this invitation.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Button asChild variant="default" className="w-full">
|
||||||
|
<Link href={`/auth/signin?callbackUrl=${encodeURIComponent('/invite?token=' + token)}`}>
|
||||||
|
Sign In
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<span className="w-full border-t" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-xs uppercase">
|
||||||
|
<span className="bg-background px-2 text-muted-foreground">
|
||||||
|
or
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline" className="w-full">
|
||||||
|
<Link href={`/auth/signup?callbackUrl=${encodeURIComponent('/invite?token=' + token)}`}>
|
||||||
|
Create Account
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative hidden h-full md:block">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<Logo
|
||||||
|
className="pointer-events-none"
|
||||||
|
iconClassName="h-32 w-32 mr-0 text-primary/40"
|
||||||
|
textClassName="sr-only"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state while checking authentication
|
||||||
|
if (status === "loading") {
|
||||||
|
return (
|
||||||
|
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
|
||||||
|
<Logo
|
||||||
|
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
|
||||||
|
iconClassName="text-primary"
|
||||||
|
/>
|
||||||
|
<div className="w-full max-w-[800px] px-4 py-8">
|
||||||
|
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
|
||||||
|
<CardContent className="grid p-0 md:grid-cols-2">
|
||||||
|
<div className="p-6 md:p-8">
|
||||||
|
<div className="mb-6 space-y-2">
|
||||||
|
<CardTitle className="text-2xl font-bold tracking-tight">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
Please wait while we load your invitation.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative hidden h-full md:block">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<Logo
|
||||||
|
className="pointer-events-none"
|
||||||
|
iconClassName="h-32 w-32 mr-0 text-primary/40"
|
||||||
|
textClassName="sr-only"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show error state for invalid invitation
|
||||||
|
if (!invitation) {
|
||||||
|
return (
|
||||||
|
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
|
||||||
|
<Logo
|
||||||
|
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
|
||||||
|
iconClassName="text-primary"
|
||||||
|
/>
|
||||||
|
<div className="w-full max-w-[800px] px-4 py-8">
|
||||||
|
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
|
||||||
|
<CardContent className="grid p-0 md:grid-cols-2">
|
||||||
|
<div className="p-6 md:p-8">
|
||||||
|
<div className="mb-6 space-y-2">
|
||||||
|
<CardTitle className="text-2xl font-bold tracking-tight">
|
||||||
|
Invalid Invitation
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
This invitation link appears to be invalid or has expired. Please request a new invitation.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button asChild className="w-full">
|
||||||
|
<Link href="/dashboard">Return to Dashboard</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="relative hidden h-full md:block">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<Logo
|
||||||
|
className="pointer-events-none"
|
||||||
|
iconClassName="h-32 w-32 mr-0 text-primary/40"
|
||||||
|
textClassName="sr-only"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
|
||||||
|
<Logo
|
||||||
|
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
|
||||||
|
iconClassName="text-primary"
|
||||||
|
/>
|
||||||
|
<div className="w-full max-w-[800px] px-4 py-8">
|
||||||
|
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
|
||||||
|
<CardContent className="grid p-0 md:grid-cols-2">
|
||||||
|
<div className="p-6 md:p-8">
|
||||||
|
<div className="mb-6 space-y-2">
|
||||||
|
<CardTitle className="text-2xl font-bold tracking-tight">
|
||||||
|
Study Invitation
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
You've been invited to join {invitation.study.title} as a {invitation.role}.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg bg-muted p-4 space-y-2">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">Study: </span>
|
||||||
|
<span className="text-sm">{invitation.study.title}</span>
|
||||||
|
</div>
|
||||||
|
{invitation.study.description && (
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">Description: </span>
|
||||||
|
<span className="text-sm">{invitation.study.description}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">Role: </span>
|
||||||
|
<span className="text-sm capitalize">{invitation.role}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">Invited by: </span>
|
||||||
|
<span className="text-sm">
|
||||||
|
{invitation.creator.firstName} {invitation.creator.lastName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">Expires: </span>
|
||||||
|
<span className="text-sm">{format(new Date(invitation.expiresAt), "PPp")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{session.user.email === invitation.email ? (
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => acceptInvitation({ token })}
|
||||||
|
disabled={isAccepting}
|
||||||
|
>
|
||||||
|
{isAccepting ? "Accepting..." : "Accept Invitation"}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This invitation was sent to {invitation.email}, but you're signed in with a different
|
||||||
|
email address ({session.user.email}).
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button asChild variant="default" className="w-full">
|
||||||
|
<Link href={`/auth/signin?callbackUrl=${encodeURIComponent('/invite?token=' + token)}`}>
|
||||||
|
Sign in with a different account
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => signOut({ callbackUrl: `/invite?token=${token}` })}
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative hidden h-full md:block">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<Logo
|
||||||
|
className="pointer-events-none"
|
||||||
|
iconClassName="h-32 w-32 mr-0 text-primary/40"
|
||||||
|
textClassName="sr-only"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,23 +1,40 @@
|
|||||||
import { ClerkProvider } from "@clerk/nextjs";
|
import "./globals.css";
|
||||||
import { Inter } from 'next/font/google';
|
|
||||||
import { Toaster } from "~/components/ui/toaster";
|
|
||||||
import "~/app/globals.css";
|
|
||||||
|
|
||||||
const inter = Inter({ subsets: ['latin'] });
|
import { GeistSans } from 'geist/font/sans';
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
import { TRPCReactProvider } from "~/trpc/react";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
import { Providers } from "~/components/providers";
|
||||||
|
import DatabaseCheck from "~/components/db-check";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "HRIStudio",
|
||||||
|
description: "A platform for managing human research studies and participant interactions.",
|
||||||
|
icons: [{ rel: "icon", url: "/favicon.ico" }],
|
||||||
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ClerkProvider>
|
<html lang="en" className="h-full">
|
||||||
<html lang="en">
|
<body className={cn(
|
||||||
<body className={inter.className}>
|
"min-h-screen bg-background font-sans antialiased",
|
||||||
|
GeistSans.className
|
||||||
|
)}>
|
||||||
|
<TRPCReactProvider {...{ headers: headers() }}>
|
||||||
|
<Providers>
|
||||||
|
<DatabaseCheck>
|
||||||
|
<div className="relative h-full">
|
||||||
{children}
|
{children}
|
||||||
<Toaster />
|
</div>
|
||||||
|
</DatabaseCheck>
|
||||||
|
</Providers>
|
||||||
|
</TRPCReactProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
</ClerkProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
537
src/app/onboarding/page.tsx
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { useToast } from "~/hooks/use-toast";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Logo } from "~/components/logo";
|
||||||
|
import { StudyForm, type StudyFormValues } from "~/components/studies/study-form";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ArrowLeft, ArrowRight, Bot, Users, Microscope, Beaker, GitBranch } from "lucide-react";
|
||||||
|
import { LucideIcon } from "lucide-react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
|
interface OnboardingStep {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
content?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define the onboarding steps
|
||||||
|
const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||||
|
{
|
||||||
|
id: "welcome",
|
||||||
|
title: "Welcome to HRIStudio",
|
||||||
|
description: "Your platform for human-robot interaction research",
|
||||||
|
icon: Bot,
|
||||||
|
content: (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
HRIStudio is a comprehensive platform designed to help researchers:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside space-y-2 text-muted-foreground">
|
||||||
|
<li>Design and run Wizard-of-Oz experiments</li>
|
||||||
|
<li>Manage research participants and data collection</li>
|
||||||
|
<li>Collaborate with team members in real-time</li>
|
||||||
|
<li>Analyze and export research data</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "roles",
|
||||||
|
title: "Understanding Roles",
|
||||||
|
description: "Different roles for different responsibilities",
|
||||||
|
icon: Users,
|
||||||
|
content: (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
HRIStudio supports various team roles:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside space-y-2 text-muted-foreground">
|
||||||
|
<li><span className="font-medium text-foreground">Owner & Admin:</span> Manage study settings and team</li>
|
||||||
|
<li><span className="font-medium text-foreground">Principal Investigator:</span> Oversee research design</li>
|
||||||
|
<li><span className="font-medium text-foreground">Wizard:</span> Control robot behavior during experiments</li>
|
||||||
|
<li><span className="font-medium text-foreground">Researcher:</span> Analyze data and results</li>
|
||||||
|
<li><span className="font-medium text-foreground">Observer:</span> View and annotate sessions</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "studies",
|
||||||
|
title: "Managing Studies",
|
||||||
|
description: "Organize your research effectively",
|
||||||
|
icon: Microscope,
|
||||||
|
content: (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Studies are the core of HRIStudio:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside space-y-2 text-muted-foreground">
|
||||||
|
<li>Create multiple studies for different research projects</li>
|
||||||
|
<li>Invite team members with specific roles</li>
|
||||||
|
<li>Manage participant recruitment and data</li>
|
||||||
|
<li>Configure experiment protocols and settings</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hierarchy",
|
||||||
|
title: "Study Structure",
|
||||||
|
description: "Understanding the experiment hierarchy",
|
||||||
|
icon: GitBranch,
|
||||||
|
content: (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="relative mx-auto w-full max-w-[400px]">
|
||||||
|
{/* Study Level */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
className="mx-auto mb-4 w-48 rounded-lg border bg-card p-3 text-center shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="font-medium">Study</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Research Project</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Connecting Line */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
className="absolute left-1/2 top-[60px] h-8 w-px -translate-x-1/2 bg-border"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Experiments Level */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
className="mx-auto mb-4 w-48 rounded-lg border bg-card p-3 text-center shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="font-medium">Experiments</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Study Protocols</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Connecting Line */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.4 }}
|
||||||
|
className="absolute left-1/2 top-[140px] h-8 w-px -translate-x-1/2 bg-border"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Trials Level */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.5 }}
|
||||||
|
className="mx-auto mb-4 w-48 rounded-lg border bg-card p-3 text-center shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="font-medium">Trials</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Individual Sessions</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Connecting Line */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.6 }}
|
||||||
|
className="absolute left-1/2 top-[220px] h-8 w-px -translate-x-1/2 bg-border"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Steps Level */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.7 }}
|
||||||
|
className="mx-auto mb-4 w-48 rounded-lg border bg-card p-3 text-center shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="font-medium">Steps</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Trial Procedures</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Connecting Line */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.8 }}
|
||||||
|
className="absolute left-1/2 bottom-[60px] h-8 w-px -translate-x-1/2 bg-border"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Actions Level */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.9 }}
|
||||||
|
className="mx-auto w-48 rounded-lg border bg-card p-3 text-center shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="font-medium">Actions</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Individual Operations</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
<p>The experiment structure flows from top to bottom:</p>
|
||||||
|
<ul className="mt-2 list-inside list-disc space-y-1">
|
||||||
|
<li><span className="font-medium text-foreground">Study:</span> Contains experiments and team members</li>
|
||||||
|
<li><span className="font-medium text-foreground">Experiments:</span> Define reusable protocols</li>
|
||||||
|
<li><span className="font-medium text-foreground">Trials:</span> Individual sessions with participants</li>
|
||||||
|
<li><span className="font-medium text-foreground">Steps:</span> Ordered procedures within a trial</li>
|
||||||
|
<li><span className="font-medium text-foreground">Actions:</span> Specific operations (movement, speech, etc.)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "experiments",
|
||||||
|
title: "Running Experiments",
|
||||||
|
description: "Conduct Wizard-of-Oz studies seamlessly",
|
||||||
|
icon: Beaker,
|
||||||
|
content: (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Design and execute experiments with ease:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside space-y-2 text-muted-foreground">
|
||||||
|
<li>Create reusable experiment templates</li>
|
||||||
|
<li>Define robot behaviors and interactions</li>
|
||||||
|
<li>Record and annotate sessions in real-time</li>
|
||||||
|
<li>Collect and analyze participant data</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "setup",
|
||||||
|
title: "Let's Get Started",
|
||||||
|
description: "Create your first study or join an existing one",
|
||||||
|
icon: Bot,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Update slideVariants
|
||||||
|
const slideVariants = {
|
||||||
|
enter: (direction: number) => ({
|
||||||
|
x: direction > 0 ? 50 : -50,
|
||||||
|
opacity: 0
|
||||||
|
}),
|
||||||
|
center: {
|
||||||
|
zIndex: 1,
|
||||||
|
x: 0,
|
||||||
|
opacity: 1
|
||||||
|
},
|
||||||
|
exit: (direction: number) => ({
|
||||||
|
zIndex: 0,
|
||||||
|
x: direction < 0 ? 50 : -50,
|
||||||
|
opacity: 0
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function OnboardingPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
const [direction, setDirection] = useState(0);
|
||||||
|
|
||||||
|
// Get invitation token if it exists
|
||||||
|
const token = searchParams.get("token");
|
||||||
|
|
||||||
|
// Fetch invitation if token exists
|
||||||
|
const { data: invitation } = api.study.getInvitation.useQuery(
|
||||||
|
{ token: token! },
|
||||||
|
{
|
||||||
|
enabled: !!token && status === "authenticated",
|
||||||
|
retry: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mutation for accepting invitation
|
||||||
|
const { mutate: acceptInvitation, isPending: isAccepting } = api.study.acceptInvitation.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "You have successfully joined the study.",
|
||||||
|
});
|
||||||
|
router.push(`/dashboard/studies/${invitation?.studyId}`);
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutation for creating a new study
|
||||||
|
const { mutate: createStudy, isPending: isCreating } = api.study.create.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Your study has been created successfully.",
|
||||||
|
});
|
||||||
|
router.push(`/dashboard/studies/${data.id}`);
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle study creation
|
||||||
|
function onCreateStudy(data: StudyFormValues) {
|
||||||
|
createStudy(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation functions
|
||||||
|
const nextStep = () => {
|
||||||
|
if (currentStep < ONBOARDING_STEPS.length - 1) {
|
||||||
|
setDirection(1);
|
||||||
|
setCurrentStep(currentStep + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevStep = () => {
|
||||||
|
if (currentStep > 0) {
|
||||||
|
setDirection(-1);
|
||||||
|
setCurrentStep(currentStep - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure currentStep is within bounds
|
||||||
|
const safeStep = Math.min(Math.max(0, currentStep), ONBOARDING_STEPS.length - 1);
|
||||||
|
const currentStepData = ONBOARDING_STEPS[safeStep]!;
|
||||||
|
const Icon = currentStepData.icon;
|
||||||
|
|
||||||
|
// Show loading state while checking authentication
|
||||||
|
if (status === "loading") {
|
||||||
|
return (
|
||||||
|
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
|
||||||
|
<Logo
|
||||||
|
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
|
||||||
|
iconClassName="text-primary"
|
||||||
|
/>
|
||||||
|
<div className="w-full max-w-[800px] px-4 py-8">
|
||||||
|
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
|
||||||
|
<CardContent className="grid p-0 md:grid-cols-2">
|
||||||
|
<div className="p-6 md:p-8">
|
||||||
|
<div className="mb-6 space-y-2">
|
||||||
|
<CardTitle className="text-2xl font-bold tracking-tight">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
Please wait while we set up your account.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative hidden h-full md:block">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<Logo
|
||||||
|
className="pointer-events-none"
|
||||||
|
iconClassName="h-32 w-32 mr-0 text-primary/40"
|
||||||
|
textClassName="sr-only"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to sign in if not authenticated
|
||||||
|
if (status === "unauthenticated") {
|
||||||
|
router.push("/auth/signin");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user has an invitation and we're on the final step
|
||||||
|
if (token && invitation && safeStep === ONBOARDING_STEPS.length - 1) {
|
||||||
|
return (
|
||||||
|
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
|
||||||
|
<Logo
|
||||||
|
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
|
||||||
|
iconClassName="text-primary"
|
||||||
|
/>
|
||||||
|
<div className="w-full max-w-[800px] px-4 py-8">
|
||||||
|
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
|
||||||
|
<CardContent className="grid p-0 md:grid-cols-2">
|
||||||
|
<div className="p-6 md:p-8">
|
||||||
|
<div className="mb-6 space-y-2">
|
||||||
|
<CardTitle className="text-2xl font-bold tracking-tight">
|
||||||
|
Join {invitation.study.title}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
You've been invited to join as a {invitation.role}.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{session?.user.email === invitation.email ? (
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => acceptInvitation({ token })}
|
||||||
|
disabled={isAccepting}
|
||||||
|
>
|
||||||
|
{isAccepting ? "Joining Study..." : "Join Study"}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This invitation was sent to {invitation.email}, but you're signed in with a different
|
||||||
|
email address ({session?.user.email}).
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => router.push("/auth/signin")}
|
||||||
|
>
|
||||||
|
Sign in with correct account
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative hidden h-full md:block">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<Logo
|
||||||
|
className="pointer-events-none"
|
||||||
|
iconClassName="h-32 w-32 mr-0 text-primary/40"
|
||||||
|
textClassName="sr-only"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
|
||||||
|
<Logo
|
||||||
|
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
|
||||||
|
iconClassName="text-primary"
|
||||||
|
/>
|
||||||
|
<div className="w-full max-w-[1000px] px-4 py-8">
|
||||||
|
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
|
||||||
|
<CardContent className="grid p-0 md:grid-cols-2">
|
||||||
|
<div className="relative p-6 md:p-8">
|
||||||
|
<div className="mb-6 space-y-2">
|
||||||
|
<motion.div
|
||||||
|
className="mb-8 flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10"
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
>
|
||||||
|
<Icon className="h-6 w-6 text-primary" />
|
||||||
|
</motion.div>
|
||||||
|
<CardTitle className="text-2xl font-bold tracking-tight">
|
||||||
|
{currentStepData.title}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
{currentStepData.description}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative h-[280px]">
|
||||||
|
<AnimatePresence mode="wait" custom={direction}>
|
||||||
|
<motion.div
|
||||||
|
key={currentStepData.id}
|
||||||
|
custom={direction}
|
||||||
|
variants={slideVariants}
|
||||||
|
initial="enter"
|
||||||
|
animate="center"
|
||||||
|
exit="exit"
|
||||||
|
transition={{
|
||||||
|
x: { type: "spring", stiffness: 300, damping: 30 },
|
||||||
|
opacity: { duration: 0.2 }
|
||||||
|
}}
|
||||||
|
className="absolute inset-0"
|
||||||
|
>
|
||||||
|
<div className="relative h-full">
|
||||||
|
<div className="h-full overflow-y-auto pr-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-muted-foreground/20 hover:scrollbar-thumb-muted-foreground/30">
|
||||||
|
{safeStep === ONBOARDING_STEPS.length - 1 ? (
|
||||||
|
<StudyForm
|
||||||
|
defaultValues={{ title: "", description: "" }}
|
||||||
|
onSubmit={onCreateStudy}
|
||||||
|
isSubmitting={isCreating}
|
||||||
|
submitLabel="Create Study"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{currentStepData.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-between pt-4">
|
||||||
|
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={prevStep}
|
||||||
|
disabled={safeStep === 0}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
||||||
|
<Button onClick={nextStep}>
|
||||||
|
Next
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative hidden h-full md:block">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<Logo
|
||||||
|
className="pointer-events-none"
|
||||||
|
iconClassName="h-32 w-32 mr-0 text-primary/40"
|
||||||
|
textClassName="sr-only"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-8 left-8 right-8">
|
||||||
|
<div className="flex justify-between gap-2">
|
||||||
|
{ONBOARDING_STEPS.map((step, index) => (
|
||||||
|
<div
|
||||||
|
key={step.id}
|
||||||
|
className={`h-1 flex-1 rounded-full transition-colors ${
|
||||||
|
index <= safeStep ? "bg-primary" : "bg-border"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
src/app/page.tsx
@@ -1,100 +1,70 @@
|
|||||||
'use client';
|
import { getServerAuthSession } from "~/server/auth";
|
||||||
|
|
||||||
import { SignedIn, SignedOut, SignInButton, SignUpButton, UserButton } from "@clerk/nextjs";
|
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { BotIcon } from "lucide-react";
|
|
||||||
import { Logo } from "~/components/logo";
|
import { Logo } from "~/components/logo";
|
||||||
|
import { HeroSection } from "~/components/home/hero-section";
|
||||||
|
import { FeaturesSection } from "~/components/home/features-section";
|
||||||
|
import { CTASection } from "~/components/home/cta-section";
|
||||||
|
|
||||||
|
export default async function Home() {
|
||||||
|
const session = await getServerAuthSession();
|
||||||
|
const isLoggedIn = !!session;
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background relative">
|
||||||
|
{/* Background Gradients */}
|
||||||
|
<div className="pointer-events-none fixed inset-0 flex items-center justify-center opacity-40">
|
||||||
|
<div className="h-[800px] w-[800px] rounded-full bg-gradient-to-r from-primary/20 via-secondary/20 to-background blur-3xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Navigation Bar */}
|
{/* Navigation Bar */}
|
||||||
<nav className="border-b bg-card/50 backdrop-blur supports-[backdrop-filter]:bg-card/50">
|
<nav className="sticky top-0 z-50 border-b bg-background/50 backdrop-blur supports-[backdrop-filter]:bg-background/50">
|
||||||
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
|
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
<Logo />
|
<Logo />
|
||||||
|
</div>
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<SignedOut>
|
{!session && (
|
||||||
<SignInButton mode="modal">
|
<>
|
||||||
<Button variant="ghost">Sign In</Button>
|
<Button variant="ghost" asChild>
|
||||||
</SignInButton>
|
<Link href="/auth/signin">Sign In</Link>
|
||||||
<SignUpButton mode="modal">
|
</Button>
|
||||||
<Button>Sign Up</Button>
|
<Button asChild>
|
||||||
</SignUpButton>
|
<Link href="/auth/signup">Sign Up</Link>
|
||||||
</SignedOut>
|
</Button>
|
||||||
<SignedIn>
|
</>
|
||||||
<UserButton afterSignOutUrl="/" />
|
)}
|
||||||
</SignedIn>
|
{session && (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/dashboard">Dashboard</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Hero Section */}
|
{/* Content Sections */}
|
||||||
<section className="container mx-auto px-4 py-24 grid lg:grid-cols-2 gap-12 items-center">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-bold tracking-tight lg:text-6xl">
|
|
||||||
Streamline Your HRI Research
|
|
||||||
</h1>
|
|
||||||
<p className="mt-6 text-xl text-muted-foreground">
|
|
||||||
A comprehensive platform for designing, executing, and analyzing Wizard-of-Oz experiments in human-robot interaction studies.
|
|
||||||
</p>
|
|
||||||
<div className="mt-8 flex flex-col sm:flex-row gap-4">
|
|
||||||
<SignedOut>
|
|
||||||
<SignUpButton mode="modal">
|
|
||||||
<Button size="lg" className="w-full sm:w-auto">
|
|
||||||
Get Started
|
|
||||||
</Button>
|
|
||||||
</SignUpButton>
|
|
||||||
</SignedOut>
|
|
||||||
<SignedIn>
|
|
||||||
<Button size="lg" className="w-full sm:w-auto" asChild>
|
|
||||||
<Link href="/dashboard">
|
|
||||||
Go to Dashboard
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</SignedIn>
|
|
||||||
<Button size="lg" variant="outline" className="w-full sm:w-auto" asChild>
|
|
||||||
<Link href="https://github.com/soconnor0919/hristudio" target="_blank">
|
|
||||||
View on GitHub
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Image
|
<HeroSection isLoggedIn={isLoggedIn} />
|
||||||
src="/hristudio_laptop.png"
|
|
||||||
alt="HRIStudio Interface"
|
|
||||||
width={800}
|
|
||||||
height={600}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Features Section */}
|
{/* Dotted pattern for content sections */}
|
||||||
<section className="container mx-auto px-4 py-24">
|
<div className="relative">
|
||||||
<div className="grid md:grid-cols-3 gap-8">
|
<div
|
||||||
<div className="space-y-4">
|
className="absolute inset-0 pointer-events-none opacity-30"
|
||||||
<h3 className="text-xl font-semibold">Visual Experiment Design</h3>
|
style={{
|
||||||
<p className="text-muted-foreground">
|
backgroundImage: `
|
||||||
Create and configure experiments using an intuitive drag-and-drop interface without extensive coding.
|
radial-gradient(circle at 1px 1px, hsl(var(--muted-foreground)) 1px, transparent 0),
|
||||||
</p>
|
linear-gradient(to bottom, transparent, hsl(var(--background)))
|
||||||
</div>
|
`,
|
||||||
<div className="space-y-4">
|
backgroundSize: '32px 32px, 100% 100%',
|
||||||
<h3 className="text-xl font-semibold">Real-time Control</h3>
|
maskImage: 'linear-gradient(to bottom, transparent, black 10%, black 90%, transparent)',
|
||||||
<p className="text-muted-foreground">
|
}}
|
||||||
Execute experiments with synchronized views for wizards and observers, enabling seamless collaboration.
|
/>
|
||||||
</p>
|
|
||||||
</div>
|
<FeaturesSection />
|
||||||
<div className="space-y-4">
|
<CTASection isLoggedIn={isLoggedIn} />
|
||||||
<h3 className="text-xl font-semibold">Comprehensive Analysis</h3>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Record, playback, and analyze experimental data with built-in annotation and export tools.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { SignIn } from "@clerk/nextjs";
|
|
||||||
|
|
||||||
export default function SignInPage() {
|
|
||||||
return (
|
|
||||||
<div className="container flex items-center justify-center min-h-screen py-10">
|
|
||||||
<SignIn />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { SignUp } from "@clerk/nextjs";
|
|
||||||
|
|
||||||
export default function SignUpPage() {
|
|
||||||
return (
|
|
||||||
<div className="container flex items-center justify-center min-h-screen py-10">
|
|
||||||
<SignUp />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
22
src/auth.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Auth } from "@auth/core"
|
||||||
|
import Credentials from "@auth/core/providers/credentials"
|
||||||
|
|
||||||
|
export const authOptions: AuthConfig = {
|
||||||
|
providers: [
|
||||||
|
Credentials({
|
||||||
|
credentials: {
|
||||||
|
email: { label: "Email", type: "email" },
|
||||||
|
password: { label: "Password", type: "password" }
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
// Drizzle ORM user lookup
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: (users, { eq }) => eq(users.email, credentials.email)
|
||||||
|
})
|
||||||
|
return verifyPassword(credentials.password, user?.password) ? user : null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
session: { strategy: "jwt" },
|
||||||
|
adapter: DrizzleAdapter(db)
|
||||||
|
}
|
||||||
138
src/components/auth/sign-in-form.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "~/components/ui/form";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { useToast } from "~/hooks/use-toast";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const signInSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8),
|
||||||
|
});
|
||||||
|
|
||||||
|
type SignInValues = z.infer<typeof signInSchema>;
|
||||||
|
|
||||||
|
interface SignInFormProps {
|
||||||
|
error?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SignInForm({ error }: SignInFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Show error toast if credentials are invalid
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Invalid email or password",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [error, toast]);
|
||||||
|
|
||||||
|
const form = useForm<SignInValues>({
|
||||||
|
resolver: zodResolver(signInSchema),
|
||||||
|
defaultValues: {
|
||||||
|
email: searchParams.get("email") ?? "",
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit(data: SignInValues) {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await signIn("credentials", {
|
||||||
|
redirect: false,
|
||||||
|
email: data.email,
|
||||||
|
password: data.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Invalid email or password",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
|
||||||
|
router.push(callbackUrl);
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Something went wrong. Please try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="name@example.com"
|
||||||
|
{...field}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
{...field}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading ? "Signing in..." : "Sign in"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
210
src/components/auth/sign-up-form.tsx
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "~/components/ui/form";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { useToast } from "~/hooks/use-toast";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const signUpSchema = z.object({
|
||||||
|
firstName: z.string().min(1, "First name is required"),
|
||||||
|
lastName: z.string().min(1, "Last name is required"),
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8),
|
||||||
|
});
|
||||||
|
|
||||||
|
type SignUpValues = z.infer<typeof signUpSchema>;
|
||||||
|
|
||||||
|
interface SignUpFormProps {
|
||||||
|
error?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SignUpForm({ error }: SignUpFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Show error toast if credentials are invalid
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Something went wrong. Please try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [error, toast]);
|
||||||
|
|
||||||
|
const form = useForm<SignUpValues>({
|
||||||
|
resolver: zodResolver(signUpSchema),
|
||||||
|
defaultValues: {
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
email: searchParams.get("email") ?? "",
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit(data: SignUpValues) {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("firstName", data.firstName);
|
||||||
|
formData.append("lastName", data.lastName);
|
||||||
|
formData.append("email", data.email);
|
||||||
|
formData.append("password", data.password);
|
||||||
|
|
||||||
|
const response = await fetch("/api/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error ?? "Something went wrong");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await signIn("credentials", {
|
||||||
|
redirect: false,
|
||||||
|
email: data.email,
|
||||||
|
password: data.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Something went wrong. Please try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the invitation token if it exists
|
||||||
|
const token = searchParams.get("token");
|
||||||
|
|
||||||
|
// Redirect to onboarding with token if it exists
|
||||||
|
const onboardingUrl = token
|
||||||
|
? `/onboarding?token=${token}`
|
||||||
|
: "/onboarding";
|
||||||
|
|
||||||
|
router.push(onboardingUrl);
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Something went wrong. Please try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="firstName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>First Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="John"
|
||||||
|
{...field}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="lastName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Last Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Doe"
|
||||||
|
{...field}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="name@example.com"
|
||||||
|
{...field}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
{...field}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading ? "Creating account..." : "Create account"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
162
src/components/auth/study-switcher.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { Notebook, ChevronsUpDown, Plus } from "lucide-react"
|
||||||
|
import { useSession } from "next-auth/react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "~/components/ui/dropdown-menu"
|
||||||
|
import {
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
useSidebar,
|
||||||
|
} from "~/components/ui/sidebar"
|
||||||
|
import { useStudy } from "~/components/providers/study-provider"
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
export function StudySwitcher() {
|
||||||
|
const { status } = useSession()
|
||||||
|
|
||||||
|
// Show nothing while loading to prevent flash
|
||||||
|
if (status === "loading") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return <StudySwitcherContent />
|
||||||
|
}
|
||||||
|
|
||||||
|
function StudySwitcherContent() {
|
||||||
|
const { isMobile, state } = useSidebar()
|
||||||
|
const router = useRouter()
|
||||||
|
const { studies, activeStudy, setActiveStudy, isLoading } = useStudy()
|
||||||
|
|
||||||
|
const handleCreateStudy = () => {
|
||||||
|
router.push("/dashboard/studies/new")
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCollapsed = state === "collapsed"
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
size="lg"
|
||||||
|
className="animate-pulse"
|
||||||
|
>
|
||||||
|
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-accent/10">
|
||||||
|
<Notebook className="size-4 text-muted-foreground/50" />
|
||||||
|
</div>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<div className="grid flex-1 gap-1">
|
||||||
|
<div className="h-4 w-24 rounded bg-sidebar-accent/10" />
|
||||||
|
<div className="h-3 w-16 rounded bg-sidebar-accent/10" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!studies || studies.length === 0) {
|
||||||
|
return (
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
size="lg"
|
||||||
|
onClick={handleCreateStudy}
|
||||||
|
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
|
>
|
||||||
|
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||||
|
<Plus className="size-4" />
|
||||||
|
</div>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span className="truncate font-semibold">Create Study</span>
|
||||||
|
<span className="truncate text-xs">Get started</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<SidebarMenuButton
|
||||||
|
size="lg"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground",
|
||||||
|
isCollapsed && "justify-center p-0"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||||
|
<Notebook className="size-4" />
|
||||||
|
</div>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<>
|
||||||
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span className="truncate font-semibold">
|
||||||
|
{activeStudy?.title ?? "Select Study"}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs">{activeStudy?.role ?? ""}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronsUpDown className="ml-auto size-4" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
className="min-w-56 rounded-lg"
|
||||||
|
align="start"
|
||||||
|
side={isMobile ? "bottom" : "right"}
|
||||||
|
sideOffset={4}
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||||
|
Studies
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
{studies.map((study) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={study.id}
|
||||||
|
onClick={() => setActiveStudy(study)}
|
||||||
|
className="gap-2 p-2"
|
||||||
|
>
|
||||||
|
<div className="flex size-6 items-center justify-center rounded-sm border">
|
||||||
|
<Notebook className="size-4 shrink-0" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p>{study.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{study.role}</p>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={handleCreateStudy}
|
||||||
|
className="gap-2 p-2"
|
||||||
|
>
|
||||||
|
<div className="flex size-6 items-center justify-center rounded-md">
|
||||||
|
<Plus className="size-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="font-medium text-muted-foreground">Create new study</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
35
src/components/auth/user-avatar.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { User } from "next-auth";
|
||||||
|
import { Avatar, AvatarFallback } from "~/components/ui/avatar";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
interface UserAvatarProps {
|
||||||
|
user: Pick<User, "name" | "image">;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserAvatar({ user, className }: UserAvatarProps) {
|
||||||
|
return (
|
||||||
|
<Avatar className={className}>
|
||||||
|
{user.image ? (
|
||||||
|
<div className="relative size-full">
|
||||||
|
<Image
|
||||||
|
alt={user.name ?? "Avatar"}
|
||||||
|
src={user.image}
|
||||||
|
fill
|
||||||
|
sizes="32px"
|
||||||
|
className="rounded-full object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
console.error("Error loading avatar image:", user.image);
|
||||||
|
e.currentTarget.style.display = "none";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<AvatarFallback>
|
||||||
|
{user.name?.charAt(0).toUpperCase() ?? "?"}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
);
|
||||||
|
}
|
||||||