feat: rewrite project

This commit is contained in:
2025-02-01 01:23:55 -05:00
parent a4c8fdc0c3
commit e6962aef79
181 changed files with 18053 additions and 12327 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

View File

@@ -1,8 +1,2 @@
{
"conventionalCommits.scopes": [
"homepage",
"repo",
"auth",
"perms"
]
}

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 14, 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 14 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

1440
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:

View File

@@ -1,14 +1,12 @@
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";
export default defineConfig({
out: './drizzle',
schema: './src/db/schema.ts',
dialect: 'postgresql',
export default {
schema: "./src/server/db/schema.ts",
dialect: "postgresql",
dbCredentials: {
url: process.env.POSTGRES_URL!,
url: env.DATABASE_URL,
},
});
tablesFilter: ["hristudio_*"],
} satisfies Config;

27
next.config.js Normal file
View File

@@ -0,0 +1,27 @@
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
* for Docker builds.
*/
import "./src/env.js";
/** @type {import("next").NextConfig} */
const config = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "**",
},
{
protocol: "http",
hostname: "localhost",
port: "3000",
pathname: "/api/images/**",
},
],
dangerouslyAllowSVG: true,
contentDispositionType: 'attachment',
},
};
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

8332
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,60 +2,90 @@
"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: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",
"@hookform/resolvers": "^3.10.0",
"@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-select": "^2.1.5",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slider": "^1.2.2",
"@radix-ui/react-slot": "^1.1.1",
"@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",
"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.33.0",
"framer-motion": "^12.0.6",
"geist": "^1.3.1",
"lucide-react": "^0.474.0",
"next": "^15.0.1",
"next-auth": "^4.24.11",
"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",
"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.24.0",
"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 +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,118 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } 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 "~/components/ui/use-toast";
const formSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
type FormValues = z.infer<typeof formSchema>;
export function SignInForm() {
const router = useRouter();
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
password: "",
},
});
async function onSubmit(data: FormValues) {
setIsLoading(true);
try {
const result = await signIn("credentials", {
redirect: false,
email: data.email,
password: data.password,
});
if (result?.error) {
toast({
variant: "destructive",
title: "Error",
description: "Invalid email or password",
});
return;
}
router.refresh();
router.push("/");
} catch (error) {
toast({
variant: "destructive",
title: "Error",
description: "Something went wrong. Please try again.",
});
} 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,149 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } 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 "~/components/ui/use-toast";
import { api } from "~/trpc/react";
const formSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(1).max(256).optional(),
});
type FormValues = z.infer<typeof formSchema>;
export function SignUpForm() {
const router = useRouter();
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const createUser = api.user.create.useMutation();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
password: "",
name: "",
},
});
async function onSubmit(data: FormValues) {
setIsLoading(true);
try {
await createUser.mutateAsync(data);
const result = await signIn("credentials", {
redirect: false,
email: data.email,
password: data.password,
});
if (result?.error) {
toast({
variant: "destructive",
title: "Error",
description: "Something went wrong. Please try again.",
});
return;
}
router.refresh();
router.push("/");
} catch (error) {
if (error instanceof Error) {
toast({
variant: "destructive",
title: "Error",
description: error.message,
});
} else {
toast({
variant: "destructive",
title: "Error",
description: "Something went wrong. Please try again.",
});
}
} 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>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="John Doe"
{...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,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,62 @@
import { hash } from "bcryptjs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "~/server/db";
import { users } from "~/server/db/schema";
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({
firstName,
lastName,
email,
password: hashedPassword,
});
return NextResponse.redirect(new URL("/login", req.url));
} 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

@@ -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,68 @@
import { type Metadata } from "next";
import Link from "next/link";
import { SignInForm } from "~/app/_components/auth/sign-in-form";
import { buttonVariants } from "~/components/ui/button";
import { cn } from "~/lib/utils";
export const metadata: Metadata = {
title: "Sign In",
description: "Sign in to your account",
};
export default function SignInPage() {
return (
<div className="container relative h-screen flex-col items-center justify-center grid lg:max-w-none lg:grid-cols-2 lg:px-0">
<div className="relative hidden h-full flex-col bg-muted p-10 text-white lg:flex dark:border-r">
<div className="absolute inset-0 bg-zinc-900" />
<div className="relative z-20 flex items-center text-lg font-medium">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-2 h-6 w-6"
>
<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3" />
</svg>
HRI Studio
</div>
<div className="relative z-20 mt-auto">
<blockquote className="space-y-2">
<p className="text-lg">
&ldquo;HRI Studio has revolutionized how we conduct human-robot interaction studies.&rdquo;
</p>
<footer className="text-sm">Sofia Dewar</footer>
</blockquote>
</div>
</div>
<div className="lg:p-8">
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<div className="flex flex-col space-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
Welcome back
</h1>
<p className="text-sm text-muted-foreground">
Enter your email to sign in to your account
</p>
</div>
<SignInForm />
<p className="px-8 text-center text-sm text-muted-foreground">
<Link
href="/auth/signup"
className={cn(
buttonVariants({ variant: "ghost" }),
"hover:bg-transparent hover:underline",
"px-0"
)}
>
Don&apos;t have an account? Sign Up
</Link>
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { type Metadata } from "next";
import Link from "next/link";
import { SignUpForm } from "~/app/_components/auth/sign-up-form";
import { buttonVariants } from "~/components/ui/button";
import { cn } from "~/lib/utils";
export const metadata: Metadata = {
title: "Sign Up",
description: "Create a new account",
};
export default function SignUpPage() {
return (
<div className="container relative h-screen flex-col items-center justify-center grid lg:max-w-none lg:grid-cols-2 lg:px-0">
<div className="relative hidden h-full flex-col bg-muted p-10 text-white lg:flex dark:border-r">
<div className="absolute inset-0 bg-zinc-900" />
<div className="relative z-20 flex items-center text-lg font-medium">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-2 h-6 w-6"
>
<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3" />
</svg>
HRI Studio
</div>
<div className="relative z-20 mt-auto">
<blockquote className="space-y-2">
<p className="text-lg">
&ldquo;HRI Studio has revolutionized how we conduct human-robot interaction studies.&rdquo;
</p>
<footer className="text-sm">Sofia Dewar</footer>
</blockquote>
</div>
</div>
<div className="lg:p-8">
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<div className="flex flex-col space-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
Create an account
</h1>
<p className="text-sm text-muted-foreground">
Enter your email below to create your account
</p>
</div>
<SignUpForm />
<p className="px-8 text-center text-sm text-muted-foreground">
<Link
href="/auth/signin"
className={cn(
buttonVariants({ variant: "ghost" }),
"hover:bg-transparent hover:underline",
"px-0"
)}
>
Already have an account? Sign In
</Link>
</p>
</div>
</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

@@ -1,26 +1,54 @@
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 { 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 { PageTransition } from "~/components/layout/page-transition"
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const { data: session, status } = useSession()
const router = useRouter()
useEffect(() => {
if (status === "unauthenticated") {
router.replace("/login")
}
}, [status, router])
// Show nothing while loading
if (status === "loading") {
return null
}
// Show nothing if not authenticated (will redirect)
if (!session) {
return null
}
return (
<ActiveStudyProvider>
<SidebarProvider>
<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}
<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>
</StudyProvider>
</ActiveStudyProvider>
);
}
</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

@@ -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 +0,0 @@
'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, 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";
interface StudyStats {
participantCount: number;
formCount: number;
trialCount: number;
}
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 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) {
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>
);
}
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>
</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>
);
}

