6 Commits

Author SHA1 Message Date
ab08c1b724 feat: Enhance plugin store and experiment design infrastructure
- Add plugin store system with dynamic loading of robot actions
- Implement plugin store API routes and database schema
- Update experiment designer to support plugin-based actions
- Refactor environment configuration and sidebar navigation
- Improve authentication session handling with additional user details
- Update Tailwind CSS configuration and global styles
- Remove deprecated files and consolidate project structure
2025-02-28 11:10:56 -05:00
88c305de61 docs: add plan for plugin store 2025-02-12 10:43:42 -05:00
4901729bd9 feat: Implement visual experiment designer and enhance landing page
- Add drag-and-drop experiment design capabilities using @dnd-kit libraries
- Introduce new experiment-related database schema and API routes
- Enhance landing page with modern design, gradients, and improved call-to-action sections
- Update app sidebar to include experiments navigation
- Add new dependencies for experiment design and visualization (reactflow, react-zoom-pan-pinch)
- Modify study and experiment schemas to support more flexible experiment configuration
- Implement initial experiment creation and management infrastructure
2025-02-12 10:35:57 -05:00
ec4d8db16e feat: Add skeleton loaders for improved loading states in studies and participants pages
- Implemented skeleton components for studies list, study details, and participants table
- Enhanced loading experience by replacing simple "Loading..." placeholders with detailed skeleton loaders
- Created reusable skeleton components in `src/components/ui/skeleton.tsx`
- Updated studies and participants pages to use new skeleton loaders
2025-02-12 00:03:24 -05:00
6e3f2e1601 chore(deps): Update project dependencies and refactor authentication flow
- Upgrade Next.js to version 15.1.7
- Update Drizzle ORM and related dependencies
- Add Nodemailer and related type definitions
- Refactor authentication routes and components
- Modify user schema to include first and last name
- Update authentication configuration and session handling
- Remove deprecated login and register pages
- Restructure authentication-related components and routes
2025-02-11 23:55:27 -05:00
e6962aef79 feat: rewrite project 2025-02-01 01:23:55 -05:00
239 changed files with 31438 additions and 12303 deletions

View File

@@ -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
- 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).
- Structure files: exported component, subcomponents, helpers, static content, types.
- When working with a database, use Drizzle ORM.
- When working with authentication, use Clerk.
- When working with authentication, use Auth.js v5.
Naming Conventions
- 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.
- 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

View File

@@ -1,20 +1,40 @@
# Clerk Authentication
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_
CLERK_SECRET_KEY=sk_test_
# Since the ".env" file is gitignored, you can use the ".env.example" file to
# build a new ".env" file when you clone the repo. Keep this file up-to-date
# when you add new variables to `.env`.
# Database
POSTGRES_URL="postgresql://user:password@localhost:5432/dbname"
# This file will be committed to version control, so make sure not to have any
# 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
NEXT_PUBLIC_APP_URL="http://localhost:3000"
# When adding additional environment variables, the schema in "/src/env.js"
# should be updated accordingly.
# Email (SMTP)
SMTP_HOST=smtp.mail.me.com
SMTP_PORT=587
SMTP_USER=your-email@example.com
SMTP_PASSWORD=your-app-specific-password
SMTP_FROM_ADDRESS=noreply@yourdomain.com
# Next Auth
# You can generate a new secret on the command line with:
# npx auth secret
# https://next-auth.js.org/configuration/options#secret
AUTH_SECRET=""
# Optional: For production deployments
# NEXT_PUBLIC_APP_URL="https://yourdomain.com"
# VERCEL_URL="https://yourdomain.com"
# Next Auth Discord Provider
AUTH_DISCORD_ID=""
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
View 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;

View File

