feat: rewrite project
@@ -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
|
||||
52
.env.example
@@ -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
@@ -0,0 +1,61 @@
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
const config = {
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"project": true
|
||||
},
|
||||
"plugins": [
|
||||
"@typescript-eslint",
|
||||
"drizzle"
|
||||
],
|
||||
"extends": [
|
||||
"next/core-web-vitals",
|
||||
"plugin:@typescript-eslint/recommended-type-checked",
|
||||
"plugin:@typescript-eslint/stylistic-type-checked"
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/array-type": "off",
|
||||
"@typescript-eslint/consistent-type-definitions": "off",
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"warn",
|
||||
{
|
||||
"prefer": "type-imports",
|
||||
"fixStyle": "inline-type-imports"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"argsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/require-await": "off",
|
||||
"@typescript-eslint/no-misused-promises": [
|
||||
"error",
|
||||
{
|
||||
"checksVoidReturn": {
|
||||
"attributes": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"drizzle/enforce-delete-with-where": [
|
||||
"error",
|
||||
{
|
||||
"drizzleObjectName": [
|
||||
"db",
|
||||
"ctx.db"
|
||||
]
|
||||
}
|
||||
],
|
||||
"drizzle/enforce-update-with-where": [
|
||||
"error",
|
||||
{
|
||||
"drizzleObjectName": [
|
||||
"db",
|
||||
"ctx.db"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
module.exports = config;
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals",
|
||||
"rules": {
|
||||
"@typescript-eslint/no-empty-interface": "off"
|
||||
}
|
||||
}
|
||||
BIN
.github/homepage-screenshot.png
vendored
|
Before Width: | Height: | Size: 816 KiB |
6
.vscode/settings.json
vendored
@@ -1,8 +1,2 @@
|
||||
{
|
||||
"conventionalCommits.scopes": [
|
||||
"homepage",
|
||||
"repo",
|
||||
"auth",
|
||||
"perms"
|
||||
]
|
||||
}
|
||||
7
LICENSE
@@ -1,7 +0,0 @@
|
||||
Copyright © 2024 Sean O'Connor
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
229
README.md
@@ -1,85 +1,188 @@
|
||||
# [HRIStudio](https://www.hristudio.com)
|
||||
# HRIStudio
|
||||
|
||||
A web platform for managing human-robot interaction studies, participants, and wizard-of-oz experiments.
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
@@ -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
@@ -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:
|
||||
@@ -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
@@ -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;
|
||||
@@ -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
114
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
Permissions:
|
||||
|
||||
Roles table, permissions table, roles_permissions table
|
||||
user has a role, role has many permissions
|
||||
user can have multiple roles
|
||||
each role has many permissions, each action that the user can do is a permission
|
||||
|
||||
6101
pnpm-lock.yaml
generated
5
postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
4
prettier.config.js
Normal file
@@ -0,0 +1,4 @@
|
||||
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
|
||||
export default {
|
||||
plugins: ["prettier-plugin-tailwindcss"],
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 391 B |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
3
public/grid.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 0V100M21 0V100M41 0V100M61 0V100M81 0V100M0 1H100M0 21H100M0 41H100M0 61H100M0 81H100" stroke="currentColor" stroke-opacity="0.1" vector-effect="non-scaling-stroke"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 288 B |
|
Before Width: | Height: | Size: 2.0 MiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 128 B |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
Before Width: | Height: | Size: 385 B |
24
scripts/init-minio.sh
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Install MinIO client if not already installed
|
||||
if ! command -v mc &> /dev/null; then
|
||||
echo "Installing MinIO client..."
|
||||
curl -O https://dl.min.io/client/mc/release/darwin-amd64/mc
|
||||
chmod +x mc
|
||||
sudo mv mc /usr/local/bin/
|
||||
fi
|
||||
|
||||
# Configure MinIO client
|
||||
mc alias set local http://localhost:9000 minioadmin minioadmin
|
||||
|
||||
# Create bucket if it doesn't exist
|
||||
if ! mc ls local/uploads &> /dev/null; then
|
||||
echo "Creating uploads bucket..."
|
||||
mc mb local/uploads
|
||||
fi
|
||||
|
||||
# Set bucket policy to public
|
||||
echo "Setting bucket policy..."
|
||||
mc policy set public local/uploads
|
||||
|
||||
echo "MinIO initialization complete!"
|
||||
118
src/app/_components/auth/sign-in-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
src/app/_components/auth/sign-up-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import NextAuth from "next-auth";
|
||||
import { authOptions } from "~/server/auth";
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
62
src/app/api/auth/register/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
71
src/app/api/images/[key]/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getObjectFromS3 } from "~/server/storage/s3";
|
||||
import { Readable } from "stream";
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: { key: string } }
|
||||
) {
|
||||
try {
|
||||
// Ensure params.key is awaited
|
||||
const { key } = params;
|
||||
const decodedKey = decodeURIComponent(key);
|
||||
console.log("Fetching image with key:", decodedKey);
|
||||
|
||||
const response = await getObjectFromS3(decodedKey);
|
||||
console.log("S3 response received:", {
|
||||
contentType: response.ContentType,
|
||||
contentLength: response.ContentLength,
|
||||
});
|
||||
|
||||
if (!response.Body) {
|
||||
console.error("No image data in response body");
|
||||
return NextResponse.json(
|
||||
{ error: "Image data not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const stream = response.Body as Readable;
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
const buffer = Buffer.concat(chunks);
|
||||
|
||||
console.log("Image buffer created, size:", buffer.length);
|
||||
|
||||
// Ensure we set the correct image content type
|
||||
const contentType = response.ContentType ?? 'image/jpeg';
|
||||
|
||||
// Create response headers
|
||||
const headers = {
|
||||
"Content-Type": contentType,
|
||||
"Content-Length": buffer.length.toString(),
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
};
|
||||
|
||||
console.log("Sending response with headers:", headers);
|
||||
|
||||
// Return the response with explicit headers object
|
||||
return new Response(buffer, {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error serving image:", error);
|
||||
if ((error as any)?.name === "NoSuchKey") {
|
||||
return NextResponse.json(
|
||||
{ error: "Image not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
// @ts-nocheck
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { NextRequest } from "next/server";
|
||||
import { db } from "~/db";
|
||||
import { invitationsTable } from "~/db/schema";
|
||||
import { PERMISSIONS, checkPermissions } from "~/lib/permissions-server";
|
||||
import { ApiError, createApiResponse } from "~/lib/api-utils";
|
||||
|
||||
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const { id } = params;
|
||||
const invitationId = parseInt(id, 10);
|
||||
|
||||
if (isNaN(invitationId)) {
|
||||
return ApiError.BadRequest("Invalid invitation ID");
|
||||
}
|
||||
|
||||
// Get the invitation to check the study ID
|
||||
const invitation = await db
|
||||
.select()
|
||||
.from(invitationsTable)
|
||||
.where(eq(invitationsTable.id, invitationId))
|
||||
.limit(1);
|
||||
|
||||
if (!invitation[0]) {
|
||||
return ApiError.NotFound("Invitation");
|
||||
}
|
||||
|
||||
const permissionCheck = await checkPermissions({
|
||||
studyId: invitation[0].studyId,
|
||||
permission: PERMISSIONS.MANAGE_ROLES
|
||||
});
|
||||
|
||||
if (permissionCheck.error) {
|
||||
return permissionCheck.error;
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(invitationsTable)
|
||||
.where(eq(invitationsTable.id, invitationId));
|
||||
|
||||
return createApiResponse({ message: "Invitation deleted successfully" });
|
||||
} catch (error) {
|
||||
return ApiError.ServerError(error);
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { NextRequest } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { db } from "~/db";
|
||||
import { invitationsTable, userRolesTable } from "~/db/schema";
|
||||
import { ApiError, createApiResponse } from "~/lib/api-utils";
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: { token: string } }) {
|
||||
const { userId } = await auth();
|
||||
const { token } = params;
|
||||
|
||||
if (!userId) {
|
||||
return ApiError.Unauthorized();
|
||||
}
|
||||
|
||||
try {
|
||||
// Find the invitation
|
||||
const [invitation] = await db
|
||||
.select()
|
||||
.from(invitationsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(invitationsTable.token, token),
|
||||
eq(invitationsTable.accepted, false)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!invitation) {
|
||||
return ApiError.NotFound("Invitation");
|
||||
}
|
||||
|
||||
// Check if invitation has expired
|
||||
if (new Date() > invitation.expiresAt) {
|
||||
return ApiError.BadRequest("Invitation has expired");
|
||||
}
|
||||
|
||||
// Assign role and mark invitation as accepted in a transaction
|
||||
await db.transaction(async (tx) => {
|
||||
// Assign role
|
||||
await tx
|
||||
.insert(userRolesTable)
|
||||
.values({
|
||||
userId: userId,
|
||||
roleId: invitation.roleId,
|
||||
studyId: invitation.studyId,
|
||||
});
|
||||
|
||||
// Mark invitation as accepted
|
||||
await tx
|
||||
.update(invitationsTable)
|
||||
.set({
|
||||
accepted: true,
|
||||
acceptedByUserId: userId,
|
||||
})
|
||||
.where(eq(invitationsTable.id, invitation.id));
|
||||
});
|
||||
|
||||
return createApiResponse({ message: "Invitation accepted successfully" });
|
||||
} catch (error) {
|
||||
return ApiError.ServerError(error);
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { db } from "~/db";
|
||||
import { invitationsTable, studyTable, rolesTable } from "~/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { randomBytes } from "crypto";
|
||||
import { sendInvitationEmail } from "~/lib/email";
|
||||
import { PERMISSIONS, checkPermissions } from "~/lib/permissions-server";
|
||||
import { ApiError, createApiResponse } from "~/lib/api-utils";
|
||||
|
||||
// Helper to generate a secure random token
|
||||
function generateToken(): string {
|
||||
return randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const studyId = url.searchParams.get("studyId");
|
||||
|
||||
if (!studyId) {
|
||||
return ApiError.BadRequest("Study ID is required");
|
||||
}
|
||||
|
||||
const permissionCheck = await checkPermissions({
|
||||
studyId: parseInt(studyId),
|
||||
permission: PERMISSIONS.MANAGE_ROLES
|
||||
});
|
||||
|
||||
if (permissionCheck.error) {
|
||||
return permissionCheck.error;
|
||||
}
|
||||
|
||||
// Get all invitations for the study, including role names
|
||||
const invitations = await db
|
||||
.select({
|
||||
id: invitationsTable.id,
|
||||
email: invitationsTable.email,
|
||||
accepted: invitationsTable.accepted,
|
||||
expiresAt: invitationsTable.expiresAt,
|
||||
createdAt: invitationsTable.createdAt,
|
||||
roleName: rolesTable.name,
|
||||
})
|
||||
.from(invitationsTable)
|
||||
.innerJoin(rolesTable, eq(invitationsTable.roleId, rolesTable.id))
|
||||
.where(eq(invitationsTable.studyId, parseInt(studyId)));
|
||||
|
||||
return createApiResponse(invitations);
|
||||
} catch (error) {
|
||||
return ApiError.ServerError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { email, studyId, roleId } = await request.json();
|
||||
|
||||
const permissionCheck = await checkPermissions({
|
||||
studyId,
|
||||
permission: PERMISSIONS.MANAGE_ROLES
|
||||
});
|
||||
|
||||
if (permissionCheck.error) {
|
||||
return permissionCheck.error;
|
||||
}
|
||||
|
||||
const { userId } = permissionCheck;
|
||||
|
||||
// Get study details
|
||||
const study = await db
|
||||
.select()
|
||||
.from(studyTable)
|
||||
.where(eq(studyTable.id, studyId))
|
||||
.limit(1);
|
||||
|
||||
if (!study[0]) {
|
||||
return ApiError.NotFound("Study");
|
||||
}
|
||||
|
||||
// Verify the role exists
|
||||
const role = await db
|
||||
.select()
|
||||
.from(rolesTable)
|
||||
.where(eq(rolesTable.id, roleId))
|
||||
.limit(1);
|
||||
|
||||
if (!role[0]) {
|
||||
return ApiError.BadRequest("Invalid role");
|
||||
}
|
||||
|
||||
// Generate invitation token
|
||||
const token = generateToken();
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 7); // 7 days expiration
|
||||
|
||||
// Create invitation
|
||||
const [invitation] = await db
|
||||
.insert(invitationsTable)
|
||||
.values({
|
||||
email,
|
||||
studyId,
|
||||
roleId,
|
||||
token,
|
||||
invitedById: userId,
|
||||
expiresAt,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Send invitation email
|
||||
await sendInvitationEmail({
|
||||
to: email,
|
||||
inviterName: "A researcher", // TODO: Get inviter name
|
||||
studyTitle: study[0].title,
|
||||
role: role[0].name,
|
||||
token,
|
||||
});
|
||||
|
||||
return createApiResponse(invitation);
|
||||
} catch (error) {
|
||||
return ApiError.ServerError(error);
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { db } from "~/db";
|
||||
import { participantsTable } from "~/db/schema";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const studyId = url.searchParams.get("studyId");
|
||||
|
||||
if (!studyId) {
|
||||
return new NextResponse("Study ID is required", { status: 400 });
|
||||
}
|
||||
|
||||
const participantList = await db
|
||||
.select()
|
||||
.from(participantsTable)
|
||||
.where(eq(participantsTable.studyId, parseInt(studyId)));
|
||||
|
||||
return NextResponse.json(participantList);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const { name, studyId } = await request.json();
|
||||
|
||||
try {
|
||||
const participant = await db
|
||||
.insert(participantsTable)
|
||||
.values({
|
||||
name,
|
||||
studyId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return NextResponse.json(participant[0]);
|
||||
} catch (error) {
|
||||
console.error("Error adding participant:", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { ApiError, createApiResponse } from "~/lib/api-utils";
|
||||
import { db } from "~/db";
|
||||
import { userRolesTable, rolePermissionsTable, permissionsTable } from "~/db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
export async function GET() {
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return ApiError.Unauthorized();
|
||||
}
|
||||
|
||||
try {
|
||||
const permissions = await db
|
||||
.selectDistinct({
|
||||
code: permissionsTable.code,
|
||||
})
|
||||
.from(userRolesTable)
|
||||
.innerJoin(rolePermissionsTable, eq(rolePermissionsTable.roleId, userRolesTable.roleId))
|
||||
.innerJoin(permissionsTable, eq(permissionsTable.id, rolePermissionsTable.permissionId))
|
||||
.where(eq(userRolesTable.userId, userId));
|
||||
|
||||
return createApiResponse(permissions.map(p => p.code));
|
||||
} catch (error) {
|
||||
return ApiError.ServerError(error);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { db } from "~/db";
|
||||
import { rolesTable } from "~/db/schema";
|
||||
|
||||
export async function GET() {
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const roles = await db
|
||||
.select({
|
||||
id: rolesTable.id,
|
||||
name: rolesTable.name,
|
||||
description: rolesTable.description,
|
||||
})
|
||||
.from(rolesTable);
|
||||
|
||||
return NextResponse.json(roles);
|
||||
} catch (error) {
|
||||
console.error("Error fetching roles:", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "~/db";
|
||||
import { participantsTable } from "~/db/schema";
|
||||
import { PERMISSIONS, checkPermissions } from "~/lib/permissions-server";
|
||||
import { ApiError, createApiResponse } from "~/lib/api-utils";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
context: { params: { id: string } }
|
||||
) {
|
||||
const { userId } = await auth();
|
||||
const { id } = await Promise.resolve(context.params);
|
||||
|
||||
if (!userId) {
|
||||
return ApiError.Unauthorized();
|
||||
}
|
||||
|
||||
try {
|
||||
const studyId = parseInt(id);
|
||||
|
||||
if (isNaN(studyId)) {
|
||||
return ApiError.BadRequest("Invalid study ID");
|
||||
}
|
||||
|
||||
const permissionCheck = await checkPermissions({
|
||||
studyId,
|
||||
permission: PERMISSIONS.VIEW_PARTICIPANT_NAMES,
|
||||
});
|
||||
|
||||
const participants = await db
|
||||
.select()
|
||||
.from(participantsTable)
|
||||
.where(eq(participantsTable.studyId, studyId));
|
||||
|
||||
if (permissionCheck.error) {
|
||||
const anonymizedParticipants = participants.map((participant, index) => ({
|
||||
...participant,
|
||||
name: `Participant ${String.fromCharCode(65 + index)}`,
|
||||
}));
|
||||
return createApiResponse(anonymizedParticipants);
|
||||
}
|
||||
|
||||
return createApiResponse(participants);
|
||||
} catch (error) {
|
||||
return ApiError.ServerError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
context: { params: { id: string } }
|
||||
) {
|
||||
const { userId } = await auth();
|
||||
const { id } = await Promise.resolve(context.params);
|
||||
|
||||
if (!userId) {
|
||||
return ApiError.Unauthorized();
|
||||
}
|
||||
|
||||
try {
|
||||
const studyId = parseInt(id);
|
||||
const { name } = await request.json();
|
||||
|
||||
if (isNaN(studyId)) {
|
||||
return ApiError.BadRequest("Invalid study ID");
|
||||
}
|
||||
|
||||
if (!name || typeof name !== "string") {
|
||||
return ApiError.BadRequest("Name is required");
|
||||
}
|
||||
|
||||
const permissionCheck = await checkPermissions({
|
||||
studyId,
|
||||
permission: PERMISSIONS.CREATE_PARTICIPANT,
|
||||
});
|
||||
|
||||
if (permissionCheck.error) {
|
||||
return permissionCheck.error;
|
||||
}
|
||||
|
||||
const participant = await db
|
||||
.insert(participantsTable)
|
||||
.values({
|
||||
name,
|
||||
studyId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return createApiResponse(participant[0]);
|
||||
} catch (error) {
|
||||
return ApiError.ServerError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
context: { params: { id: string } }
|
||||
) {
|
||||
const { userId } = await auth();
|
||||
const { id } = await Promise.resolve(context.params);
|
||||
|
||||
if (!userId) {
|
||||
return ApiError.Unauthorized();
|
||||
}
|
||||
|
||||
try {
|
||||
const studyId = parseInt(id);
|
||||
const { participantId } = await request.json();
|
||||
|
||||
if (isNaN(studyId)) {
|
||||
return ApiError.BadRequest("Invalid study ID");
|
||||
}
|
||||
|
||||
if (!participantId || typeof participantId !== "number") {
|
||||
return ApiError.BadRequest("Participant ID is required");
|
||||
}
|
||||
|
||||
const permissionCheck = await checkPermissions({
|
||||
studyId,
|
||||
permission: PERMISSIONS.DELETE_PARTICIPANT,
|
||||
});
|
||||
|
||||
if (permissionCheck.error) {
|
||||
return permissionCheck.error;
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(participantsTable)
|
||||
.where(eq(participantsTable.id, participantId));
|
||||
|
||||
return createApiResponse({ success: true });
|
||||
} catch (error) {
|
||||
return ApiError.ServerError(error);
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { db } from "~/db";
|
||||
import { studyTable, userRolesTable, rolePermissionsTable, permissionsTable } from "~/db/schema";
|
||||
import { PERMISSIONS, checkPermissions } from "~/lib/permissions-server";
|
||||
import { ApiError, createApiResponse } from "~/lib/api-utils";
|
||||
|
||||
export async function GET(request: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const id = await Promise.resolve(params.id);
|
||||
const studyId = parseInt(id);
|
||||
|
||||
if (isNaN(studyId)) {
|
||||
return ApiError.BadRequest("Invalid study ID");
|
||||
}
|
||||
|
||||
const permissionCheck = await checkPermissions({
|
||||
studyId,
|
||||
permission: PERMISSIONS.VIEW_STUDY
|
||||
});
|
||||
|
||||
if (permissionCheck.error) {
|
||||
return permissionCheck.error;
|
||||
}
|
||||
|
||||
// Get study with permissions
|
||||
const studyWithPermissions = await db
|
||||
.selectDistinct({
|
||||
id: studyTable.id,
|
||||
title: studyTable.title,
|
||||
description: studyTable.description,
|
||||
createdAt: studyTable.createdAt,
|
||||
updatedAt: studyTable.updatedAt,
|
||||
userId: studyTable.userId,
|
||||
permissionCode: permissionsTable.code,
|
||||
})
|
||||
.from(studyTable)
|
||||
.leftJoin(userRolesTable, eq(userRolesTable.studyId, studyTable.id))
|
||||
.leftJoin(rolePermissionsTable, eq(rolePermissionsTable.roleId, userRolesTable.roleId))
|
||||
.leftJoin(permissionsTable, eq(permissionsTable.id, rolePermissionsTable.permissionId))
|
||||
.where(eq(studyTable.id, studyId));
|
||||
|
||||
if (!studyWithPermissions.length) {
|
||||
return ApiError.NotFound("Study");
|
||||
}
|
||||
|
||||
// Group permissions
|
||||
const study = {
|
||||
...studyWithPermissions[0],
|
||||
permissions: studyWithPermissions
|
||||
.map(s => s.permissionCode)
|
||||
.filter((code): code is string => code !== null)
|
||||
};
|
||||
|
||||
return createApiResponse(study);
|
||||
} catch (error) {
|
||||
return ApiError.ServerError(error);
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "~/db";
|
||||
import { participantsTable } from "~/db/schema";
|
||||
import { PERMISSIONS, checkPermissions } from "~/lib/permissions-server";
|
||||
import { ApiError, createApiResponse } from "~/lib/api-utils";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
context: { params: { id: string } }
|
||||
) {
|
||||
const { userId } = await auth();
|
||||
const { id } = await Promise.resolve(context.params);
|
||||
|
||||
if (!userId) {
|
||||
return ApiError.Unauthorized();
|
||||
}
|
||||
|
||||
try {
|
||||
const studyId = parseInt(id);
|
||||
|
||||
if (isNaN(studyId)) {
|
||||
return ApiError.BadRequest("Invalid study ID");
|
||||
}
|
||||
|
||||
const permissionCheck = await checkPermissions({
|
||||
studyId,
|
||||
permission: PERMISSIONS.VIEW_STUDY,
|
||||
});
|
||||
|
||||
if (permissionCheck.error) {
|
||||
return permissionCheck.error;
|
||||
}
|
||||
|
||||
// Get participant count using SQL count
|
||||
const [{ count }] = await db
|
||||
.select({
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(participantsTable)
|
||||
.where(eq(participantsTable.studyId, studyId));
|
||||
|
||||
// TODO: Add actual trial and form counts when those tables are added
|
||||
const stats = {
|
||||
participantCount: count,
|
||||
completedTrialsCount: 0,
|
||||
pendingFormsCount: 0,
|
||||
};
|
||||
|
||||
return createApiResponse(stats);
|
||||
} catch (error) {
|
||||
return ApiError.ServerError(error);
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { db } from "~/db";
|
||||
import { userRolesTable } from "~/db/schema";
|
||||
import { PERMISSIONS, checkPermissions } from "~/lib/permissions-server";
|
||||
import { ApiError, createApiResponse } from "~/lib/api-utils";
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
context: { params: { id: string; userId: string } }
|
||||
) {
|
||||
try {
|
||||
const { id, userId } = await Promise.resolve(context.params);
|
||||
const studyId = parseInt(id);
|
||||
|
||||
if (isNaN(studyId)) {
|
||||
return ApiError.BadRequest("Invalid study ID");
|
||||
}
|
||||
|
||||
const permissionCheck = await checkPermissions({
|
||||
studyId,
|
||||
permission: PERMISSIONS.MANAGE_ROLES
|
||||
});
|
||||
|
||||
if (permissionCheck.error) {
|
||||
return permissionCheck.error;
|
||||
}
|
||||
|
||||
const { roleId } = await request.json();
|
||||
|
||||
if (!roleId || typeof roleId !== "number") {
|
||||
return ApiError.BadRequest("Role ID is required");
|
||||
}
|
||||
|
||||
// Update user's role in the study
|
||||
await db.transaction(async (tx) => {
|
||||
// Delete existing roles
|
||||
await tx
|
||||
.delete(userRolesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(userRolesTable.userId, userId),
|
||||
eq(userRolesTable.studyId, studyId)
|
||||
)
|
||||
);
|
||||
|
||||
// Assign new role
|
||||
await tx
|
||||
.insert(userRolesTable)
|
||||
.values({
|
||||
userId,
|
||||
roleId,
|
||||
studyId,
|
||||
});
|
||||
});
|
||||
|
||||
return createApiResponse({ message: "Role updated successfully" });
|
||||
} catch (error) {
|
||||
return ApiError.ServerError(error);
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { db } from "~/db";
|
||||
import { userRolesTable, usersTable, rolesTable } from "~/db/schema";
|
||||
import { PERMISSIONS, checkPermissions } from "~/lib/permissions-server";
|
||||
import { ApiError, createApiResponse } from "~/lib/api-utils";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
context: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const { id } = await Promise.resolve(context.params);
|
||||
const studyId = parseInt(id);
|
||||
|
||||
if (isNaN(studyId)) {
|
||||
return ApiError.BadRequest("Invalid study ID");
|
||||
}
|
||||
|
||||
const permissionCheck = await checkPermissions({
|
||||
studyId,
|
||||
permission: PERMISSIONS.VIEW_STUDY
|
||||
});
|
||||
|
||||
if (permissionCheck.error) {
|
||||
return permissionCheck.error;
|
||||
}
|
||||
|
||||
// Get all users in the study with their roles
|
||||
const studyUsers = await db
|
||||
.select({
|
||||
id: usersTable.id,
|
||||
email: usersTable.email,
|
||||
name: usersTable.name,
|
||||
imageUrl: usersTable.imageUrl,
|
||||
roleId: rolesTable.id,
|
||||
roleName: rolesTable.name,
|
||||
})
|
||||
.from(userRolesTable)
|
||||
.innerJoin(usersTable, eq(usersTable.id, userRolesTable.userId))
|
||||
.innerJoin(rolesTable, eq(rolesTable.id, userRolesTable.roleId))
|
||||
.where(eq(userRolesTable.studyId, studyId));
|
||||
|
||||
// Group roles by user
|
||||
const users = studyUsers.reduce((acc, curr) => {
|
||||
const existingUser = acc.find(u => u.id === curr.id);
|
||||
if (!existingUser) {
|
||||
acc.push({
|
||||
id: curr.id,
|
||||
email: curr.email,
|
||||
name: curr.name,
|
||||
imageUrl: curr.imageUrl,
|
||||
roles: [{
|
||||
id: curr.roleId,
|
||||
name: curr.roleName,
|
||||
}]
|
||||
});
|
||||
} else if (curr.roleName && !existingUser.roles.some(r => r.id === curr.roleId)) {
|
||||
existingUser.roles.push({
|
||||
id: curr.roleId,
|
||||
name: curr.roleName,
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, [] as Array<{
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
imageUrl: string | null;
|
||||
roles: Array<{ id: number; name: string }>;
|
||||
}>);
|
||||
|
||||
return createApiResponse(users);
|
||||
} catch (error) {
|
||||
return ApiError.ServerError(error);
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import { eq, and, or } from "drizzle-orm";
|
||||
import { db } from "~/db";
|
||||
import { studyTable, userRolesTable, rolePermissionsTable, permissionsTable, rolesTable } from "~/db/schema";
|
||||
import { PERMISSIONS, checkPermissions } from "~/lib/permissions-server";
|
||||
import { ApiError, createApiResponse, getEnvironment } from "~/lib/api-utils";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
|
||||
export async function GET() {
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return ApiError.Unauthorized();
|
||||
}
|
||||
|
||||
try {
|
||||
const currentEnvironment = getEnvironment();
|
||||
|
||||
// Get all studies where user has any role
|
||||
const studiesWithPermissions = await db
|
||||
.selectDistinct({
|
||||
id: studyTable.id,
|
||||
title: studyTable.title,
|
||||
description: studyTable.description,
|
||||
createdAt: studyTable.createdAt,
|
||||
updatedAt: studyTable.updatedAt,
|
||||
userId: studyTable.userId,
|
||||
permissionCode: permissionsTable.code,
|
||||
roleName: rolesTable.name,
|
||||
})
|
||||
.from(studyTable)
|
||||
.innerJoin(
|
||||
userRolesTable,
|
||||
and(
|
||||
eq(userRolesTable.studyId, studyTable.id),
|
||||
eq(userRolesTable.userId, userId)
|
||||
)
|
||||
)
|
||||
.innerJoin(rolesTable, eq(rolesTable.id, userRolesTable.roleId))
|
||||
.leftJoin(rolePermissionsTable, eq(rolePermissionsTable.roleId, userRolesTable.roleId))
|
||||
.leftJoin(permissionsTable, eq(permissionsTable.id, rolePermissionsTable.permissionId))
|
||||
.where(eq(studyTable.environment, currentEnvironment));
|
||||
|
||||
// Group permissions and roles by study
|
||||
const studies = studiesWithPermissions.reduce((acc, curr) => {
|
||||
const existingStudy = acc.find(s => s.id === curr.id);
|
||||
if (!existingStudy) {
|
||||
acc.push({
|
||||
id: curr.id,
|
||||
title: curr.title,
|
||||
description: curr.description,
|
||||
createdAt: curr.createdAt,
|
||||
updatedAt: curr.updatedAt,
|
||||
userId: curr.userId,
|
||||
permissions: curr.permissionCode ? [curr.permissionCode] : [],
|
||||
roles: curr.roleName ? [curr.roleName] : []
|
||||
});
|
||||
} else {
|
||||
if (curr.permissionCode && !existingStudy.permissions.includes(curr.permissionCode)) {
|
||||
existingStudy.permissions.push(curr.permissionCode);
|
||||
}
|
||||
if (curr.roleName && !existingStudy.roles.includes(curr.roleName)) {
|
||||
existingStudy.roles.push(curr.roleName);
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}, [] as Array<{
|
||||
id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date | null;
|
||||
userId: string;
|
||||
permissions: string[];
|
||||
roles: string[];
|
||||
}>);
|
||||
|
||||
return createApiResponse(studies);
|
||||
} catch (error) {
|
||||
return ApiError.ServerError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return ApiError.Unauthorized();
|
||||
}
|
||||
|
||||
try {
|
||||
const { title, description } = await request.json();
|
||||
const currentEnvironment = getEnvironment();
|
||||
|
||||
// Create study and assign admin role in a transaction
|
||||
const result = await db.transaction(async (tx) => {
|
||||
// Create the study
|
||||
const [study] = await tx
|
||||
.insert(studyTable)
|
||||
.values({
|
||||
title,
|
||||
description,
|
||||
userId: userId,
|
||||
environment: currentEnvironment,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Look up the ADMIN role
|
||||
const [adminRole] = await tx
|
||||
.select()
|
||||
.from(rolesTable)
|
||||
.where(eq(rolesTable.name, 'admin'))
|
||||
.limit(1);
|
||||
|
||||
if (!adminRole) {
|
||||
throw new Error('Admin role not found');
|
||||
}
|
||||
|
||||
// Assign admin role
|
||||
await tx
|
||||
.insert(userRolesTable)
|
||||
.values({
|
||||
userId: userId,
|
||||
roleId: adminRole.id,
|
||||
studyId: study.id,
|
||||
});
|
||||
|
||||
// Get all permissions for this role
|
||||
const permissions = await tx
|
||||
.select({
|
||||
permissionCode: permissionsTable.code
|
||||
})
|
||||
.from(rolePermissionsTable)
|
||||
.innerJoin(permissionsTable, eq(permissionsTable.id, rolePermissionsTable.permissionId))
|
||||
.where(eq(rolePermissionsTable.roleId, adminRole.id));
|
||||
|
||||
return {
|
||||
...study,
|
||||
permissions: permissions.map(p => p.permissionCode)
|
||||
};
|
||||
});
|
||||
|
||||
return createApiResponse(result);
|
||||
} catch (error) {
|
||||
return ApiError.ServerError(error);
|
||||
}
|
||||
}
|
||||
34
src/app/api/trpc/[trpc]/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||
import { type NextRequest } from "next/server";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { appRouter } from "~/server/api/root";
|
||||
import { createTRPCContext } from "~/server/api/trpc";
|
||||
|
||||
/**
|
||||
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
|
||||
* handling a HTTP request (e.g. when you make requests from Client Components).
|
||||
*/
|
||||
const createContext = async (req: NextRequest) => {
|
||||
return createTRPCContext({
|
||||
headers: req.headers,
|
||||
});
|
||||
};
|
||||
|
||||
const handler = (req: NextRequest) =>
|
||||
fetchRequestHandler({
|
||||
endpoint: "/api/trpc",
|
||||
req,
|
||||
router: appRouter,
|
||||
createContext: () => createContext(req),
|
||||
onError:
|
||||
env.NODE_ENV === "development"
|
||||
? ({ path, error }) => {
|
||||
console.error(
|
||||
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
42
src/app/api/upload/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getServerAuthSession } from "~/server/auth";
|
||||
import { uploadToS3 } from "~/server/storage/s3";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const session = await getServerAuthSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get the form data
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as File;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ error: "No file provided" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generate a unique key for the file
|
||||
const key = `${session.user.id}/${nanoid()}.${file.type.split("/")[1]}`;
|
||||
|
||||
// Convert file to buffer and upload
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
const url = await uploadToS3(buffer, key, file.type);
|
||||
|
||||
console.log("File uploaded successfully:", key);
|
||||
|
||||
return NextResponse.json({ url });
|
||||
} catch (error) {
|
||||
console.error("Error handling upload:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { Webhook } from 'svix';
|
||||
import { headers } from 'next/headers';
|
||||
import { WebhookEvent } from '@clerk/nextjs/server';
|
||||
import { db } from '~/db';
|
||||
import { usersTable } from '~/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
// Get the headers
|
||||
const headerPayload = headers();
|
||||
const svix_id = headerPayload.get("svix-id");
|
||||
const svix_timestamp = headerPayload.get("svix-timestamp");
|
||||
const svix_signature = headerPayload.get("svix-signature");
|
||||
|
||||
// If there are no headers, error out
|
||||
if (!svix_id || !svix_timestamp || !svix_signature) {
|
||||
return new Response('Error occured -- no svix headers', {
|
||||
status: 400
|
||||
});
|
||||
}
|
||||
|
||||
// Get the body
|
||||
const payload = await req.json();
|
||||
const body = JSON.stringify(payload);
|
||||
|
||||
// Create a new Svix instance with your webhook secret
|
||||
const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET || '');
|
||||
|
||||
let evt: WebhookEvent;
|
||||
|
||||
// Verify the payload with the headers
|
||||
try {
|
||||
evt = wh.verify(body, {
|
||||
"svix-id": svix_id,
|
||||
"svix-timestamp": svix_timestamp,
|
||||
"svix-signature": svix_signature,
|
||||
}) as WebhookEvent;
|
||||
} catch (err) {
|
||||
console.error('Error verifying webhook:', err);
|
||||
return new Response('Error occured', {
|
||||
status: 400
|
||||
});
|
||||
}
|
||||
|
||||
// Handle the webhook
|
||||
const eventType = evt.type;
|
||||
console.log(`Webhook received: ${eventType}`);
|
||||
|
||||
if (eventType === 'user.created' || eventType === 'user.updated') {
|
||||
const { id, email_addresses, first_name, last_name, image_url } = evt.data;
|
||||
const primaryEmail = email_addresses?.[0]?.email_address;
|
||||
|
||||
// Create or update user in our database
|
||||
await db.insert(usersTable).values({
|
||||
id,
|
||||
email: primaryEmail,
|
||||
name: [first_name, last_name].filter(Boolean).join(' ') || null,
|
||||
imageUrl: image_url,
|
||||
}).onConflictDoUpdate({
|
||||
target: usersTable.id,
|
||||
set: {
|
||||
email: primaryEmail,
|
||||
name: [first_name, last_name].filter(Boolean).join(' ') || null,
|
||||
imageUrl: image_url,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`${eventType === 'user.created' ? 'Created' : 'Updated'} user in database: ${id}`);
|
||||
}
|
||||
|
||||
return new Response('', { status: 200 });
|
||||
}
|
||||
68
src/app/auth/signin/page.tsx
Normal 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">
|
||||
“HRI Studio has revolutionized how we conduct human-robot interaction studies.”
|
||||
</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't have an account? Sign Up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
src/app/auth/signup/page.tsx
Normal 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">
|
||||
“HRI Studio has revolutionized how we conduct human-robot interaction studies.”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
280
src/app/dashboard/account/page.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
"use client"
|
||||
|
||||
import { useSession } from "next-auth/react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
import { ImageIcon, Loader2 } from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "~/components/ui/form"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import { PageContent } from "~/components/layout/page-content"
|
||||
import { PageHeader } from "~/components/layout/page-header"
|
||||
import { api } from "~/trpc/react"
|
||||
import { toast } from "sonner"
|
||||
import { uploadFile } from "~/lib/upload"
|
||||
import { ImageCropModal } from "~/components/ui/image-crop-modal"
|
||||
|
||||
const accountFormSchema = z.object({
|
||||
firstName: z.string().min(1, "First name is required"),
|
||||
lastName: z.string().min(1, "Last name is required"),
|
||||
email: z.string().email("Invalid email address").optional(),
|
||||
image: z.string().optional(),
|
||||
})
|
||||
|
||||
type AccountFormValues = z.infer<typeof accountFormSchema>
|
||||
|
||||
export default function AccountPage() {
|
||||
const { data: session, update: updateSession } = useSession()
|
||||
const [cropFile, setCropFile] = useState<File | null>(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
// Debug full session object
|
||||
useEffect(() => {
|
||||
console.log("[Debug] Full session object:", JSON.stringify(session, null, 2));
|
||||
}, [session]);
|
||||
|
||||
const form = useForm<AccountFormValues>({
|
||||
resolver: zodResolver(accountFormSchema),
|
||||
defaultValues: {
|
||||
firstName: session?.user?.name?.split(" ")[0] ?? "",
|
||||
lastName: session?.user?.name?.split(" ")[1] ?? "",
|
||||
email: session?.user?.email ?? "",
|
||||
image: session?.user?.image ?? undefined,
|
||||
}
|
||||
});
|
||||
|
||||
const updateUser = api.user.update.useMutation();
|
||||
|
||||
const onSubmit = async (data: AccountFormValues) => {
|
||||
try {
|
||||
console.log("[1] Starting update with form data:", data);
|
||||
|
||||
// 1. Update database
|
||||
const result = await updateUser.mutateAsync({
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
image: data.image ?? null,
|
||||
});
|
||||
console.log("[2] Database updated:", result);
|
||||
|
||||
// 2. Show success message
|
||||
toast.success("Profile updated");
|
||||
console.log("[3] Showing success toast");
|
||||
|
||||
// 3. Force a hard reload of the page
|
||||
console.log("[4] Forcing page reload");
|
||||
window.location.reload();
|
||||
|
||||
} catch (error) {
|
||||
console.error("[X] Update failed:", error);
|
||||
toast.error("Failed to update profile");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCrop = async (blob: Blob) => {
|
||||
try {
|
||||
setIsUploading(true);
|
||||
|
||||
const file = new File([blob], cropFile?.name ?? "avatar.jpg", {
|
||||
type: cropFile?.type ?? "image/jpeg",
|
||||
});
|
||||
|
||||
const imageUrl = await uploadFile(file);
|
||||
|
||||
setPreviewImage(imageUrl);
|
||||
form.setValue("image", imageUrl);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error uploading file:", error);
|
||||
toast.error("Error uploading image");
|
||||
setPreviewImage(null);
|
||||
form.setValue("image", undefined);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setCropFile(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Account Settings"
|
||||
description="Manage your profile information"
|
||||
/>
|
||||
<PageContent>
|
||||
<div className="grid gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profile</CardTitle>
|
||||
<CardDescription>
|
||||
Update your profile picture and information
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
<div className="flex flex-col gap-8 sm:flex-row">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="image"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start justify-start">
|
||||
<FormLabel className="group flex size-32 cursor-pointer items-center justify-center overflow-hidden rounded-full border bg-muted transition-colors hover:bg-muted/80">
|
||||
{previewImage ? (
|
||||
<div className="relative size-full overflow-hidden rounded-full">
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/60 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<p className="text-xs font-medium text-white">Change Image</p>
|
||||
</div>
|
||||
<Image
|
||||
src={previewImage}
|
||||
alt="Avatar"
|
||||
fill
|
||||
sizes="128px"
|
||||
className="object-cover"
|
||||
priority
|
||||
onError={(e) => {
|
||||
console.error("Error loading image:", previewImage);
|
||||
e.currentTarget.style.display = "none";
|
||||
setPreviewImage(null);
|
||||
form.setValue("image", undefined);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ImageIcon className="size-8 text-muted-foreground transition-colors group-hover:text-muted-foreground/80" />
|
||||
)}
|
||||
<FormControl>
|
||||
<Input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setCropFile(file);
|
||||
}
|
||||
}}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Click to upload a new profile picture
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="firstName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>First Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter your first name"
|
||||
{...field}
|
||||
disabled={updateUser.isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="lastName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Last Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter your last name"
|
||||
{...field}
|
||||
disabled={updateUser.isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
{...field}
|
||||
disabled
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Email cannot be changed
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={updateUser.isPending || isUploading}
|
||||
>
|
||||
{(updateUser.isPending || isUploading) && (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageContent>
|
||||
{cropFile && (
|
||||
<ImageCropModal
|
||||
file={cropFile}
|
||||
aspect={1}
|
||||
onCrop={handleCrop}
|
||||
onCancel={() => setCropFile(null)}
|
||||
className="sm:max-w-md"
|
||||
cropBoxClassName="rounded-full border-2 border-primary shadow-2xl"
|
||||
overlayClassName="bg-background/80 backdrop-blur-sm"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useActiveStudy } from "~/context/active-study";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
|
||||
export default function StudyLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { id } = useParams();
|
||||
const { studies, activeStudy, setActiveStudy, isLoading } = useActiveStudy();
|
||||
|
||||
useEffect(() => {
|
||||
if (studies.length > 0 && id) {
|
||||
const study = studies.find(s => s.id === parseInt(id as string));
|
||||
if (study && (!activeStudy || activeStudy.id !== study.id)) {
|
||||
setActiveStudy(study);
|
||||
}
|
||||
}
|
||||
}, [id, studies, activeStudy, setActiveStudy]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="h-6">
|
||||
<Skeleton className="h-4 w-[250px]" />
|
||||
</div>
|
||||
<Skeleton className="h-[400px]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -1,182 +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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { SettingsTab } from "~/components/studies/settings-tab";
|
||||
import { UsersTab } from "~/components/studies/users-tab";
|
||||
import { useEffect } from "react";
|
||||
import { PERMISSIONS } from "~/lib/permissions-client";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Settings2Icon, UsersIcon } from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { getApiUrl } from "~/lib/fetch-utils";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
|
||||
interface Study {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export default function StudySettings() {
|
||||
const [study, setStudy] = useState<Study | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'settings' | 'users'>('settings');
|
||||
const { id } = useParams();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStudy = async () => {
|
||||
try {
|
||||
const response = await fetch(getApiUrl(`/api/studies/${id}`));
|
||||
if (!response.ok) {
|
||||
if (response.status === 403) {
|
||||
router.push('/dashboard/studies');
|
||||
return;
|
||||
}
|
||||
throw new Error("Failed to fetch study");
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
// Check if user has any required permissions
|
||||
const requiredPermissions = [PERMISSIONS.EDIT_STUDY, PERMISSIONS.MANAGE_ROLES];
|
||||
const hasAccess = data.data.permissions.some(p => requiredPermissions.includes(p));
|
||||
|
||||
if (!hasAccess) {
|
||||
router.push('/dashboard/studies');
|
||||
return;
|
||||
}
|
||||
|
||||
setStudy(data.data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching study:", error);
|
||||
setError(error instanceof Error ? error.message : "Failed to load study");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStudy();
|
||||
}, [id, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-8 w-[150px] mb-2" />
|
||||
<Skeleton className="h-4 w-[250px]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
<div className="w-48 flex flex-col gap-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<Card>
|
||||
<CardContent className="py-6">
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-4 w-[200px]" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-2/3" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !study) {
|
||||
return <div>Error: {error}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Settings</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Manage study settings and team members
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
<div className="w-48 flex flex-col gap-2">
|
||||
<Button
|
||||
variant={activeTab === 'settings' ? 'secondary' : 'ghost'}
|
||||
className="justify-start"
|
||||
onClick={() => setActiveTab('settings')}
|
||||
>
|
||||
<Settings2Icon className="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'users' ? 'secondary' : 'ghost'}
|
||||
className="justify-start"
|
||||
onClick={() => setActiveTab('users')}
|
||||
>
|
||||
<UsersIcon className="mr-2 h-4 w-4" />
|
||||
Users
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className={cn(activeTab === 'settings' ? 'block' : 'hidden')}>
|
||||
<SettingsTab study={study} />
|
||||
</div>
|
||||
<div className={cn(activeTab === 'users' ? 'block' : 'hidden')}>
|
||||
<UsersTab studyId={study.id} permissions={study.permissions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,137 +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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
|
||||
export const runtime = "edge";
|
||||
export const contentType = "image/svg+xml";
|
||||
export const size = {
|
||||
width: 32,
|
||||
height: 32,
|
||||
};
|
||||
|
||||
export default function Icon() {
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "transparent",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 8V4H8" />
|
||||
<rect width="16" height="12" x="4" y="8" rx="2" />
|
||||
<path d="M2 14h2" />
|
||||
<path d="M20 14h2" />
|
||||
<path d="M15 13v2" />
|
||||
<path d="M9 13v2" />
|
||||
</svg>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
...size,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { useUser } from "@clerk/nextjs";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Logo } from "~/components/logo";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
|
||||
interface InvitationAcceptContentProps {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export function InvitationAcceptContent({ token }: InvitationAcceptContentProps) {
|
||||
const { isLoaded, isSignedIn } = useUser();
|
||||
const router = useRouter();
|
||||
const [isAccepting, setIsAccepting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleAcceptInvitation = async () => {
|
||||
setIsAccepting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/invitations/accept/${token}`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || "Failed to accept invitation");
|
||||
}
|
||||
|
||||
router.push("/dashboard");
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : "Failed to accept invitation");
|
||||
setIsAccepting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isLoaded) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50/50">
|
||||
<div className="w-full max-w-md px-4 py-8">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="mb-6">
|
||||
<Logo className="h-10" />
|
||||
</div>
|
||||
<p className="text-gray-500 text-center">
|
||||
A platform for managing human-robot interaction studies
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>Research Study Invitation</CardTitle>
|
||||
<CardDescription>
|
||||
You've been invited to collaborate on a research study.
|
||||
{!isSignedIn && " Please sign in or create an account to continue."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error && (
|
||||
<div className="mb-4 p-4 text-sm text-red-800 bg-red-100 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSignedIn ? (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button className="w-full" disabled={isAccepting}>
|
||||
Accept Invitation
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Accept Research Study Invitation</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to accept this invitation? You will be added as a collaborator to the research study.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleAcceptInvitation}
|
||||
disabled={isAccepting}
|
||||
>
|
||||
{isAccepting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Accepting...
|
||||
</>
|
||||
) : (
|
||||
"Accept"
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
variant="default"
|
||||
className="w-full"
|
||||
onClick={() => router.push(`/sign-in?redirect_url=${encodeURIComponent(`/invite/accept/${token}`)}`)}
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => router.push(`/sign-up?redirect_url=${encodeURIComponent(`/invite/accept/${token}`)}`)}
|
||||
>
|
||||
Create Account
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Suspense } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { InvitationAcceptContent } from "./invitation-accept-content";
|
||||
|
||||
interface InvitationAcceptPageProps {
|
||||
params: { token: string };
|
||||
}
|
||||
|
||||
export default async function InvitationAcceptPage({ params }: InvitationAcceptPageProps) {
|
||||
const token = await Promise.resolve(params.token);
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<InvitationAcceptContent token={token} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
103
src/app/login/login-form.tsx
Normal 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'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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from "react";
|
||||
import { SignIn } from "@clerk/nextjs";
|
||||
|
||||
export default function SignInPage() {
|
||||
return (
|
||||
<div className="container flex items-center justify-center min-h-screen py-10">
|
||||
<SignIn />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from "react";
|
||||
import { SignUp } from "@clerk/nextjs";
|
||||
|
||||
export default function SignUpPage() {
|
||||
return (
|
||||
<div className="container flex items-center justify-center min-h-screen py-10">
|
||||
<SignUp />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
src/app/studies/[id]/page.tsx
Normal 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
@@ -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)
|
||||
}
|
||||
149
src/components/auth/study-switcher.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
src/components/auth/user-avatar.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { User } from "next-auth";
|
||||
import { Avatar, AvatarFallback } from "~/components/ui/avatar";
|
||||
import Image from "next/image";
|
||||
|
||||
interface UserAvatarProps {
|
||||
user: Pick<User, "name" | "image">;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function UserAvatar({ user, className }: UserAvatarProps) {
|
||||
return (
|
||||
<Avatar className={className}>
|
||||
{user.image ? (
|
||||
<div className="relative size-full">
|
||||
<Image
|
||||
alt={user.name ?? "Avatar"}
|
||||
src={user.image}
|
||||
fill
|
||||
sizes="32px"
|
||||
className="rounded-full object-cover"
|
||||
onError={(e) => {
|
||||
console.error("Error loading avatar image:", user.image);
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<AvatarFallback>
|
||||
{user.name?.charAt(0).toUpperCase() ?? "?"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
60
src/components/db-check.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
src/components/experiment-builder.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
25
src/components/layout/page-content.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
src/components/layout/page-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
src/components/layout/page-transition.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
92
src/components/navigation/app-sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
64
src/components/navigation/breadcrumb-nav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
src/components/navigation/header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
73
src/components/navigation/nav-main.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
89
src/components/navigation/nav-projects.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
89
src/components/navigation/nav-studies.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
152
src/components/navigation/nav-user.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
11
src/components/providers.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { SessionProvider } from "next-auth/react"
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SessionProvider>
|
||||
{children}
|
||||
</SessionProvider>
|
||||
)
|
||||
}
|
||||
89
src/components/providers/study-provider.tsx
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||