View File

@@ -1,112 +0,0 @@
'use client';
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
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 { 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";
export default function NewParticipant() {
const [name, setName] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const { id } = useParams();
const router = useRouter();
const { toast } = useToast();
const { activeStudy } = useActiveStudy();
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");
}
toast({
title: "Success",
description: "Participant created successfully",
});
router.push(`/dashboard/studies/${id}/participants`);
} catch (error) {
console.error("Error creating participant:", error);
toast({
title: "Error",
description: "Failed to create participant",
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
};
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>
);
}

View File

@@ -1,345 +0,0 @@
'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";
interface Participant {
id: number;
name: string;
studyId: number;
createdAt: string;
}
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);
}
};
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>
<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>
);
}
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>
);
}

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 +0,0 @@
'use client';
import { 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 { 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);
const router = useRouter();
const { toast } = useToast();
const { refreshStudies } = useActiveStudy();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
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);
}
};
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>
);
}

View File

@@ -1,237 +0,0 @@
'use client';
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { PlusIcon, Trash2Icon, Settings2, ArrowRight } from "lucide-react";
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";
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);
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>
);
}
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>
))
) : (
<Card>
<CardContent className="py-8">
<p className="text-center text-muted-foreground">
No studies found{hasPermission(studies[0]?.permissions || [], PERMISSIONS.CREATE_STUDY) ? ". Create your first study above" : ""}.
</p>
</CardContent>
</Card>
)}
</div>
</div>
);
}

View File

@@ -1,127 +1,21 @@
@tailwind base;
@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%;
--radius: 0.5rem;
/* 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%;
.auth-gradient {
@apply relative bg-background;
}
.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%;
/* 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%;
.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-[''];
}
/* Add these utility classes */
.card-level-1 {
background-color: hsl(var(--card-level-1));
.auth-gradient::after {
@apply absolute inset-0 -z-10 bg-[url('/grid.svg')] bg-center opacity-10 content-[''];
}
.card-level-2 {
background-color: hsl(var(--card-level-2));
.auth-card {
@apply relative overflow-hidden border border-border/50 bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/50;
}
.card-level-3 {
background-color: hsl(var(--card-level-3));
.auth-input {
@apply h-10 bg-background/50 backdrop-blur supports-[backdrop-filter]:bg-background/30;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
/* Sidebar specific styles */
.sidebar-separator {
@apply my-3 border-t border-[hsl(var(--sidebar-separator))] opacity-60;
}
.sidebar-dropdown-content {
@apply bg-[hsl(var(--sidebar-background))] border-[hsl(var(--sidebar-border))];
}
.sidebar-button {
@apply hover:bg-[hsl(var(--sidebar-hover))] text-[hsl(var(--sidebar-foreground))];
}
.sidebar-button[data-active="true"] {
@apply bg-[hsl(var(--sidebar-active))] font-medium;
}
}

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

View File

@@ -1,23 +1,47 @@
import { ClerkProvider } from "@clerk/nextjs";
import { Inter } from 'next/font/google';
import { Toaster } from "~/components/ui/toaster";
import "~/app/globals.css";
import "~/styles/globals.css";
const inter = Inter({ subsets: ['latin'] });
import { Inter } from "next/font/google";
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";
const inter = Inter({
subsets: ["latin"],
variable: "--font-sans",
});
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",
inter.variable
)}>
<TRPCReactProvider {...{ headers: headers() }}>
<Providers>
<DatabaseCheck>
<div className="relative h-full">
{children}
</div>
</DatabaseCheck>
</Providers>
</TRPCReactProvider>
</body>
</html>
);
}
}

View File

@@ -0,0 +1,103 @@
'use client';
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import Link from "next/link";
import { Loader2 } from "lucide-react";
export function LoginForm({ error }: { error: boolean }) {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setIsLoading(true);
const formData = new FormData(e.currentTarget);
const response = await signIn("credentials", {
email: formData.get("email"),
password: formData.get("password"),
redirect: false,
});
setIsLoading(false);
if (!response?.error) {
router.push("/dashboard");
router.refresh();
} else {
router.push("/login?error=CredentialsSignin");
router.refresh();
}
}
return (
<form onSubmit={onSubmit} className="animate-in fade-in-50 slide-in-from-bottom-4">
{error && (
<div className="mb-4 rounded border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<p>Invalid email or password</p>
</div>
)}
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
placeholder="name@example.com"
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
className="auth-input"
disabled={isLoading}
required
/>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Link
href="/forgot-password"
className="text-sm text-muted-foreground transition-colors hover:text-primary"
>
Forgot password?
</Link>
</div>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
className="auth-input"
disabled={isLoading}
required
/>
</div>
<Button className="w-full" type="submit" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing in...
</>
) : (
"Sign In"
)}
</Button>
</div>
<div className="mt-4 text-center text-sm text-muted-foreground">
Don&apos;t have an account?{" "}
<Link
href="/register"
className="font-medium text-primary underline-offset-4 transition-colors hover:underline"
>
Sign up
</Link>
</div>
</form>
);
}