@@ -1,6 +0,0 @@
{
"extends": "next/core-web-vitals",
"rules": {
"@typescript-eslint/no-empty-interface": "off"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 816 KiB

25
.vscode/settings.json vendored
View File

@@ -1,8 +1,21 @@
{
"conventionalCommits.scopes": [
"homepage",
"repo",
"auth",
"perms"
]
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"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
}

View File

@@ -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
View File

@@ -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.
![HRIStudio Homepage](.github/homepage-screenshot.png)
## 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
A modern web application for managing human-robot interaction studies, built with Next.js 15, TypeScript, and the App Router.
## Tech Stack
- [Next.js](https://nextjs.org/) - React framework with App Router
- [TypeScript](https://www.typescriptlang.org/) - Static type checking
- [Clerk](https://clerk.com/) - Authentication and user management
- [Drizzle ORM](https://orm.drizzle.team/) - TypeScript ORM
- [PostgreSQL](https://www.postgresql.org/) - Database
- [TailwindCSS](https://tailwindcss.com/) - Utility-first CSS
- [Shadcn UI](https://ui.shadcn.com/) - Component library
- [Radix UI](https://www.radix-ui.com/) - Accessible component primitives
- [Lucide Icons](https://lucide.dev/) - Icon system
- **Framework**: Next.js 15 with App Router
- **Language**: TypeScript
- **Authentication**: NextAuth.js
- **Database**: PostgreSQL with Drizzle ORM
- **UI Components**: Shadcn UI + Radix UI
- **Styling**: Tailwind CSS
- **API Layer**: tRPC
- **File Storage**: MinIO (S3-compatible)
## Getting Started
## Key Principles
1. Clone the repository:
```bash
git clone https://github.com/yourusername/hristudio.git
```
### TypeScript Usage
- Use TypeScript for all code files
- Prefer interfaces over types
- Avoid enums; use const objects with `as const` instead
- Use proper type inference with `zod` schemas
2. Install dependencies:
```bash
pnpm install
```
### Component Structure
- Use functional components with TypeScript interfaces
- Structure files in this order:
1. Exported component
2. Subcomponents
3. Helper functions
4. Static content
5. Types/interfaces
3. Set up environment variables:
```bash
cp .env.example .env
```
### Naming Conventions
- Use lowercase with dashes for directories (e.g., `components/auth-wizard`)
- 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:
```bash
pnpm db:push
```
### Data Management
- Use Drizzle ORM for database operations
- 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:
```bash
pnpm dev
```
6. Open [http://localhost:3000](http://localhost:3000) in your browser
## Project Structure
### Authentication
- Use NextAuth.js for authentication
- Handle user sessions with JWT strategy
- Store passwords with bcrypt hashing
- Implement proper CSRF protection
### File Structure
```
src/
├── app/ # Next.js app router pages and API routes
├── components/ # React components
│ ├── ui/ # Shadcn UI components
│ └── ... # Feature-specific components
├── context/ # React context providers
├── db/ # Database schema and configuration
├── hooks/ # Custom React hooks
├── lib/ # Utility functions and permissions
└── types/ # TypeScript type definitions
├── app/ # Next.js App Router pages
├── components/
│ ├── ui/ # Reusable UI components
│ └── layout/ # Layout components
├── server/
│ ├── api/ # tRPC routers
│ ├── auth/ # Authentication config
│ └── db/ # Database schema and config
└── 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
- Run `pnpm db:studio` to open the Drizzle Studio database UI
- Use `pnpm lint` to check for code style issues
- Run `pnpm build` to create a production build
```bash
# Install dependencies
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
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

1662
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
@@ -16,5 +16,6 @@
"ui": "~/components/ui",
"lib": "~/lib",
"hooks": "~/hooks"
}
},
"iconLibrary": "lucide"
}

47
docker-compose.yml Normal file
View 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
View 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
View 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
View 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.

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
/Users/soconnor/Projects/csci378/hristudio-sp2025/root.tex

169
docs/structure.md Normal file
View 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
View 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

View File

@@ -1,14 +1,19 @@
import 'dotenv/config';
import { config } from 'dotenv';
import { defineConfig } from 'drizzle-kit';
import { type Config } from "drizzle-kit";
config({ path: '.env.local' });
import { env } from "~/env.mjs";
export default defineConfig({
out: './drizzle',
schema: './src/db/schema.ts',
dialect: 'postgresql',
export default {
schema: "./src/server/db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
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
View 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;

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -2,60 +2,104 @@
"name": "hristudio",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"check": "next lint && tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"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:seed": "tsx src/db/seed.ts",
"ngrok:start": "ngrok http --url=endless-pegasus-happily.ngrok-free.app 3000",
"db:drop": "tsx src/db/drop.ts",
"db:reset": "pnpm db:drop && pnpm db:push && pnpm db:seed",
"test:email": "tsx src/scripts/test-email.ts"
"dev": "next dev --turbo",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"lint": "next lint",
"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": {
"@clerk/nextjs": "^6.7.1",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.2",
"@types/nodemailer": "^6.4.17",
"@vercel/analytics": "^1.4.1",
"@vercel/postgres": "^0.10.0",
"@auth/drizzle-adapter": "^1.7.4",
"@aws-sdk/client-s3": "^3.735.0",
"@aws-sdk/lib-storage": "^3.735.0",
"@aws-sdk/s3-request-presigner": "^3.735.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@hookform/resolvers": "^3.10.0",
"@paralleldrive/cuid2": "^2.2.2",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-collapsible": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.5",
"@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",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^4.1.0",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.37.0",
"lucide-react": "^0.468.0",
"next": "15.0.3",
"ngrok": "5.0.0-beta.2",
"nodemailer": "^6.9.16",
"punycode": "^2.3.1",
"drizzle-orm": "^0.39.3",
"framer-motion": "^12.0.6",
"geist": "^1.3.1",
"lucide-react": "^0.474.0",
"next": "^15.1.7",
"next-auth": "^4.24.11",
"nodemailer": "^6.10.0",
"postgres": "^3.4.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"svix": "^1.42.0",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7"
"react-easy-crop": "^5.2.0",
"react-hook-form": "^7.54.2",
"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": {
"@types/node": "^22.10.1",
"@types/react": "^18.3.13",
"@types/react-dom": "^18.3.1",
"drizzle-kit": "^0.29.1",
"eslint": "^9.16.0",
"eslint-config-next": "15.0.3",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
"@types/bcryptjs": "^2.4.6",
"@types/eslint": "^8.56.10",
"@types/node": "^20.14.10",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^8.1.0",
"@typescript-eslint/parser": "^8.1.0",
"drizzle-kit": "^0.30.4",
"eslint": "^8.57.0",
"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"
}
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

5
postcss.config.js Normal file
View File

@@ -0,0 +1,5 @@
export default {
plugins: {
tailwindcss: {},
},
};

View File

@@ -1,8 +0,0 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

4
prettier.config.js Normal file
View File

@@ -0,0 +1,4 @@
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
export default {
plugins: ["prettier-plugin-tailwindcss"],
};

View File

@@ -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

View File

@@ -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
View 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

View File

@@ -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

View File

@@ -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}

View File

@@ -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

View File

@@ -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
View 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!"

View 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 };

View 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 }
);
}
}

View 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 }
);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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 });
}
}

View File

@@ -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);
}
}