80
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,80 @@
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 { LoginForm } from "./login-form";
import { Logo } from "~/components/logo";
export const metadata: Metadata = {
title: "Login | HRIStudio",
description: "Login to your account",
};
export default async function LoginPage({
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>
<LoginForm 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">
By signing in, you agree to our{" "}
<Link href="/terms" className="underline underline-offset-4 hover:text-primary">
Terms of Service
</Link>{" "}
and{" "}
<Link href="/privacy" className="underline underline-offset-4 hover:text-primary">
Privacy Policy
</Link>
.
</p>
</div>
</div>
);
}

View File

@@ -1,31 +1,36 @@
'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";
export default function Home() {
export default async function Home() {
const session = await getServerAuthSession();
return (
<div className="min-h-screen bg-background">
{/* Navigation Bar */}
<nav className="border-b bg-card/50 backdrop-blur supports-[backdrop-filter]:bg-card/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="/login">Sign In</Link>
</Button>
<Button asChild>
<Link href="/register">Sign Up</Link>
</Button>
</>
)}
{session && (
<Button asChild>
<Link href="/dashboard">Dashboard</Link>
</Button>
)}
</div>
</div>
</nav>
@@ -40,20 +45,15 @@ export default function Home() {
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>
{!session ? (
<Button size="lg" className="w-full sm:w-auto" asChild>
<Link href="/dashboard">
Go to Dashboard
</Link>
<Link href="/register">Get Started</Link>
</Button>
</SignedIn>
) : (
<Button size="lg" className="w-full sm:w-auto" asChild>
<Link href="/dashboard">Go to Dashboard</Link>
</Button>
)}
<Button size="lg" variant="outline" className="w-full sm:w-auto" asChild>
<Link href="https://github.com/soconnor0919/hristudio" target="_blank">
View on GitHub
@@ -61,14 +61,11 @@ export default function Home() {
</Button>
</div>
</div>
<div className="relative">
<Image
src="/hristudio_laptop.png"
alt="HRIStudio Interface"
width={800}
height={600}
priority
/>
<div className="relative aspect-video">
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-secondary/20 rounded-lg" />
<div className="absolute inset-0 flex items-center justify-center">
<BotIcon className="h-32 w-32 text-primary/40" />
</div>
</div>
</section>
@@ -97,4 +94,4 @@ export default function Home() {
</section>
</div>
);
}
}

142
src/app/register/page.tsx Normal file
View File

@@ -0,0 +1,142 @@
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getServerAuthSession } from "~/server/auth";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardTitle
} from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Logo } from "~/components/logo";
export const metadata: Metadata = {
title: "Register | HRIStudio",
description: "Create a new account",
};
export default async function RegisterPage() {
const session = await getServerAuthSession();
if (session) {
redirect("/dashboard");
}
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">
Enter your details to get started
</CardDescription>
</div>
<form action="/api/auth/register" method="POST" className="animate-in fade-in-50 slide-in-from-bottom-4">
<div className="grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="firstName">First Name</Label>
<Input
id="firstName"
name="firstName"
placeholder="John"
autoComplete="given-name"
className="auth-input"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="lastName">Last Name</Label>
<Input
id="lastName"
name="lastName"
placeholder="Doe"
autoComplete="family-name"
className="auth-input"
required
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
placeholder="name@example.com"
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
className="auth-input"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
className="auth-input"
required
/>
<p className="text-sm text-muted-foreground">
Must be at least 8 characters long
</p>
</div>
<Button className="w-full" type="submit">
Create account
</Button>
</div>
<div className="mt-4 text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link
href="/login"
className="font-medium text-primary underline-offset-4 transition-colors hover:underline"
>
Sign in
</Link>
</div>
</form>
</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">
By creating an account, you agree to our{" "}
<Link href="/terms" className="underline underline-offset-4 hover:text-primary">
Terms of Service
</Link>{" "}
and{" "}
<Link href="/privacy" className="underline underline-offset-4 hover:text-primary">
Privacy Policy
</Link>
.
</p>
</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>
);
}

View File

@@ -0,0 +1,15 @@
export default async function StudyPage({ params }: { params: { id: string } }) {
const study = await db.query.studies.findFirst({
where: (studies, { eq }) => eq(studies.id, params.id),
with: { experiments: true }
})
return (
<div className="max-w-6xl mx-auto p-6">
<StudyHeader study={study} />
<Suspense fallback={<ExperimentListSkeleton />}>
<ExperimentList studyId={params.id} />
</Suspense>
</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,149 @@
"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 } = useSidebar()
const router = useRouter()
const { studies, activeStudy, setActiveStudy, isLoading } = useStudy()
const handleCreateStudy = () => {
router.push("/dashboard/studies/new")
}
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>
<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>
<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="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">
<Notebook className="size-4" />
</div>
<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="w-[--radix-dropdown-menu-trigger-width] 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>
);
}

View File

@@ -1,116 +0,0 @@
'use client';
import { ChevronRight } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useActiveStudy } from "~/context/active-study";
interface BreadcrumbItem {
label: string;
href?: string;
}
export function Breadcrumb() {
const pathname = usePathname();
const { activeStudy } = useActiveStudy();
const getBreadcrumbs = (): BreadcrumbItem[] => {
const items: BreadcrumbItem[] = [{ label: 'Dashboard', href: '/dashboard' }];
const path = pathname.split('/').filter(Boolean);
// Handle root dashboard
if (path.length === 1 && path[0] === 'dashboard') {
return items;
}
// Handle studies list page
if (path[1] === 'studies') {
items.push({ label: 'Studies', href: '/dashboard/studies' });
if (path[2] === 'new') {
items.push({ label: 'New Study' });
return items;
}
if (!activeStudy) {
return items;
}
// Handle active study pages
items.push({
label: activeStudy.title,
href: `/dashboard/studies/${activeStudy.id}`
});
// Add section based on URL
if (path.length > 3) {
const section = path[3];
const sectionLabel = section.charAt(0).toUpperCase() + section.slice(1);
if (section === 'new') {
items.push({
label: `New ${path[2].slice(0, -1)}`,
href: `/dashboard/studies/${activeStudy.id}/${path[2]}/new`
});
} else {
items.push({
label: sectionLabel,
href: `/dashboard/studies/${activeStudy.id}/${section}`
});
}
}
return items;
}
// Handle participants page
if (path[1] === 'participants') {
items.push({ label: 'Participants', href: '/dashboard/participants' });
return items;
}
// Handle settings page
if (path[1] === 'settings') {
items.push({ label: 'Settings', href: '/dashboard/settings' });
return items;
}
// Handle profile page
if (path[1] === 'profile') {
items.push({ label: 'Profile', href: '/dashboard/profile' });
return items;
}
return items;
};
const breadcrumbs = getBreadcrumbs();
// Always show breadcrumbs on dashboard pages
if (breadcrumbs.length <= 1 && !pathname.startsWith('/dashboard')) return null;
return (
<div className="flex items-center space-x-2 text-sm text-muted-foreground mb-6">
{breadcrumbs.map((item, index) => {
const isLast = index === breadcrumbs.length - 1;
return (
<div key={item.label} className="flex items-center">
{index > 0 && <ChevronRight className="h-4 w-4 mx-2" />}
{item.href && !isLast ? (
<Link
href={item.href}
className="hover:text-foreground transition-colors"
>
{item.label}
</Link>
) : (
<span className={isLast ? "text-foreground font-medium" : ""}>
{item.label}
</span>
)}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,60 @@
'use server'
import { db } from "~/server/db"
import { users } from "~/server/db/schema"
import { Card } from "~/components/ui/card"
import { DatabaseIcon } from "lucide-react"
import { Logo } from "~/components/logo"
async function checkDatabase() {
try {
// Try a simple query to check database connection
await db.select().from(users).limit(1)
return true
} catch (error) {
console.error("Database connection error:", error)
return false
}
}
export default async function DatabaseCheck({
children,
}: {
children: React.ReactNode
}): Promise<JSX.Element> {
const isConnected = await checkDatabase()
if (isConnected) {
return <>{children}</>
}
return (
<div className="grid min-h-screen place-items-center bg-background font-sans antialiased">
<div className="flex flex-col items-center gap-8">
<Logo
className="text-2xl"
iconClassName="h-8 w-8"
/>
<Card className="w-[448px] border-destructive p-6">
<div className="flex flex-col items-center gap-4 text-center">
<DatabaseIcon className="h-12 w-12 text-destructive" />
<div className="space-y-2">
<h1 className="text-2xl font-bold tracking-tight">
Database Connection Error
</h1>
<p className="text-muted-foreground">
Could not connect to the database. Please make sure the database is running and try again.
</p>
</div>
<div className="w-full rounded-lg bg-muted p-4">
<p className="mb-2 font-mono text-sm">Start the database with:</p>
<code className="block rounded bg-background p-2 text-sm">
docker-compose up -d
</code>
</div>
</div>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,20 @@
export function ExperimentBuilder() {
return (
<DndContext>
<SortableContext items={steps}>
<div className="space-y-4">
{steps.map((step) => (
<ExperimentStep
key={step.id}
step={step}
actions={actionsMap[step.id]}
/>
))}
</div>
</SortableContext>
<DragOverlay>
{activeStep && <StepPreview step={activeStep} />}
</DragOverlay>
</DndContext>
)
}

View File

@@ -1,157 +0,0 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { ROLES } from "~/lib/roles";
import { UserPlusIcon } from "lucide-react";
interface Role {
id: number;
name: string;
}
interface InviteUserDialogProps {
studyId: number;
onInviteSent?: () => void;
}
export function InviteUserDialog({ studyId, onInviteSent }: InviteUserDialogProps) {
const [open, setOpen] = useState(false);
const [email, setEmail] = useState("");
const [roleId, setRoleId] = useState("");
const [loading, setLoading] = useState(false);
const [roles, setRoles] = useState<Role[]>([]);
const router = useRouter();
useEffect(() => {
// Fetch available roles when dialog opens
if (open) {
fetchRoles();
}
}, [open]);
const fetchRoles = async () => {
try {
const response = await fetch("/api/roles");
if (response.ok) {
const data = await response.json();
setRoles(data.filter((role: Role) =>
role.name !== ROLES.ADMIN && role.name !== ROLES.PRINCIPAL_INVESTIGATOR
));
}
} catch (error) {
console.error("Error fetching roles:", error);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const response = await fetch("/api/invitations", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email,
studyId,
roleId: parseInt(roleId),
}),
});
if (!response.ok) {
throw new Error("Failed to send invitation");
}
setOpen(false);
setEmail("");
setRoleId("");
onInviteSent?.();
router.refresh();
} catch (error) {
console.error("Error sending invitation:", error);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<UserPlusIcon className="w-4 h-4 mr-2" />
Invite User
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Invite User</DialogTitle>
<DialogDescription>
Send an invitation to join your study. The user will receive an email with instructions.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="researcher@university.edu"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select
value={roleId}
onValueChange={setRoleId}
required
>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role.id} value={role.id.toString()}>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
type="submit"
disabled={loading}
>
{loading ? "Sending..." : "Send Invitation"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1 +0,0 @@

View File

@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "~/lib/utils"
interface PageContentProps
extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode
}
export function PageContent({
children,
className,
...props
}: PageContentProps) {
return (
<div
className={cn(
"grid gap-6",
className,
)}
{...props}
>
{children}
</div>
)
}

View File

@@ -0,0 +1,34 @@
import * as React from "react"
import { cn } from "~/lib/utils"
interface PageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
title: string
description?: string
children?: React.ReactNode
}
export function PageHeader({
title,
description,
children,
className,
...props
}: PageHeaderProps) {
return (
<div
className={cn(
"flex items-center justify-between gap-4 pb-6",
className,
)}
{...props}
>
<div className="grid gap-1">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
{children}
</div>
)
}

View File

@@ -0,0 +1,25 @@
"use client"
import { motion, AnimatePresence } from "framer-motion"
import { usePathname } from "next/navigation"
export function PageTransition({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
return (
<AnimatePresence mode="wait">
<motion.div
key={pathname}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: 0.15,
ease: "easeOut",
}}
>
{children}
</motion.div>
</AnimatePresence>
)
}

View File

@@ -1 +0,0 @@

View File

@@ -1,12 +1,12 @@
import { BotIcon } from "lucide-react";
import Link from "next/link";
import { cn } from "~/lib/utils";
import { BotIcon } from "lucide-react"
import Link from "next/link"
import { cn } from "~/lib/utils"
interface LogoProps {
href?: string;
className?: string;
iconClassName?: string;
textClassName?: string;
href?: string
className?: string
iconClassName?: string
textClassName?: string
}
export function Logo({
@@ -32,5 +32,5 @@ export function Logo({
<span className="font-normal">Studio</span>
</span>
</Link>
);
}
)
}

View File

@@ -0,0 +1,92 @@
"use client"
import {
Beaker,
Home,
Settings2,
User
} from "lucide-react"
import * as React from "react"
import { useSession } from "next-auth/react"
import { StudySwitcher } from "~/components/auth/study-switcher"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarRail,
} from "~/components/ui/sidebar"
import { NavMain } from "~/components/navigation/nav-main"
import { NavUser } from "~/components/navigation/nav-user"
const data = {
navMain: [
{
title: "Overview",
url: "/dashboard",
icon: Home,
isActive: true,
},
{
title: "Studies",
url: "/dashboard/studies",
icon: Beaker,
items: [
{
title: "All Studies",
url: "/dashboard/studies",
},
{
title: "Create Study",
url: "/dashboard/studies/new",
},
],
},
{
title: "Settings",
url: "/dashboard/settings",
icon: Settings2,
items: [
{
title: "Account",
url: "/dashboard/account",
icon: User,
},
{
title: "Team",
url: "/dashboard/settings/team",
},
{
title: "Billing",
url: "/dashboard/settings/billing",
},
],
},
],
}
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { data: session } = useSession()
if (!session) return null
return (
<Sidebar
collapsible="icon"
variant="floating"
className="border-none"
{...props}
>
<SidebarHeader>
<StudySwitcher />
</SidebarHeader>
<SidebarContent>
<NavMain items={data.navMain} />
</SidebarContent>
<SidebarFooter>
<NavUser />
</SidebarFooter>
<SidebarRail />
</Sidebar>
)
}

View File

@@ -0,0 +1,64 @@
"use client"
import { usePathname } from "next/navigation"
import Link from "next/link"
export function BreadcrumbNav() {
const pathname = usePathname()
// Get breadcrumb items based on pathname
const getBreadcrumbs = () => {
const paths = pathname.split("/").filter(Boolean)
const items = []
if (paths[0] === "dashboard") {
items.push({
label: "Dashboard",
href: "/dashboard",
current: paths.length === 1,
})
}
if (paths[1] === "studies") {
items.push({
label: "Studies",
href: "/dashboard/studies",
current: paths.length === 2,
})
if (paths[2] === "new") {
items.push({
label: "Create Study",
current: true,
})
}
}
return items
}
const breadcrumbs = getBreadcrumbs()
return (
<nav aria-label="breadcrumb">
<ol className="flex items-center gap-2">
{breadcrumbs.map((item, index) => (
<li key={item.label} className="flex items-center">
{index > 0 && (
<span role="presentation" aria-hidden="true" className="mx-2 text-muted-foreground">
/
</span>
)}
{item.current ? (
<span className="font-medium">{item.label}</span>
) : (
<Link href={item.href} className="text-muted-foreground hover:text-foreground">
{item.label}
</Link>
)}
</li>
))}
</ol>
</nav>
)
}

View File

@@ -0,0 +1,25 @@
"use client"
import { Separator } from "~/components/ui/separator"
import { SidebarTrigger } from "~/components/ui/sidebar"
import { BreadcrumbNav } from "./breadcrumb-nav"
import { Logo } from "~/components/logo"
export function Header() {
return (
<div className="sticky top-0 z-40 w-full">
<header className="mx-2 mt-2 flex h-14 items-center justify-between rounded-lg border bg-gradient-to-r from-[hsl(var(--sidebar-gradient-from))] to-[hsl(var(--sidebar-gradient-to))] px-6 shadow-sm md:ml-0">
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-2 text-[hsl(var(--sidebar-text))] hover:bg-[hsl(var(--sidebar-text))]/10" />
<Separator orientation="vertical" className="h-4 bg-[hsl(var(--sidebar-text))]/10" />
<BreadcrumbNav />
</div>
<Logo
href="/dashboard"
className="text-[hsl(var(--sidebar-text))]"
iconClassName="text-[hsl(var(--sidebar-text-muted))]"
/>
</header>
</div>
)
}

View File

@@ -0,0 +1,73 @@
"use client"
import { ChevronRight, type LucideIcon } from "lucide-react"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "~/components/ui/collapsible"
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "~/components/ui/sidebar"
export function NavMain({
items,
}: {
items: {
title: string
url: string
icon?: LucideIcon
isActive?: boolean
items?: {
title: string
url: string
}[]
}[]
}) {
return (
<SidebarGroup>
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<Collapsible
key={item.title}
asChild
defaultOpen={item.isActive}
className="group/collapsible"
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={item.title}>
{item.icon && <item.icon />}
<span>{item.title}</span>
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton asChild>
<a href={subItem.url}>
<span>{subItem.title}</span>
</a>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
))}
</SidebarMenu>
</SidebarGroup>
)
}