View 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 }
);
}
}

View File

@@ -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 });
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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 };

View 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 }
);
}
}

View File

@@ -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 });
}

View 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>
);
}

View 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>
);
}

View 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"
/>
)}
</>
)
}

View 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>
);
}

View File

@@ -1,26 +1,104 @@
import { Sidebar } from "~/components/sidebar";
import { Breadcrumb } from "~/components/breadcrumb";
import { ActiveStudyProvider } from "~/context/active-study";
import { StudyProvider } from "~/context/StudyContext";
"use client"
import { useEffect } from "react"
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({
children,
}: {
children: React.ReactNode
}) {
return (
<ActiveStudyProvider>
<StudyProvider>
<div className="flex h-screen">
<Sidebar />
<div className="flex-1 flex flex-col min-h-0">
<main className="flex-1 overflow-y-auto p-6">
<Breadcrumb />
{children}
</main>
</div>
</div>
</StudyProvider>
</ActiveStudyProvider>
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 (
<SidebarProvider>
<PluginStoreProvider>
<StudyProvider>
<div className="flex h-full min-h-screen w-full bg-muted/40 dark:bg-background relative">
{/* Background Elements */}
<div className="pointer-events-none fixed inset-0 z-0">
{/* Base Gradient */}
<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}
</PageTransition>
</main>
</div>
</div>
</StudyProvider>
</PluginStoreProvider>
</SidebarProvider>
)
}

View File

@@ -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";
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 (
<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]" />
export default function DashboardPage() {
return (
<>
<PageHeader
title="Dashboard"
description="Welcome to your research platform."
/>
<PageContent>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Studies</CardTitle>
<Beaker className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">0</div>
<p className="text-xs text-muted-foreground">
Active research studies
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Participants</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">0</div>
<p className="text-xs text-muted-foreground">
Across all studies
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Quick Actions</CardTitle>
</CardHeader>
<CardContent>
<Button asChild variant="outline" className="w-full">
<Link href="/dashboard/studies/new">
<Plus className="mr-2 h-4 w-4" />
Create New Study
</Link>
</Button>
</CardContent>
</Card>
</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>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Total Studies
</CardTitle>
<BookOpen className="h-4 w-4 text-muted-foreground" />
<CardTitle>Recent Activity</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.studyCount}</div>
<p className="text-xs text-muted-foreground">
Active research studies
<p className="text-sm text-muted-foreground">
No recent activity to show.
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Pending Invitations
</CardTitle>
<Settings2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.activeInvitationCount}</div>
<p className="text-xs text-muted-foreground">
Awaiting responses
</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Quick Actions</CardTitle>
<CardDescription>Common tasks and actions</CardDescription>
</CardHeader>
<CardContent className="flex gap-4">
<Button onClick={() => router.push('/dashboard/studies/new')}>
Create New Study
</Button>
<Button
variant="outline"
onClick={() => router.push('/dashboard/settings')}
>
<Settings2 className="w-4 h-4 mr-2" />
Settings
</Button>
</CardContent>
</Card>
</div>
);
</PageContent>
</>
)
}