View File

@@ -0,0 +1,89 @@
"use client"
import {
Folder,
Forward,
MoreHorizontal,
Trash2,
type LucideIcon,
} from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "~/components/ui/sidebar"
export function NavProjects({
projects,
}: {
projects: {
name: string
url: string
icon: LucideIcon
}[]
}) {
const { isMobile } = useSidebar()
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Projects</SidebarGroupLabel>
<SidebarMenu>
{projects.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.name}</span>
</a>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction showOnHover>
<MoreHorizontal />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48 rounded-lg"
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
>
<DropdownMenuItem>
<Folder className="text-muted-foreground" />
<span>View Project</span>
</DropdownMenuItem>
<DropdownMenuItem>
<Forward className="text-muted-foreground" />
<span>Share Project</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Trash2 className="text-muted-foreground" />
<span>Delete Project</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton className="text-sidebar-foreground/70">
<MoreHorizontal className="text-sidebar-foreground/70" />
<span>More</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
)
}

View File

@@ -0,0 +1,89 @@
"use client"
import {
Folder,
Forward,
MoreHorizontal,
Trash2,
type LucideIcon,
} from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "~/components/ui/sidebar"
export function NavStudies({
studies,
}: {
studies: {
name: string
url: string
icon: LucideIcon
}[]
}) {
const { isMobile } = useSidebar()
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Active Studies</SidebarGroupLabel>
<SidebarMenu>
{studies.map((study) => (
<SidebarMenuItem key={study.name}>
<SidebarMenuButton asChild>
<a href={study.url}>
<study.icon />
<span>{study.name}</span>
</a>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction showOnHover>
<MoreHorizontal />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48 rounded-lg"
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
>
<DropdownMenuItem>
<Folder className="text-muted-foreground" />
<span>View Study</span>
</DropdownMenuItem>
<DropdownMenuItem>
<Forward className="text-muted-foreground" />
<span>Share Study</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Trash2 className="text-muted-foreground" />
<span>Archive Study</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton className="text-sidebar-foreground/70">
<MoreHorizontal className="text-sidebar-foreground/70" />
<span>View All Studies</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
)
}

View File

@@ -0,0 +1,152 @@
"use client"
import { ChevronsUpDown, LogOut, Settings, User } from "lucide-react"
import { useSession } from "next-auth/react"
import Link from "next/link"
import Image from "next/image"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "~/components/ui/sidebar"
import { Avatar, AvatarFallback } from "~/components/ui/avatar"
export function NavUser() {
const { data: session, status } = useSession()
if (status === "loading") {
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">
<User className="size-4 text-muted-foreground/50" />
</div>
<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 (!session?.user) {
return null
}
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="size-8 rounded-lg">
{session.user.image ? (
<div className="relative size-full overflow-hidden rounded-lg">
<Image
src={session.user.image}
alt={session.user.name ?? "User"}
fill
sizes="32px"
className="object-cover"
onError={(e) => {
console.error("Error loading nav avatar:", session.user.image);
e.currentTarget.style.display = "none";
}}
/>
</div>
) : (
<AvatarFallback className="rounded-lg bg-sidebar-muted">
<User className="size-4" />
</AvatarFallback>
)}
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{session.user.name ?? "User"}
</span>
<span className="truncate text-xs text-sidebar-muted">
{session.user.email}
</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="font-normal">
<div className="flex items-center gap-2 px-1 py-1.5">
<Avatar className="size-8 rounded-lg">
{session.user.image ? (
<div className="relative size-full overflow-hidden rounded-lg">
<Image
src={session.user.image}
alt={session.user.name ?? "User"}
fill
sizes="32px"
className="object-cover"
onError={(e) => {
console.error("Error loading dropdown avatar:", session.user.image);
e.currentTarget.style.display = "none";
}}
/>
</div>
) : (
<AvatarFallback className="rounded-lg">
<User className="size-4" />
</AvatarFallback>
)}
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="font-semibold">
{session.user.name ?? "User"}
</span>
<span className="text-xs text-muted-foreground">
{session.user.email}
</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link href="/dashboard/account">
<Settings className="mr-2 size-4" />
Settings
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/api/auth/signout">
<LogOut className="mr-2 size-4" />
Sign out
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}

View File

@@ -1,22 +0,0 @@
interface PageHeaderProps {
title: string;
description: string;
children?: React.ReactNode;
}
export function PageHeader({ title, description, children }: PageHeaderProps) {
return (
<div className="mb-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
<p className="text-sm text-muted-foreground mt-1">
{description}
</p>
</div>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
"use client"
import { SessionProvider } from "next-auth/react"
export function Providers({ children }: { children: React.ReactNode }) {
return (
<SessionProvider>
{children}
</SessionProvider>
)
}

View File

@@ -0,0 +1,89 @@
"use client"
import * as React from "react"
import { api } from "~/trpc/react"
interface Study {
id: number
title: string
description: string | null
role: string
}
interface StudyContextType {
studies: Study[]
activeStudy: Study | null
setActiveStudy: (study: Study | null) => void
isLoading: boolean
}
const StudyContext = React.createContext<StudyContextType | undefined>(undefined)
const STORAGE_KEY = "activeStudyId"
function getStoredStudyId(): number | null {
if (typeof window === "undefined") return null
const stored = localStorage.getItem(STORAGE_KEY)
return stored ? Number(stored) : null
}
export function StudyProvider({ children }: { children: React.ReactNode }) {
// Initialize with stored study ID
const [activeStudyId, setActiveStudyId] = React.useState<number | null>(() => getStoredStudyId())
const { data: studies = [], isLoading } = api.study.getMyStudies.useQuery()
// Find the active study from the studies array
const activeStudy = React.useMemo(() => {
if (!studies.length || !activeStudyId) return null
return studies.find(s => s.id === activeStudyId) ?? studies[0]
}, [studies, activeStudyId])
// Update active study ID when studies load
React.useEffect(() => {
if (!studies.length) return;
if (!activeStudyId || !studies.find(s => s.id === activeStudyId)) {
// If no active study or it doesn't exist in the list, set the first study
const id = studies[0]?.id;
if (id) {
setActiveStudyId(id);
localStorage.setItem(STORAGE_KEY, String(id));
}
}
}, [studies, activeStudyId]);
const setActiveStudy = React.useCallback((study: Study | null) => {
if (study) {
setActiveStudyId(study.id)
localStorage.setItem(STORAGE_KEY, String(study.id))
} else {
setActiveStudyId(null)
localStorage.removeItem(STORAGE_KEY)
}
}, [])
const value = React.useMemo(
() => ({
studies,
activeStudy: activeStudy ?? null,
setActiveStudy,
isLoading,
}),
[studies, activeStudy, setActiveStudy, isLoading]
)
return (
<StudyContext.Provider value={value}>
{children}
</StudyContext.Provider>
)
}
export function useStudy() {
const context = React.useContext(StudyContext)
if (context === undefined) {
throw new Error("useStudy must be used within a StudyProvider")
}
return context
}

View File

@@ -1,254 +0,0 @@
'use client';
import { UserButton, useUser } from "@clerk/nextjs"
import {
BarChartIcon,
UsersRoundIcon,
LandPlotIcon,
FileTextIcon,
LayoutDashboard,
Menu,
Settings,
ChevronDown,
FolderIcon,
PlusIcon
} from "lucide-react"
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"
import { useState } from "react"
import { Button } from "~/components/ui/button"
import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "~/components/ui/sheet"
import { cn } from "~/lib/utils"
import { Logo } from "~/components/logo"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select"
import { Separator } from "~/components/ui/separator"
import { useActiveStudy } from "~/context/active-study"
const getNavItems = (studyId?: number) => [
{
name: "Dashboard",
href: studyId ? `/dashboard/studies/${studyId}` : "/dashboard",
icon: LayoutDashboard,
exact: true,
requiresStudy: false
},
{
name: "Studies",
href: "/dashboard/studies",
icon: FolderIcon,
exact: true,
requiresStudy: false,
hideWithStudy: true
},
{
name: "Participants",
href: `/dashboard/studies/${studyId}/participants`,
icon: UsersRoundIcon,
requiresStudy: true,
baseRoute: "participants"
},
{
name: "Trials",
href: `/dashboard/studies/${studyId}/trials`,
icon: LandPlotIcon,
requiresStudy: true,
baseRoute: "trials"
},
{
name: "Forms",
href: `/dashboard/studies/${studyId}/forms`,
icon: FileTextIcon,
requiresStudy: true,
baseRoute: "forms"
},
{
name: "Data Analysis",
href: `/dashboard/studies/${studyId}/analysis`,
icon: BarChartIcon,
requiresStudy: true,
baseRoute: "analysis"
},
{
name: "Settings",
href: `/dashboard/studies/${studyId}/settings`,
icon: Settings,
requiresStudy: true,
baseRoute: "settings"
},
];
export function Sidebar() {
const pathname = usePathname()
const router = useRouter()
const [isOpen, setIsOpen] = useState(false)
const { user } = useUser()
const { activeStudy, setActiveStudy, studies, isLoading } = useActiveStudy()
const navItems = getNavItems(activeStudy?.id)
const visibleNavItems = activeStudy
? navItems.filter(item => !item.hideWithStudy)
: navItems.filter(item => !item.requiresStudy)
const isActiveRoute = (item: { href: string, exact?: boolean, baseRoute?: string }) => {
if (item.exact) {
return pathname === item.href;
}
if (item.baseRoute && activeStudy) {
const pattern = new RegExp(`/dashboard/studies/\\d+/${item.baseRoute}`);
return pattern.test(pathname);
}
return pathname.startsWith(item.href);
};
const handleStudyChange = (value: string) => {
if (value === "all") {
setActiveStudy(null);
router.push("/dashboard");
} else {
const study = studies.find(s => s.id.toString() === value);
if (study) {
setActiveStudy(study);
router.push(`/dashboard/studies/${study.id}`);
}
}
};
const SidebarContent = () => (
<div className="flex h-full flex-col">
<div className="p-4">
<Select
value={activeStudy?.id?.toString() || "all"}
onValueChange={handleStudyChange}
>
<SelectTrigger className="w-full sidebar-button">
<div className="flex items-center justify-between">
<span className="truncate">
{activeStudy?.title || "All Studies"}
</span>
</div>
</SelectTrigger>
<SelectContent className="sidebar-dropdown-content">
<SelectItem value="all" className="sidebar-button">
<div className="flex items-center">
<FolderIcon className="h-4 w-4 mr-2" />
All Studies
</div>
</SelectItem>
<Separator className="sidebar-separator" />
{studies.map((study) => (
<SelectItem
key={study.id}
value={study.id.toString()}
className="sidebar-button"
>
{study.title}
</SelectItem>
))}
<Separator className="sidebar-separator" />
<Button
variant="ghost"
className="w-full justify-start sidebar-button"
asChild
>
<Link href="/dashboard/studies/new">
<PlusIcon className="h-4 w-4 mr-2" />
Create New Study
</Link>
</Button>
</SelectContent>
</Select>
</div>
<nav className="flex-1 overflow-y-auto p-4">
<ul className="space-y-2">
{visibleNavItems.map((item) => {
const IconComponent = item.icon;
const isActive = isActiveRoute(item);
return (
<li key={item.href}>
<Button
asChild
variant="ghost"
className="w-full justify-start sidebar-button"
data-active={isActive}
>
<Link href={item.href} onClick={() => setIsOpen(false)}>
<IconComponent className="h-5 w-5 mr-3" />
<span>{item.name}</span>
</Link>
</Button>
</li>
);
})}
</ul>
</nav>
<div className="p-4">
<div className="border-t border-[hsl(var(--sidebar-separator))]">
<div className="flex items-center justify-between pt-4">
<div className="flex items-center space-x-4">
<div className="w-8 h-8">
<UserButton afterSignOutUrl="/" />
</div>
{user && (
<div className="min-w-0">
<p className="text-sm font-medium text-[hsl(var(--sidebar-foreground))] truncate">
{user.fullName ?? user.username ?? 'User'}
</p>
<p className="text-xs text-[hsl(var(--sidebar-muted))] truncate">
{user.primaryEmailAddress?.emailAddress ?? 'user@example.com'}
</p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
)
return (
<>
<div className="lg:hidden fixed top-0 left-0 right-0 z-50">
<div className="flex h-14 items-center justify-between border-b border-[hsl(var(--sidebar-border))]">
<Logo
href="/dashboard"
className="text-[hsl(var(--sidebar-foreground))]"
iconClassName="text-[hsl(var(--sidebar-muted))]"
/>
<Sheet open={isOpen} onOpenChange={setIsOpen}>
<SheetTrigger asChild>
<Button variant="ghost" className="h-14 w-14 px-0 sidebar-button">
<Menu className="h-6 w-6" />
</Button>
</SheetTrigger>
<SheetContent
side="left"
className="w-full p-0 border-[hsl(var(--sidebar-border))]"
>
<SheetTitle className="sr-only">Navigation Menu</SheetTitle>
<SidebarContent />
</SheetContent>
</Sheet>
</div>
</div>
<div className="hidden lg:flex lg:w-64 lg:flex-col lg:border-r lg:border-[hsl(var(--sidebar-border))]">
<div className="flex h-14 items-center border-b border-[hsl(var(--sidebar-border))] px-4">
<Logo
href="/dashboard"
className="text-[hsl(var(--sidebar-foreground))]"
iconClassName="text-[hsl(var(--sidebar-muted))]"
/>
</div>
<SidebarContent />
</div>
</>
)
}

View File

@@ -1,137 +0,0 @@
'use client';
import { useCallback, useEffect, useState } from "react";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { useToast } from "~/hooks/use-toast";
import { PERMISSIONS } from "~/lib/permissions-client";
import { InviteUserDialog } from "./invite-user-dialog";
interface Invitation {
id: string;
email: string;
roleName: string;
accepted: boolean;
expiresAt: string;
}
interface InvitationsTabProps {
studyId: number;
permissions: string[];
}
export function InvitationsTab({ studyId, permissions }: InvitationsTabProps) {
const [invitations, setInvitations] = useState<Invitation[]>([]);
const [isLoading, setIsLoading] = useState(true);
const { toast } = useToast();
const hasPermission = (permission: string) => permissions.includes(permission);
const canManageRoles = hasPermission(PERMISSIONS.MANAGE_ROLES);
const fetchInvitations = useCallback(async () => {
try {
const response = await fetch(`/api/invitations?studyId=${studyId}`);
if (!response.ok) throw new Error("Failed to fetch invitations");
const data = await response.json();
setInvitations(data.data || []);
} catch (error) {
console.error("Error fetching invitations:", error);
toast({
title: "Error",
description: "Failed to load invitations",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
}, [studyId, toast]);
useEffect(() => {
fetchInvitations();
}, [fetchInvitations]);
const handleDeleteInvitation = async (invitationId: string) => {
try {
const response = await fetch(`/api/invitations/${invitationId}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error("Failed to delete invitation");
}
setInvitations(invitations.filter(inv => inv.id !== invitationId));
toast({
title: "Success",
description: "Invitation deleted successfully",
});
} catch (error) {
console.error("Error deleting invitation:", error);
toast({
title: "Error",
description: "Failed to delete invitation",
variant: "destructive",
});
}
};
if (isLoading) {
return (
<Card>
<CardContent className="py-8">
<p className="text-center text-muted-foreground">Loading invitations...</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Manage Invitations</CardTitle>
<CardDescription>
Invite researchers and participants to collaborate on this study
</CardDescription>
</div>
<InviteUserDialog studyId={studyId} onInviteSent={fetchInvitations} />
</div>
</CardHeader>
<CardContent className="space-y-6">
{invitations.length > 0 ? (
<div className="space-y-4">
{invitations.map((invitation) => (
<div
key={invitation.id}
className="flex items-center justify-between p-4 border rounded-lg bg-card"
>
<div>
<p className="font-medium">{invitation.email}</p>
<p className="text-sm text-muted-foreground">
Role: {invitation.roleName}
{invitation.accepted ? " • Accepted" : " • Pending"}
</p>
</div>
{!invitation.accepted && (
<Button
variant="outline"
size="sm"
onClick={() => handleDeleteInvitation(invitation.id)}
>
Cancel
</Button>
)}
</div>
))}
</div>
) : (
<p className="text-muted-foreground">No invitations sent yet.</p>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,165 +0,0 @@
'use client';
import { useState, useEffect, useCallback } from "react";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { useToast } from "~/hooks/use-toast";
interface Role {
id: number;
name: string;
description: string;
}
interface InviteUserDialogProps {
studyId: number;
onInviteSent: () => void;
}
export function InviteUserDialog({ studyId, onInviteSent }: InviteUserDialogProps) {
const [email, setEmail] = useState("");
const [roleId, setRoleId] = useState<string>("");
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [roles, setRoles] = useState<Role[]>([]);
const { toast } = useToast();
const fetchRoles = useCallback(async () => {
try {
const response = await fetch("/api/roles");
if (!response.ok) {
throw new Error("Failed to fetch roles");
}
const data = await response.json();
// Filter out admin and PI roles
setRoles(data.filter((role: Role) =>
!['admin', 'principal_investigator'].includes(role.name)
));
} catch (error) {
console.error("Error fetching roles:", error);
toast({
title: "Error",
description: "Failed to load roles",
variant: "destructive",
});
}
}, [toast]);
// Fetch available roles when dialog opens
useEffect(() => {
fetchRoles();
}, [fetchRoles]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !roleId) return;
setIsLoading(true);
try {
const response = await fetch("/api/invitations", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email,
roleId: parseInt(roleId, 10),
studyId,
}),
});
if (!response.ok) {
throw new Error("Failed to send invitation");
}
toast({
title: "Success",
description: "Invitation sent successfully",
});
setIsOpen(false);
setEmail("");
setRoleId("");
onInviteSent();
} catch (error) {
console.error("Error sending invitation:", error);
toast({
title: "Error",
description: "Failed to send invitation",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button>Invite User</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Invite User</DialogTitle>
<DialogDescription>
Send an invitation to collaborate on this study
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="Enter email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select value={roleId} onValueChange={setRoleId} required>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role.id} value={role.id.toString()}>
{role.name.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
).join(' ')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? "Sending..." : "Send Invitation"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

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