View File

@@ -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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
</>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
</>
);
}

View 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>
</>
);
}

View 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>
</>
);
}

View File

@@ -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;
}

View File

@@ -1,182 +1,108 @@
'use client';
"use client";
import { useCallback, useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { useRouter, useSearchParams } 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 { useToast } from "~/hooks/use-toast";
import { Plus, Users, FileText, BarChart, PlayCircle } from "lucide-react";
import Link from "next/link";
import { useActiveStudy } from "~/context/active-study";
import { getApiUrl } from "~/lib/fetch-utils";
import { Skeleton } from "~/components/ui/skeleton";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
import { Pencil as PencilIcon } from "lucide-react";
import { use } from "react";
import { StudyOverview } from "~/components/studies/study-overview";
import { StudyParticipants } from "~/components/studies/study-participants";
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 {
participantCount: number;
formCount: number;
trialCount: number;
}
export default function StudyPage({ params }: { params: Promise<{ id: string }> }) {
const router = useRouter();
const searchParams = useSearchParams();
const resolvedParams = use(params);
const id = Number(resolvedParams.id);
const activeTab = searchParams.get("tab") ?? "overview";
export default function StudyDashboard() {
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 { data: study, isLoading: isLoadingStudy } = api.study.getById.useQuery({ id });
const fetchStats = useCallback(async () => {
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) {
if (isLoadingStudy) {
return (
<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>
</div>
<div className="grid gap-4 grid-cols-1 md:grid-cols-3">
{[1, 2, 3].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>
<>
<PageHeader
title="Loading..."
description="Please wait while we load the study details"
/>
<PageContent>
<Tabs defaultValue="overview" className="space-y-4">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="participants">Participants</TabsTrigger>
<TabsTrigger value="members">Members</TabsTrigger>
<TabsTrigger value="metadata">Metadata</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<StudyDetailsSkeleton />
</TabsContent>
</Tabs>
</PageContent>
</>
);
}
if (!study) {
return <div>Study not found</div>;
}
const canEdit = study.role === "admin";
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">{activeStudy?.title}</h2>
<p className="text-muted-foreground">
Overview of your study&apos;s progress and statistics
</p>
</div>
</div>
<div className="grid gap-4 grid-cols-1 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<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>
<>
<PageHeader
title={study.title}
description={study.description ?? "No description provided"}
>
{canEdit && (
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/dashboard/studies/${id}/edit`)}
>
<PencilIcon className="h-4 w-4 mr-2" />
Edit Study
</Button>
<Button variant="outline" asChild>
<Link href={`/dashboard/studies/${id}/trials/new`}>
<PlayCircle className="w-4 h-4 mr-2" />
Start Trial
</Link>
</Button>
</CardContent>
</Card>
</div>
)}
</PageHeader>
<PageContent>
<Tabs defaultValue={activeTab} className="space-y-4">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="participants">Participants</TabsTrigger>
<TabsTrigger value="members">Members</TabsTrigger>
<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>
</>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -1,112 +1,102 @@
'use client';
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
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 { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { ParticipantForm, type ParticipantFormValues } from "~/components/participants/participant-form";
import { use } from "react";
import { useToast } from "~/hooks/use-toast";
import { ArrowLeft } from "lucide-react";
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";
import { ROLES } from "~/lib/permissions/constants";
export default function NewParticipant() {
const [name, setName] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const { id } = useParams();
function generateIdentifier(studyId: number, count: number) {
// Format: P001, P002, etc. with study prefix
const paddedCount = String(count + 1).padStart(3, '0');
return `P${paddedCount}`;
}
export default function NewParticipantPage({ params }: { params: Promise<{ id: string }> }) {
const router = useRouter();
const { toast } = useToast();
const { activeStudy } = useActiveStudy();
const resolvedParams = use(params);
const studyId = Number(resolvedParams.id);
useEffect(() => {
if (!activeStudy || !hasPermission(activeStudy.permissions, PERMISSIONS.CREATE_PARTICIPANT)) {
router.push(`/dashboard/studies/${id}`);
}
}, [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 { data: study } = api.study.getById.useQuery({ id: studyId });
const { data: participantCount = 0 } = api.participant.getCount.useQuery(
{ studyId },
{ enabled: !!study }
);
const { mutate: createParticipant, isPending: isCreating } = api.participant.create.useMutation({
onSuccess: () => {
toast({
title: "Success",
description: "Participant created successfully",
description: "Participant added successfully",
});
router.push(`/dashboard/studies/${id}/participants`);
} catch (error) {
console.error("Error creating participant:", error);
router.push(`/dashboard/studies/${studyId}/participants`);
router.refresh();
},
onError: (error) => {
toast({
title: "Error",
description: "Failed to create participant",
description: error.message,
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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<Button
variant="ghost"
className="gap-2"
asChild
>
<Link href={`/dashboard/studies/${id}/participants`}>
<ArrowLeft className="h-4 w-4" />
Back to Participants
</Link>
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Add New Participant</CardTitle>
<CardDescription>
Create a new participant for {activeStudy?.title}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Participant Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter participant name"
required
/>
</div>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create Participant"}
</Button>
</form>
</CardContent>
</Card>
</div>
<>
<PageHeader
title="Add Participant"
description={`Add a new participant to ${study.title}`}
/>
<PageContent>
<Card>
<CardHeader>
<CardTitle>Participant Details</CardTitle>
<CardDescription>
Enter the participant's information. Fields marked with * are required.
</CardDescription>
</CardHeader>
<CardContent>
<ParticipantForm
defaultValues={{
identifier: generateIdentifier(studyId, participantCount),
email: "",
firstName: "",
lastName: "",
notes: "",
status: "active",
}}
onSubmit={onSubmit}
isSubmitting={isCreating}
submitLabel="Add Participant"
/>
</CardContent>
</Card>
</PageContent>
</>
);
}

View File

@@ -1,345 +1,36 @@
'use client';
"use client";
import { useCallback, useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { useToast } from "~/hooks/use-toast";
import { Plus, Trash2 } from "lucide-react";
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";
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 { use } from "react";
import { StudyParticipants } from "~/components/studies/study-participants";
interface Participant {
id: number;
name: string;
studyId: number;
createdAt: string;
}
export default function ParticipantsPage({ params }: { params: Promise<{ id: string }> }) {
const router = useRouter();
const resolvedParams = use(params);
const studyId = Number(resolvedParams.id);
export default function ParticipantsList() {
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);
}
};
const { data: study, isLoading } = api.study.getById.useQuery({ id: studyId });
if (isLoading) {
return (
<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>
return <div>Loading...</div>;
}
<Card>
<CardHeader>
<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>
);
if (!study) {
return <div>Study not found</div>;
}
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 && (
<>
.{" "}
<Dialog>
<DialogTrigger asChild>
<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>
</div>
<DialogFooter>
<Button
onClick={handleAddParticipant}
disabled={isAddingParticipant || !newParticipantName.trim()}
>
{isAddingParticipant ? "Adding..." : "Add Participant"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)}
</div>
)}
</CardContent>
</Card>
</div>
<>
<PageHeader
title="Participants"
description={`Manage participants for ${study.title}`}
/>
<PageContent>
<StudyParticipants studyId={studyId} role={study.role} />
</PageContent>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -1,137 +1,50 @@
'use client';
"use client";
import { useState } from "react";
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 { 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() {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
export default function NewStudyPage() {
const router = useRouter();
const { toast } = useToast();
const { refreshStudies } = useActiveStudy();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
const { mutate: createStudy, isPending: isCreating } = api.study.create.useMutation({
onSuccess: (data) => {
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) {
throw new Error("Failed to create study");
}
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);
}
};
function onSubmit(data: StudyFormValues) {
createStudy(data);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Create New Study</h2>
<p className="text-muted-foreground">
Set up a new research study and configure its settings
</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>
<CardHeader>
<CardTitle>Study Details</CardTitle>
<CardDescription>
Configure the basic settings for your new study
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Study Title</Label>
<Input
id="title"
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>
</Card>
</div>
</div>
</div>
<>
<PageHeader
title="New Study"
description="Create a new study"
/>
<PageContent>
<Card>
<CardHeader>
<CardTitle>Study Details</CardTitle>
<CardDescription>
Enter the information for your new study.
</CardDescription>
</CardHeader>
<CardContent>
<StudyForm
defaultValues={{ title: "", description: "" }}
onSubmit={onSubmit}
isSubmitting={isCreating}
submitLabel="Create Study"
/>
</CardContent>
</Card>
</PageContent>
</>
);
}

View File

@@ -1,237 +1,68 @@
'use client';
"use client";
import { useCallback, useEffect, useState } from "react";
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { useToast } from "~/hooks/use-toast";
import { PERMISSIONS, hasPermission } from "~/lib/permissions-client";
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";
import { Plus as PlusIcon } from "lucide-react";
import { StudyListSkeleton } from "~/components/ui/skeleton";
interface Study {
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);
export default function StudiesPage() {
const router = useRouter();
const { toast } = useToast();
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 (
<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-[300px]" />
</div>
<Skeleton className="h-10 w-[140px]" />
</div>
<div className="grid gap-4">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="p-6">
<div className="flex items-start justify-between">
<div className="space-y-2">
<Skeleton className="h-5 w-[200px] mb-1" />
<Skeleton className="h-4 w-[300px] mb-1" />
<Skeleton className="h-4 w-[150px]" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-9 w-[100px]" />
<Skeleton className="h-9 w-9" />
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}
const { data: studies, isLoading } = api.study.getMyStudies.useQuery();
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>
<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>
))
) : (
<>
<PageHeader
title="Studies"
description="Manage your research studies"
>
<Button
onClick={() => router.push("/dashboard/studies/new")}
size="sm"
>
<PlusIcon className="h-4 w-4 mr-2" />
New Study
</Button>
</PageHeader>
<PageContent>
{isLoading ? (
<StudyListSkeleton />
) : !studies || studies.length === 0 ? (
<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>
<CardHeader>
<CardTitle>No Studies</CardTitle>
<CardDescription>
You haven't created any studies yet. Click the button above to create your first study.
</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>
</CardContent>
</Card>
))}
</div>
)}
</div>
</div>
</PageContent>
</>
);
}

View File

@@ -2,101 +2,108 @@
@tailwind components;
@tailwind utilities;
body {
font-family: Arial, Helvetica, sans-serif;
}
@layer base {
:root {
--background: 210 50% 98%;
--foreground: 215 25% 27%;
--card: 210 50% 98%;
--card-foreground: 215 25% 27%;
--popover: 210 50% 98%;
--popover-foreground: 215 25% 27%;
--primary: 215 60% 40%;
--primary-foreground: 210 50% 98%;
--secondary: 210 55% 92%;
--secondary-foreground: 215 25% 27%;
--muted: 210 55% 92%;
--muted-foreground: 215 20% 50%;
--accent: 210 55% 92%;
--accent-foreground: 215 25% 27%;
--destructive: 0 84% 60%;
--destructive-foreground: 210 50% 98%;
--border: 214 32% 91%;
--input: 214 32% 91%;
--ring: 215 60% 40%;
--background: 200 30% 97%;
--foreground: 200 50% 20%;
--card: 0 0% 100%;
--card-foreground: 200 50% 20%;
--popover: 0 0% 100%;
--popover-foreground: 200 50% 20%;
--primary: 200 85% 45%;
--primary-foreground: 0 0% 100%;
--secondary: 200 30% 96%;
--secondary-foreground: 200 50% 20%;
--muted: 200 30% 96%;
--muted-foreground: 200 30% 40%;
--accent: 200 85% 45%;
--accent-foreground: 0 0% 100%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 100%;
--border: 200 30% 90%;
--input: 200 30% 90%;
--ring: 200 85% 45%;
--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 */
--gradient-start: 210 50% 96%;
--gradient-end: 210 50% 98%;
/* Updated sidebar variables for a clean, light look */
--sidebar-background: 210 50% 98%;
--sidebar-foreground: 215 25% 27%;
--sidebar-muted: 215 20% 50%;
--sidebar-hover: 210 50% 94%;
--sidebar-border: 214 32% 91%;
--sidebar-separator: 214 32% 91%;
--sidebar-active: 210 50% 92%;
--card-level-1: 210 50% 95%;
--card-level-2: 210 50% 90%;
--card-level-3: 210 50% 85%;
/* Sidebar specific colors */
--sidebar-background: 0 0% 100%;
--sidebar-foreground: 200 50% 20%;
--sidebar-muted: 200 30% 40%;
--sidebar-muted-foreground: 200 30% 40%;
--sidebar-accent: 200 30% 96%;
--sidebar-accent-foreground: 200 50% 20%;
--sidebar-border: 200 30% 90%;
--sidebar-ring: 200 85% 45%;
--sidebar-hover: 200 40% 96%;
--sidebar-active: var(--primary);
--sidebar-active-foreground: var(--primary-foreground);
}
.dark {
--background: 220 20% 15%;
--foreground: 220 20% 90%;
--card: 220 20% 15%;
--card-foreground: 220 20% 90%;
--popover: 220 20% 15%;
--popover-foreground: 220 20% 90%;
--primary: 220 60% 50%;
--primary-foreground: 220 20% 90%;
--secondary: 220 30% 20%;
--secondary-foreground: 220 20% 90%;
--muted: 220 30% 20%;
--muted-foreground: 220 20% 70%;
--accent: 220 30% 20%;
--accent-foreground: 220 20% 90%;
--destructive: 0 62% 40%;
--destructive-foreground: 220 20% 90%;
--border: 220 30% 20%;
--input: 220 30% 20%;
--ring: 220 60% 50%;
@media (prefers-color-scheme: dark) {
:root {
--background: 200 30% 8%;
--foreground: 200 20% 96%;
--card: 200 25% 15%;
--card-foreground: 200 15% 85%;
--popover: 200 50% 8%;
--popover-foreground: 200 20% 96%;
--primary: 200 70% 40%;
--primary-foreground: 0 0% 100%;
--secondary: 200 30% 20%;
--secondary-foreground: 200 20% 96%;
--muted: 200 30% 20%;
--muted-foreground: 200 30% 65%;
--accent: 200 70% 40%;
--accent-foreground: 0 0% 100%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 100%;
--border: 200 30% 20%;
--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 */
--gradient-start: 220 20% 12%;
--gradient-end: 220 20% 15%;
/* Updated sidebar variables for dark mode */
--sidebar-background-top: 220 20% 15%;
--sidebar-background-bottom: 220 20% 15%;
--sidebar-foreground: 220 20% 90%;
--sidebar-muted: 220 20% 60%;
--sidebar-hover: 220 20% 20%;
--sidebar-border: 220 20% 25%;
--sidebar-separator: 220 20% 22%;
--card-level-1: 220 20% 12%;
--card-level-2: 220 20% 10%;
--card-level-3: 220 20% 8%;
/* Sidebar specific colors - dark mode */
--sidebar-background: 200 30% 12%;
--sidebar-foreground: 200 20% 96%;
--sidebar-muted: 200 30% 65%;
--sidebar-muted-foreground: 200 30% 65%;
--sidebar-accent: 200 30% 20%;
--sidebar-accent-foreground: 200 20% 96%;
--sidebar-border: 200 30% 20%;
--sidebar-ring: 200 70% 40%;
--sidebar-hover: 200 25% 20%;
--sidebar-active: var(--primary);
--sidebar-active-foreground: var(--primary-foreground);
}
}
/* Add these utility classes */
.card-level-1 {
background-color: hsl(var(--card-level-1));
.auth-gradient {
@apply relative bg-background;
}
.card-level-2 {
background-color: hsl(var(--card-level-2));
.auth-gradient::before {
@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 {
background-color: hsl(var(--card-level-3));
.auth-gradient::after {
@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 {
@apply bg-background text-foreground;
font-feature-settings:
"rlig" 1,
"calt" 1;
}
}
/* Sidebar specific styles */
.sidebar-separator {
@apply my-3 border-t border-[hsl(var(--sidebar-separator))] opacity-60;
/* Sidebar and Header shared styles */
[data-sidebar="sidebar"],
[data-nav="header"] {
@apply relative isolate rounded-lg border-[hsl(var(--sidebar-border))] bg-transparent;
}
.sidebar-dropdown-content {
@apply bg-[hsl(var(--sidebar-background))] border-[hsl(var(--sidebar-border))];
[data-sidebar="sidebar"]::before,
[data-nav="header"]::before {
@apply absolute inset-0 -z-10 rounded-lg backdrop-blur-2xl content-[''];
}
.sidebar-button {
@apply hover:bg-[hsl(var(--sidebar-hover))] text-[hsl(var(--sidebar-foreground))];
/* Light mode adjustments */
:root [data-sidebar="sidebar"]::before,
:root [data-nav="header"]::before {
@apply bg-white/50;
}
.sidebar-button[data-active="true"] {
@apply bg-[hsl(var(--sidebar-active))] font-medium;
/* Dark mode adjustments */
@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);
}
}

View File

@@ -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,
}
);
}

View File

@@ -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&apos;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>
);
}

View File

@@ -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
View 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>
);
}

View File

@@ -1,23 +1,40 @@
import { ClerkProvider } from "@clerk/nextjs";
import { Inter } from 'next/font/google';
import { Toaster } from "~/components/ui/toaster";
import "~/app/globals.css";
import "./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({
children,
}: {
children: React.ReactNode;
children: React.ReactNode
}) {
return (
<ClerkProvider>
<html lang="en">
<body className={inter.className}>
{children}
<Toaster />
</body>
</html>
</ClerkProvider>
<html lang="en" className="h-full">
<body className={cn(
"min-h-screen bg-background font-sans antialiased",
GeistSans.className
)}>
<TRPCReactProvider {...{ headers: headers() }}>
<Providers>
<DatabaseCheck>
<div className="relative h-full">
{children}
</div>
</DatabaseCheck>
</Providers>
</TRPCReactProvider>
</body>
</html>
);
}

537
src/app/onboarding/page.tsx Normal file
View 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>
);
}

View File

@@ -1,100 +1,70 @@
'use client';
import { SignedIn, SignedOut, SignInButton, SignUpButton, UserButton } from "@clerk/nextjs";
import { getServerAuthSession } from "~/server/auth";
import { Button } from "~/components/ui/button";
import Image from "next/image";
import Link from "next/link";
import { BotIcon } from "lucide-react";
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 (
<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 */}
<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">
<Logo />
<div className="flex items-center space-x-2">
<Logo />
</div>
<div className="flex items-center space-x-4">
<SignedOut>
<SignInButton mode="modal">
<Button variant="ghost">Sign In</Button>
</SignInButton>
<SignUpButton mode="modal">
<Button>Sign Up</Button>
</SignUpButton>
</SignedOut>
<SignedIn>
<UserButton afterSignOutUrl="/" />
</SignedIn>
{!session && (
<>
<Button variant="ghost" asChild>
<Link href="/auth/signin">Sign In</Link>
</Button>
<Button asChild>
<Link href="/auth/signup">Sign Up</Link>
</Button>
</>
)}
{session && (
<Button asChild>
<Link href="/dashboard">Dashboard</Link>
</Button>
)}
</div>
</div>
</nav>
{/* Hero Section */}
<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">
<Image
src="/hristudio_laptop.png"
alt="HRIStudio Interface"
width={800}
height={600}
priority
/>
</div>
</section>
{/* Content Sections */}
<div className="relative">
<HeroSection isLoggedIn={isLoggedIn} />
{/* Features Section */}
<section className="container mx-auto px-4 py-24">
<div className="grid md:grid-cols-3 gap-8">
<div className="space-y-4">
<h3 className="text-xl font-semibold">Visual Experiment Design</h3>
<p className="text-muted-foreground">
Create and configure experiments using an intuitive drag-and-drop interface without extensive coding.
</p>
</div>
<div className="space-y-4">
<h3 className="text-xl font-semibold">Real-time Control</h3>
<p className="text-muted-foreground">
Execute experiments with synchronized views for wizards and observers, enabling seamless collaboration.
</p>
</div>
<div className="space-y-4">
<h3 className="text-xl font-semibold">Comprehensive Analysis</h3>
<p className="text-muted-foreground">
Record, playback, and analyze experimental data with built-in annotation and export tools.
</p>
</div>
{/* Dotted pattern for content sections */}
<div className="relative">
<div
className="absolute inset-0 pointer-events-none opacity-30"
style={{
backgroundImage: `
radial-gradient(circle at 1px 1px, hsl(var(--muted-foreground)) 1px, transparent 0),
linear-gradient(to bottom, transparent, hsl(var(--background)))
`,
backgroundSize: '32px 32px, 100% 100%',
maskImage: 'linear-gradient(to bottom, transparent, black 10%, black 90%, transparent)',
}}
/>
<FeaturesSection />
<CTASection isLoggedIn={isLoggedIn} />
</div>
</section>
</div>
</div>
);
}

View File

@@ -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>
);
}

View File

@@ -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
View 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)
}

View 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>
);
}

View 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>
);
}

View 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>
)
}

View 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>
);
}

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