Clean codebase- start from scratch
43
.cursorrules
Normal file
@@ -0,0 +1,43 @@
|
||||
You are an expert in TypeScript, Clerk, Node.js, Drizzle ORM, Next.js App Router, React, Shadcn UI, Radix UI and Tailwind.
|
||||
|
||||
Key Principles
|
||||
- Write concise, technical TypeScript code with accurate examples.
|
||||
- Use functional and declarative programming patterns; avoid classes.
|
||||
- Prefer iteration and modularization over code duplication.
|
||||
- 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.
|
||||
|
||||
Naming Conventions
|
||||
- Use lowercase with dashes for directories (e.g., components/auth-wizard).
|
||||
- Favor named exports for components.
|
||||
|
||||
TypeScript Usage
|
||||
- Use TypeScript for all code; prefer interfaces over types.
|
||||
- Avoid enums; use maps instead.
|
||||
- Use functional components with TypeScript interfaces.
|
||||
|
||||
Syntax and Formatting
|
||||
- Use the "function" keyword for pure functions.
|
||||
- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements.
|
||||
- Use declarative JSX.
|
||||
|
||||
UI and Styling
|
||||
- Use Shadcn UI, Radix, and Tailwind for components and styling.
|
||||
- Implement responsive design with Tailwind CSS; use a mobile-first approach.
|
||||
|
||||
Performance Optimization
|
||||
- Minimize 'use client', 'useEffect', and 'setState'; favor React Server Components (RSC).
|
||||
- Wrap client components in Suspense with fallback.
|
||||
- Use dynamic loading for non-critical components.
|
||||
- Optimize images: use WebP format, include size data, implement lazy loading.
|
||||
|
||||
Key Conventions
|
||||
- Use 'nuqs' for URL search parameter state management.
|
||||
- Optimize Web Vitals (LCP, CLS, FID).
|
||||
- Limit 'use client':
|
||||
- Favor server components and Next.js SSR.
|
||||
- Use only for Web API access in small components.
|
||||
- Avoid for data fetching or state management.
|
||||
|
||||
19
.env
@@ -1,16 +1,3 @@
|
||||
# When adding additional environment variables, the schema in "/src/env.js"
|
||||
# should be updated accordingly.
|
||||
|
||||
# Drizzle
|
||||
DATABASE_URL="postgresql://postgres:jusxah-jufrew-niwjY5@db:5432/hristudio"
|
||||
|
||||
# Clerk
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_cmVmaW5lZC1kcnVtLTIzLmNsZXJrLmFjY291bnRzLmRldiQ
|
||||
CLERK_SECRET_KEY=sk_test_3qESERGxZqHpROHzFe7nYxjfqfVhpHWS1UVDQt86v8
|
||||
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
|
||||
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
|
||||
|
||||
# Database
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=jusxah-jufrew-niwjY5
|
||||
POSTGRES_DB=hristudio
|
||||
POSTGRES_URL="postgres://default:UhgYZQV3Tk5x@ep-green-bar-a4foofof-pooler.us-east-1.aws.neon.tech/verceldb?sslmode=require"
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_ZmFjdHVhbC1saWdlci0xMi5jbGVyay5hY2NvdW50cy5kZXYk
|
||||
CLERK_SECRET_KEY=sk_test_67aglhtMoQwtdV5sRzkpCDE4F8S2nWyyGVW5XQAJ6o
|
||||
17
.env.example
@@ -1,17 +0,0 @@
|
||||
# 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`.
|
||||
|
||||
# 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.
|
||||
|
||||
# When adding additional environment variables, the schema in "/src/env.js"
|
||||
# should be updated accordingly.
|
||||
|
||||
# Drizzle
|
||||
DATABASE_URL="postgresql://postgres:password@localhost:5432/hristudio"
|
||||
|
||||
# Example:
|
||||
# SERVERVAR="foo"
|
||||
# NEXT_PUBLIC_CLIENTVAR="bar"
|
||||
@@ -1,61 +0,0 @@
|
||||
/** @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;
|
||||
3
.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||
}
|
||||
41
.gitignore
vendored
@@ -1,12 +1,14 @@
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/nextjs,react
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=nextjs,react
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
### NextJS ###
|
||||
# dependencies
|
||||
/node_modules
|
||||
.pnpm-store/
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
@@ -26,10 +28,9 @@
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
# env files (can opt-in for commiting if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
@@ -37,27 +38,3 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
### react ###
|
||||
.DS_*
|
||||
*.log
|
||||
logs
|
||||
**/*.backup.*
|
||||
**/*.back.*
|
||||
|
||||
node_modules
|
||||
bower_components
|
||||
|
||||
*.sublime*
|
||||
|
||||
psd
|
||||
thumb
|
||||
sketch
|
||||
|
||||
pnpm-lock.yaml
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/nextjs,react
|
||||
pnpm-lock.yaml
|
||||
|
||||
/content
|
||||
/test_data
|
||||
|
||||
31
Dockerfile
@@ -1,31 +0,0 @@
|
||||
# Use the Node.js 18 Alpine Linux image as the base image
|
||||
FROM node:22-alpine
|
||||
|
||||
# Install GraphicsMagick
|
||||
RUN apk add --no-cache graphicsmagick ghostscript
|
||||
|
||||
# Set the working directory inside the container to /app
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json and package-lock.json files into the working directory
|
||||
COPY package*.json ./
|
||||
|
||||
# Install the dependencies specified in package.json
|
||||
RUN npm install -g pnpm
|
||||
RUN pnpm install
|
||||
|
||||
# Copy all the files from the local directory to the working directory in the container
|
||||
COPY . .
|
||||
|
||||
# # Clear previous build artifacts
|
||||
# RUN rm -rf .next
|
||||
|
||||
# # Build the application
|
||||
# RUN pnpm build
|
||||
|
||||
# # Ensure correct permissions
|
||||
# RUN chown -R node:node .
|
||||
# USER node
|
||||
|
||||
# Run the application in development mode
|
||||
CMD ["pnpm", "run", "dev"]
|
||||
90
README.md
@@ -1,80 +1,36 @@
|
||||
# HRIStudio
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
HRIStudio is a web-based platform designed to streamline the design, execution, and analysis of Wizard-of-Oz (WoZ) experiments in Human-Robot Interaction (HRI) studies. It offers an intuitive interface for experiment creation, real-time control and monitoring during experimental runs, and comprehensive data logging and playback tools for analysis and reproducibility.
|
||||
## Getting Started
|
||||
|
||||
## Features
|
||||
First, run the development server:
|
||||
|
||||
- User-friendly graphical interface for designing WoZ experiments
|
||||
- Visual programming environment with drag-and-drop functionality
|
||||
- Real-time control and observation capabilities during live experiment sessions
|
||||
- Comprehensive data logging and playback tools
|
||||
- Integration with Robot Operating System (ROS) for various robotic platforms
|
||||
- Collaborative workflow support with multiple user accounts and data sharing
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
## System Requirements
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
- Node.js (version X.X.X or higher)
|
||||
- npm (version X.X.X or higher)
|
||||
- ROS (Robot Operating System)
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
## Installation
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
1. Clone the repository:
|
||||
```
|
||||
git clone https://github.com/your-username/hristudio.git
|
||||
```
|
||||
## Learn More
|
||||
|
||||
2. Navigate to the project directory:
|
||||
```
|
||||
cd hristudio
|
||||
```
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
3. Install dependencies:
|
||||
```
|
||||
npm install
|
||||
```
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
4. Set up environment variables:
|
||||
Create a `.env.local` file in the root directory and add the necessary environment variables (e.g., database connection string, API keys).
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
5. Run the development server:
|
||||
```
|
||||
npm run dev
|
||||
```
|
||||
## Deploy on Vercel
|
||||
|
||||
6. Open [http://localhost:3000](http://localhost:3000) in your browser to see the application.
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `pages/`: Contains the Next.js pages
|
||||
- `components/`: Reusable React components
|
||||
- `public/`: Static assets
|
||||
- `styles/`: CSS styles
|
||||
- `lib/`: Utility functions and modules
|
||||
- `api/`: API routes
|
||||
- `ros/`: ROS interface and related components
|
||||
|
||||
## Usage
|
||||
|
||||
1. Create a new study in the Dashboard view
|
||||
2. Design your experiment using the visual programming interface in the Design view
|
||||
3. Execute the experiment using the Execute view
|
||||
4. Analyze results and playback recorded data in the Playback view
|
||||
|
||||
For detailed usage instructions, please refer to the [User Guide](link-to-user-guide).
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions to HRIStudio! Please read our [Contributing Guidelines](link-to-contributing-guidelines) for more information on how to get started.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the [MIT License](link-to-license-file).
|
||||
|
||||
## Contact
|
||||
|
||||
For questions or support, please contact [your-email@example.com](mailto:your-email@example.com).
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
This project is being developed by Sean O'Connor and L. Felipe Perrone at Bucknell University. We would like to thank the robotics and HRI research community for their valuable insights and contributions.
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- '3000:3000' # Node.js
|
||||
# - '4983:4983' # Drizzle Studio
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
command: ["sh", "-c", "pnpm db:push && pnpm run dev"]
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
db:
|
||||
image: postgres
|
||||
restart: always
|
||||
# ports:
|
||||
# - 5432:5432 # DEBUG
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
|
||||
adminer:
|
||||
image: adminer
|
||||
restart: always
|
||||
ports:
|
||||
- 8080:8080
|
||||
|
||||
volumes:
|
||||
postgres:
|
||||
@@ -1,12 +1,11 @@
|
||||
import { type Config } from "drizzle-kit";
|
||||
import 'dotenv/config';
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
import { env } from "~/env";
|
||||
|
||||
export default {
|
||||
schema: "./src/server/db/schema.ts",
|
||||
dialect: "postgresql",
|
||||
export default defineConfig({
|
||||
out: './drizzle',
|
||||
schema: './src/db/schema.ts',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: env.DATABASE_URL,
|
||||
url: process.env.POSTGRES_URL!,
|
||||
},
|
||||
// tablesFilter: ["hristudio_*"],
|
||||
} satisfies Config;
|
||||
});
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
|
||||
* for Docker builds.
|
||||
*/
|
||||
await import("./src/env.js");
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: '2mb',
|
||||
},
|
||||
},
|
||||
webpack: (config) => {
|
||||
config.externals.push({
|
||||
"utf-8-validate": "commonjs utf-8-validate",
|
||||
bufferutil: "commonjs bufferutil",
|
||||
});
|
||||
return config;
|
||||
},
|
||||
// Add this section to disable linting during build
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
// Add this section to disable type checking during build
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
// Add this section
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/content/:path*',
|
||||
destination: '/api/content/:path*',
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
79
package.json
@@ -2,68 +2,43 @@
|
||||
"name": "hristudio",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"dev": "next dev",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"start": "next start"
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clerk/nextjs": "^5.6.0",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@clerk/nextjs": "^6.1.0",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-icons": "^1.3.1",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.1",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.2.1",
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"@t3-oss/env-nextjs": "^0.10.1",
|
||||
"@vercel/postgres": "^0.10.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cn": "^0.1.1",
|
||||
"drizzle-orm": "^0.33.0",
|
||||
"geist": "^1.3.1",
|
||||
"lucide-react": "^0.441.0",
|
||||
"next": "^14.2.13",
|
||||
"next-themes": "^0.3.0",
|
||||
"pdf2pic": "^3.1.3",
|
||||
"postgres": "^3.4.4",
|
||||
"radix-ui": "^1.0.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^5.3.0",
|
||||
"spawn-sync": "^2.0.0",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.23.8"
|
||||
"dotenv": "^16.4.5",
|
||||
"drizzle-orm": "^0.36.0",
|
||||
"lucide-react": "^0.454.0",
|
||||
"next": "15.0.2",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/eslint": "^8.56.12",
|
||||
"@types/node": "^20.16.5",
|
||||
"@types/react": "^18.3.7",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.6.0",
|
||||
"@typescript-eslint/parser": "^8.6.0",
|
||||
"drizzle-kit": "^0.24.2",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-next": "^14.2.12",
|
||||
"eslint-plugin-drizzle": "^0.2.3",
|
||||
"@types/node": "^22.8.6",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"drizzle-kit": "^0.27.0",
|
||||
"eslint": "^9.13.0",
|
||||
"eslint-config-next": "15.0.2",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.6",
|
||||
"tailwindcss": "^3.4.12",
|
||||
"typescript": "^5.6.2"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.37.0"
|
||||
},
|
||||
"packageManager": "pnpm@9.9.0"
|
||||
"tailwindcss": "^3.4.14",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
7
perms.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
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
|
||||
|
||||
5469
pnpm-lock.yaml
generated
Normal file
@@ -1,7 +0,0 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
8
postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,6 +0,0 @@
|
||||
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
|
||||
const config = {
|
||||
plugins: ["prettier-plugin-tailwindcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
Before Width: | Height: | Size: 3.1 KiB |
1
public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 2.0 MiB |
1
public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -1,46 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { path: string[] } }
|
||||
) {
|
||||
const { userId } = auth();
|
||||
if (!userId) {
|
||||
return new NextResponse('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
// Construct the file path relative to the project root
|
||||
const filePath = path.join(process.cwd(), 'content', ...params.path);
|
||||
|
||||
console.log('Attempting to read file:', filePath); // Add this log
|
||||
|
||||
try {
|
||||
const file = await fs.readFile(filePath);
|
||||
const response = new NextResponse(file);
|
||||
|
||||
// Determine content type based on file extension
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
switch (ext) {
|
||||
case '.pdf':
|
||||
response.headers.set('Content-Type', 'application/pdf');
|
||||
break;
|
||||
case '.png':
|
||||
response.headers.set('Content-Type', 'image/png');
|
||||
break;
|
||||
case '.jpg':
|
||||
case '.jpeg':
|
||||
response.headers.set('Content-Type', 'image/jpeg');
|
||||
break;
|
||||
default:
|
||||
response.headers.set('Content-Type', 'application/octet-stream');
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Error reading file:', error);
|
||||
return new NextResponse('File not found', { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "~/server/db";
|
||||
import { informedConsentForms, contents } from "~/server/db/schema";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const { userId } = auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) {
|
||||
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// First, get the content associated with this form
|
||||
const [form] = await db
|
||||
.select({
|
||||
contentId: informedConsentForms.contentId,
|
||||
location: contents.location,
|
||||
previewLocation: contents.previewLocation,
|
||||
})
|
||||
.from(informedConsentForms)
|
||||
.innerJoin(contents, eq(informedConsentForms.contentId, contents.id))
|
||||
.where(eq(informedConsentForms.id, id));
|
||||
|
||||
if (!form) {
|
||||
return NextResponse.json({ error: 'Form not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Delete the file and preview from the file system
|
||||
const fullPath = path.join(process.cwd(), form.location ?? '');
|
||||
const previewPath = path.join(process.cwd(), form.previewLocation ?? '');
|
||||
try {
|
||||
await fs.access(fullPath);
|
||||
await fs.unlink(fullPath);
|
||||
} catch (error) {
|
||||
console.warn(`File not found or couldn't be deleted: ${fullPath}`);
|
||||
}
|
||||
try {
|
||||
await fs.access(previewPath);
|
||||
await fs.unlink(previewPath);
|
||||
} catch (error) {
|
||||
console.warn(`Preview file not found or couldn't be deleted: ${previewPath}`);
|
||||
}
|
||||
|
||||
// Delete the form and content from the database
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(informedConsentForms).where(eq(informedConsentForms.id, id));
|
||||
await tx.delete(contents).where(eq(contents.id, form.contentId));
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: "Form and preview deleted successfully" });
|
||||
} catch (error) {
|
||||
console.error('Error deleting form:', error);
|
||||
return NextResponse.json({ error: 'Failed to delete form' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "~/server/db";
|
||||
import { contents, informedConsentForms, contentTypes } from "~/server/db/schema";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { saveFile } from "~/lib/fileStorage";
|
||||
import fs from 'fs/promises';
|
||||
import { studies, participants } from "~/server/db/schema";
|
||||
import { anonymizeParticipants } from "~/lib/permissions"; // Import the anonymize function
|
||||
|
||||
// Function to generate a random string
|
||||
const generateRandomString = (length: number) => {
|
||||
const characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += characters.charAt(Math.floor(Math.random() * characters.length));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { userId } = auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const forms = await db.select({
|
||||
id: informedConsentForms.id,
|
||||
title: contents.title,
|
||||
location: contents.location,
|
||||
previewLocation: contents.previewLocation,
|
||||
studyId: informedConsentForms.studyId,
|
||||
studyTitle: studies.title,
|
||||
participantId: informedConsentForms.participantId,
|
||||
participantName: participants.name,
|
||||
contentId: informedConsentForms.contentId,
|
||||
}).from(informedConsentForms)
|
||||
.innerJoin(contents, eq(informedConsentForms.contentId, contents.id))
|
||||
.innerJoin(studies, eq(informedConsentForms.studyId, studies.id))
|
||||
.innerJoin(participants, eq(informedConsentForms.participantId, participants.id));
|
||||
|
||||
// Anonymize participant names
|
||||
const anonymizedForms = forms.map(form => ({
|
||||
...form,
|
||||
participantName: `Participant ${form.participantId}` // Anonymizing logic
|
||||
}));
|
||||
|
||||
return NextResponse.json(anonymizedForms);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { userId } = auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File;
|
||||
const title = formData.get('title') as string;
|
||||
const studyId = formData.get('studyId') as string;
|
||||
const participantId = formData.get('participantId') as string;
|
||||
|
||||
if (!file || !title || !studyId || !participantId) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const [formContentType] = await db
|
||||
.select()
|
||||
.from(contentTypes)
|
||||
.where(eq(contentTypes.name, "Informed Consent Form"));
|
||||
|
||||
const [previewContentType] = await db
|
||||
.select()
|
||||
.from(contentTypes)
|
||||
.where(eq(contentTypes.name, "Preview Image"));
|
||||
|
||||
if (!formContentType || !previewContentType) {
|
||||
return NextResponse.json({ error: 'Content type not found' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Generate a random filename with the same extension
|
||||
const fileExtension = file.name.split('.').pop(); // Get the file extension
|
||||
const randomFileName = `${generateRandomString(12)}.${fileExtension}`; // Generate random filename with 12 characters
|
||||
const { pdfPath, previewPath } = await saveFile(file, `${formContentType.id}/${randomFileName}`, previewContentType.id);
|
||||
|
||||
const [content] = await db
|
||||
.insert(contents)
|
||||
.values({
|
||||
contentTypeId: formContentType.id,
|
||||
uploader: userId,
|
||||
location: pdfPath,
|
||||
previewLocation: previewPath,
|
||||
title: title,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!content) {
|
||||
throw new Error("Content not found");
|
||||
}
|
||||
|
||||
const [form] = await db
|
||||
.insert(informedConsentForms)
|
||||
.values({
|
||||
studyId: parseInt(studyId),
|
||||
participantId: parseInt(participantId),
|
||||
contentId: content.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return NextResponse.json(form);
|
||||
} catch (error) {
|
||||
console.error('Error uploading form:', error);
|
||||
return NextResponse.json({ error: 'Failed to upload form' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,32 @@
|
||||
import { db } from "~/server/db";
|
||||
import { participants } from "~/server/db/schema";
|
||||
import { NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { db } from "~/db";
|
||||
import { participants } from "~/db/schema";
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
console.log('DELETE route hit, params:', params);
|
||||
const id = parseInt(params.id);
|
||||
|
||||
if (isNaN(id)) {
|
||||
console.log('Invalid ID:', id);
|
||||
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
|
||||
}
|
||||
export async function DELETE(request: Request, { params }: { params: { id: string } }) {
|
||||
const { userId } = await auth();
|
||||
|
||||
try {
|
||||
console.log('Attempting to delete participant with ID:', id);
|
||||
const deletedParticipant = await db.delete(participants)
|
||||
.where(eq(participants.id, id))
|
||||
.returning();
|
||||
|
||||
console.log('Deleted participant:', deletedParticipant);
|
||||
|
||||
if (deletedParticipant.length === 0) {
|
||||
console.log('Participant not found');
|
||||
return NextResponse.json({ error: 'Participant not found' }, { status: 404 });
|
||||
if (!userId) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
console.log('Participant deleted successfully');
|
||||
return NextResponse.json({ message: "Participant deleted successfully" });
|
||||
} catch (error) {
|
||||
console.error('Error deleting participant:', error);
|
||||
return NextResponse.json({ error: 'Failed to delete participant', details: String(error) }, { status: 500 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const participantId = parseInt(id);
|
||||
|
||||
try {
|
||||
const result = await db
|
||||
.delete(participants)
|
||||
.where(eq(participants.id, participantId))
|
||||
.execute();
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return new NextResponse("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
return new NextResponse(null, { status: 204 }); // No content for successful deletion
|
||||
} catch (error) {
|
||||
console.error("Error deleting participant:", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,52 @@
|
||||
import { db } from "~/server/db";
|
||||
import { participants, trialParticipants, trials } from "~/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { auth } from "@clerk/nextjs/server"; // Import auth to get userId
|
||||
import { anonymizeParticipants } from "~/lib/permissions"; // Import the anonymize function
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { db } from "~/db";
|
||||
import { participants } from "~/db/schema";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { userId } = auth(); // Get the userId from auth
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const studyId = searchParams.get('studyId');
|
||||
|
||||
if (!studyId) {
|
||||
return NextResponse.json({ error: 'Study ID is required' }, { status: 400 });
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const participantsWithLatestTrial = await db
|
||||
.select({
|
||||
id: participants.id,
|
||||
name: participants.name,
|
||||
studyId: participants.studyId,
|
||||
createdAt: participants.createdAt,
|
||||
latestTrialTimestamp: sql<Date | null>`MAX(${trials.createdAt})`.as('latestTrialTimestamp')
|
||||
})
|
||||
.from(participants)
|
||||
.leftJoin(trialParticipants, eq(participants.id, trialParticipants.participantId))
|
||||
.leftJoin(trials, eq(trialParticipants.trialId, trials.id))
|
||||
.where(eq(participants.studyId, parseInt(studyId)))
|
||||
.groupBy(participants.id)
|
||||
.orderBy(sql`COALESCE(MAX(${trials.createdAt}), ${participants.createdAt}) DESC`);
|
||||
const url = new URL(request.url);
|
||||
const studyId = url.searchParams.get("studyId");
|
||||
|
||||
// Anonymize participant names
|
||||
const anonymizedParticipants = anonymizeParticipants(participantsWithLatestTrial, userId);
|
||||
if (!studyId) {
|
||||
return new NextResponse("Study ID is required", { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json(anonymizedParticipants);
|
||||
} catch (error) {
|
||||
console.error('Error in GET /api/participants:', error);
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
const participantList = await db
|
||||
.select()
|
||||
.from(participants)
|
||||
.where(eq(participants.studyId, parseInt(studyId)));
|
||||
|
||||
return NextResponse.json(participantList);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { name, studyId } = await request.json();
|
||||
if (!name || !studyId) {
|
||||
return NextResponse.json({ error: 'Name and Study ID are required' }, { status: 400 });
|
||||
}
|
||||
const newParticipant = await db.insert(participants).values({ name, studyId }).returning();
|
||||
return NextResponse.json(newParticipant[0]);
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const { name, studyId } = await request.json();
|
||||
|
||||
try {
|
||||
const participant = await db
|
||||
.insert(participants)
|
||||
.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 });
|
||||
}
|
||||
}
|
||||
14
src/app/api/permissions/route.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { getUserPermissions } from "~/lib/permissions";
|
||||
|
||||
export async function GET() {
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const permissions = await getUserPermissions(userId);
|
||||
return NextResponse.json(Array.from(permissions));
|
||||
}
|
||||
@@ -1,86 +1,32 @@
|
||||
import { db } from "~/server/db";
|
||||
import { studies } from "~/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { db } from "~/db";
|
||||
import { studyTable } from "~/db/schema";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const { userId } = auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
export async function DELETE(request: Request, { params }: { params: { id: string } }) {
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) {
|
||||
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const study = await db.select().from(studies).where(and(eq(studies.id, id), eq(studies.userId, userId))).limit(1);
|
||||
|
||||
if (study.length === 0) {
|
||||
return NextResponse.json({ error: 'Study not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(study[0]);
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const { userId } = auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) {
|
||||
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { title, description } = await request.json();
|
||||
if (!title) {
|
||||
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const updatedStudy = await db
|
||||
.update(studies)
|
||||
.set({ title, description })
|
||||
.where(and(eq(studies.id, id), eq(studies.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (updatedStudy.length === 0) {
|
||||
return NextResponse.json({ error: 'Study not found or unauthorized' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(updatedStudy[0]);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const { userId } = auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) {
|
||||
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const deletedStudy = await db
|
||||
.delete(studies)
|
||||
.where(and(eq(studies.id, id), eq(studies.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (deletedStudy.length === 0) {
|
||||
return NextResponse.json({ error: 'Study not found or unauthorized' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: "Study deleted successfully" });
|
||||
}
|
||||
const { id } = await params;
|
||||
const studyId = parseInt(id);
|
||||
|
||||
try {
|
||||
const result = await db
|
||||
.delete(studyTable)
|
||||
.where(eq(studyTable.id, studyId))
|
||||
.execute();
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return new NextResponse("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
console.error("Error deleting study:", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,41 @@
|
||||
import { db } from "~/server/db";
|
||||
import { studies } from "~/server/db/schema";
|
||||
import { NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { db } from "~/db";
|
||||
import { studyTable } from "~/db/schema";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { userId } = auth();
|
||||
export async function GET() {
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const allStudies = await db.select().from(studies).where(eq(studies.userId, userId));
|
||||
return NextResponse.json(allStudies);
|
||||
const studies = await db
|
||||
.select()
|
||||
.from(studyTable)
|
||||
.where(eq(studyTable.userId, userId));
|
||||
// TODO: Open up to multiple users
|
||||
return NextResponse.json(studies);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { userId } = auth();
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const { title, description } = await request.json();
|
||||
if (!title) {
|
||||
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const newStudy = await db.insert(studies).values({ title, description, userId }).returning();
|
||||
return NextResponse.json(newStudy[0]);
|
||||
}
|
||||
const study = await db
|
||||
.insert(studyTable)
|
||||
.values({
|
||||
title,
|
||||
description,
|
||||
userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return NextResponse.json(study[0]);
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { db } from "~/server/db";
|
||||
import { trials, trialParticipants } from "~/server/db/schema";
|
||||
import { NextResponse } from "next/server";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
|
||||
export async function GET() {
|
||||
const allTrials = await db
|
||||
.select({
|
||||
id: trials.id,
|
||||
title: trials.title,
|
||||
participantIds: sql`ARRAY_AGG(${trialParticipants.participantId})`.as('participantIds'),
|
||||
})
|
||||
.from(trials)
|
||||
.leftJoin(trialParticipants, eq(trials.id, trialParticipants.trialId))
|
||||
.groupBy(trials.id);
|
||||
|
||||
return NextResponse.json(allTrials);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { title, participantIds } = await request.json();
|
||||
|
||||
if (!title || !Array.isArray(participantIds) || participantIds.some(id => typeof id !== 'number')) {
|
||||
return NextResponse.json({ error: 'Title and valid Participant IDs are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Insert the new trial into the trials table
|
||||
const newTrial = await db.insert(trials).values({ title }).returning();
|
||||
// Check if newTrial is defined and has at least one element
|
||||
if (!newTrial || newTrial.length === 0) {
|
||||
throw new Error('Failed to create a new trial');
|
||||
}
|
||||
// Insert the participant associations into the trial_participants table
|
||||
const trialId = newTrial[0]?.id; // Use optional chaining to safely get the ID of the newly created trial
|
||||
if (trialId === undefined) {
|
||||
throw new Error('Trial ID is undefined');
|
||||
}
|
||||
const trialParticipantEntries = participantIds.map(participantId => ({
|
||||
trialId,
|
||||
participantId,
|
||||
}));
|
||||
|
||||
await db.insert(trialParticipants).values(trialParticipantEntries);
|
||||
|
||||
return NextResponse.json(newTrial[0]);
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
const { id } = await request.json();
|
||||
await db.delete(trials).where(eq(trials.id, id));
|
||||
return NextResponse.json({ message: "Trial deleted successfully" });
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { db } from "~/server/db";
|
||||
import { users } from "~/server/db/schema";
|
||||
import { NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { email } = await request.json();
|
||||
|
||||
// Check if email is provided
|
||||
if (!email) {
|
||||
return NextResponse.json({ error: "Email is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if the user already exists
|
||||
const existingUser = await db.select().from(users).where(eq(users.email, email)).limit(1);
|
||||
if (existingUser) {
|
||||
return NextResponse.json({ error: "User already exists" }, { status: 409 });
|
||||
}
|
||||
|
||||
// Insert the new user into the database
|
||||
const newUser = await db.insert(users).values({ email }).returning();
|
||||
return NextResponse.json(newUser[0]);
|
||||
} catch (error) {
|
||||
console.error("Error creating user:", error);
|
||||
return NextResponse.json({ error: "Failed to create user" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Layout from "~/components/layout";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "~/components/ui/card";
|
||||
import { useStudyContext } from '~/context/StudyContext';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Avatar, AvatarFallback } from "~/components/ui/avatar";
|
||||
|
||||
interface ParticipantWithTrial {
|
||||
id: number;
|
||||
name: string;
|
||||
latestTrialTimestamp: string | null;
|
||||
createdAt: string; // Add createdAt to the interface
|
||||
}
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const { selectedStudy } = useStudyContext();
|
||||
const [participants, setParticipants] = useState<ParticipantWithTrial[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchParticipants = async () => {
|
||||
if (selectedStudy) {
|
||||
const response = await fetch(`/api/participants?studyId=${selectedStudy.id}`);
|
||||
const data = await response.json();
|
||||
setParticipants(data);
|
||||
}
|
||||
};
|
||||
|
||||
fetchParticipants();
|
||||
}, [selectedStudy]);
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'No trials yet';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout pageTitle="Dashboard">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card className="card-level-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Platform Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Add content for Platform Information */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="card-level-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Participants</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{participants.slice(0, 4).map(participant => (
|
||||
<Card key={participant.id} className="card-level-2 p-3 px-4 flex items-center">
|
||||
<Avatar className="mr-4">
|
||||
<AvatarFallback>{participant.name.split(' ').map(n => n[0]).join('')}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">{participant.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Last trial: {formatDate(participant.latestTrialTimestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{participants.length > 4 && (
|
||||
<div className="mt-4 text-center">
|
||||
<Link href="/participants">
|
||||
<Button variant="outline" className="text-blue-600 hover:underline">
|
||||
View More Participants
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Project Members</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Add content for Project Members */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Completed Trials</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Add content for Completed Trials */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
24
src/app/dashboard/layout.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Sidebar } from "~/components/sidebar";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { StudyProvider } from "~/context/StudyContext";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<StudyProvider>
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className={cn(
|
||||
"flex-1 overflow-y-auto",
|
||||
"lg:pt-8 p-8",
|
||||
"pt-[calc(3.5rem+2rem)]"
|
||||
)}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</StudyProvider>
|
||||
);
|
||||
}
|
||||
8
src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
export default function Dashboard() {
|
||||
return (
|
||||
<div>
|
||||
<p>Dashboard</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
174
src/app/dashboard/participants/page.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
} from "~/components/ui/select";
|
||||
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStudies();
|
||||
}, []);
|
||||
|
||||
const fetchStudies = async () => {
|
||||
try {
|
||||
const response = await fetch('/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 {
|
||||
const response = await fetch(`/api/participants?studyId=${studyId}`);
|
||||
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(`/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(`/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">
|
||||
<h1 className="text-3xl font-bold mb-4">Manage Participants</h1>
|
||||
<div className="mb-4">
|
||||
<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>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Add New Participant</CardTitle>
|
||||
</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="mt-4">
|
||||
<h2 className="text-xl font-semibold">Participant List</h2>
|
||||
<ul>
|
||||
{participants.map((participant) => (
|
||||
<li key={participant.id} className="flex justify-between items-center">
|
||||
<span>{participant.name}</span>
|
||||
<Button onClick={() => deleteParticipant(participant.id)} variant="destructive">
|
||||
<Trash2Icon className="w-4 h-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
src/app/dashboard/studies/page.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter
|
||||
} from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { Label } from "~/components/ui/label";
|
||||
|
||||
interface Study {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function Studies() {
|
||||
const [studies, setStudies] = useState<Study[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newStudyTitle, setNewStudyTitle] = useState("");
|
||||
const [newStudyDescription, setNewStudyDescription] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetchStudies();
|
||||
}, []);
|
||||
|
||||
const fetchStudies = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/studies');
|
||||
const data = await response.json();
|
||||
setStudies(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching studies:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createStudy = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const response = await fetch('/api/studies', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: newStudyTitle,
|
||||
description: newStudyDescription,
|
||||
}),
|
||||
});
|
||||
const newStudy = await response.json();
|
||||
setStudies([...studies, newStudy]);
|
||||
setNewStudyTitle("");
|
||||
setNewStudyDescription("");
|
||||
} catch (error) {
|
||||
console.error('Error creating study:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteStudy = async (id: number) => {
|
||||
try {
|
||||
await fetch(`/api/studies/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
setStudies(studies.filter(study => study.id !== id));
|
||||
} catch (error) {
|
||||
console.error('Error deleting study:', 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">Studies</h1>
|
||||
</div>
|
||||
|
||||
<Card className="mb-8">
|
||||
<CardHeader>
|
||||
<CardTitle>Create New Study</CardTitle>
|
||||
<CardDescription>
|
||||
Add a new research study to your collection
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={createStudy} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Study Title</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="title"
|
||||
value={newStudyTitle}
|
||||
onChange={(e) => setNewStudyTitle(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={newStudyDescription}
|
||||
onChange={(e) => setNewStudyDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit">
|
||||
<PlusIcon className="w-4 h-4 mr-2" />
|
||||
Create Study
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{studies.map((study) => (
|
||||
<Card key={study.id}>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle>{study.title}</CardTitle>
|
||||
{study.description && (
|
||||
<CardDescription className="mt-1.5">
|
||||
{study.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="text-destructive" onClick={() => deleteStudy(study.id)}>
|
||||
<Trash2Icon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardFooter className="text-sm text-muted-foreground">
|
||||
Created: {new Date(study.createdAt).toLocaleDateString()}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
@@ -1,22 +0,0 @@
|
||||
import Layout from "~/components/layout";
|
||||
import { FormsGrid } from "~/components/forms/FormsGrid";
|
||||
import { UploadFormButton } from "~/components/forms/UploadFormButton";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "~/components/ui/card";
|
||||
|
||||
export default function FormsPage() {
|
||||
return (
|
||||
<Layout pageTitle="Forms">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex justify-between items-center">
|
||||
<span>Forms</span>
|
||||
<UploadFormButton />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FormsGrid />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,10 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 210 50% 98%;
|
||||
@@ -100,42 +104,3 @@
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 20%, 50%, 80%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
40% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
60% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-bounce {
|
||||
animation: bounce 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.spinner {
|
||||
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 4px solid #3498db;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@@ -1,31 +1,26 @@
|
||||
import { ClerkProvider } from '@clerk/nextjs'
|
||||
import { Inter } from "next/font/google"
|
||||
import { StudyProvider } from '~/context/StudyContext'
|
||||
import { ThemeProvider } from '~/components/ThemeProvider'
|
||||
import "~/styles/globals.css"
|
||||
import {
|
||||
ClerkProvider,
|
||||
SignInButton,
|
||||
SignedIn,
|
||||
SignedOut,
|
||||
UserButton
|
||||
} from '@clerk/nextjs'
|
||||
import './globals.css'
|
||||
import { Button } from '~/components/ui/button';
|
||||
import { Inter } from 'next/font/google';
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
variable: "--font-sans",
|
||||
})
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata = {
|
||||
title: "HRIStudio",
|
||||
description: "Created with create-t3-app",
|
||||
icons: [{ rel: "icon", url: "/favicon.ico" }],
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: React.PropsWithChildren) {
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<ClerkProvider>
|
||||
<html lang="en" className={inter.variable}>
|
||||
<body className="font-sans">
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<StudyProvider>
|
||||
{children}
|
||||
</StudyProvider>
|
||||
</ThemeProvider>
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
</ClerkProvider>
|
||||
|
||||
@@ -1,74 +1,38 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { redirect } from "next/navigation";
|
||||
'use client';
|
||||
|
||||
export default function HomePage() {
|
||||
const { userId } = auth();
|
||||
import { SignedIn, SignedOut, SignInButton, SignOutButton, UserButton, useUser } from "@clerk/nextjs";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
if (userId) {
|
||||
redirect("/dash");
|
||||
export default function Home() {
|
||||
const { user, isLoaded } = useUser(); // Get user information and loading state
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoaded) {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isLoaded]);
|
||||
|
||||
if (loading) {
|
||||
return <div>Loading...</div>; // Show a loading state while fetching user data
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-blue-100 to-white">
|
||||
<div className="container mx-auto px-4 py-16">
|
||||
<header className="text-center mb-16">
|
||||
<h1 className="text-5xl font-bold mb-4 text-blue-800">Welcome to HRIStudio</h1>
|
||||
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||
Empowering Human-Robot Interaction Research and Development
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center mb-16">
|
||||
<div>
|
||||
<h2 className="text-3xl font-semibold mb-4 text-blue-700">About HRIStudio</h2>
|
||||
<p className="text-lg text-gray-700 mb-4">
|
||||
HRIStudio is a cutting-edge platform designed to streamline the process of creating,
|
||||
managing, and analyzing Human-Robot Interaction experiments. Our suite of tools
|
||||
empowers researchers and developers to push the boundaries of HRI research.
|
||||
</p>
|
||||
<p className="text-lg text-gray-700 mb-4">
|
||||
With HRIStudio, you can:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-gray-700 mb-6">
|
||||
<li>Design complex interaction scenarios with ease</li>
|
||||
<li>Collect and analyze data in real-time</li>
|
||||
<li>Collaborate seamlessly with team members</li>
|
||||
<li>Visualize results with advanced reporting tools</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="relative aspect-video w-full">
|
||||
<Image
|
||||
src="/hristudio_laptop.png"
|
||||
alt="HRIStudio Interface on Laptop"
|
||||
fill
|
||||
style={{ objectFit: 'contain' }}
|
||||
// className="rounded-lg shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-semibold mb-4 text-blue-700">Join the HRI Revolution</h2>
|
||||
<p className="text-lg text-gray-700 mb-6">
|
||||
Whether you're a seasoned researcher or just starting in the field of Human-Robot Interaction,
|
||||
HRIStudio provides the tools and support you need to succeed.
|
||||
</p>
|
||||
<div className="space-x-4">
|
||||
<Link href="/sign-in" className="bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 rounded-full transition duration-300">
|
||||
Sign In
|
||||
</Link>
|
||||
<Link href="/sign-up" className="bg-green-600 hover:bg-green-700 text-white font-bold py-3 px-6 rounded-full transition duration-300">
|
||||
Sign Up
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="text-center text-gray-600">
|
||||
<p>© 2024 HRIStudio. All rights reserved.</p>
|
||||
</footer>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-8">
|
||||
<h1 className="text-3xl font-bold mb-4">Welcome to HRIStudio</h1>
|
||||
<SignedOut>
|
||||
<SignInButton>
|
||||
<Button>Sign In</Button>
|
||||
</SignInButton>
|
||||
</SignedOut>
|
||||
<SignedIn>
|
||||
<UserButton />
|
||||
<p className="mt-4">Signed in as: {user?.emailAddresses[0].emailAddress}</p> {/* Display user's email */}
|
||||
<SignOutButton>
|
||||
<Button>Sign Out</Button>
|
||||
</SignOutButton>
|
||||
</SignedIn>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import Layout from "~/components/layout";
|
||||
import { Participants } from "~/components/participant/Participants";
|
||||
|
||||
const ParticipantsPage = () => {
|
||||
return (
|
||||
<Layout pageTitle="Participants">
|
||||
<Participants />
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ParticipantsPage;
|
||||
@@ -1,143 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useSignIn, useSignUp } from "@clerk/nextjs"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { Separator } from "~/components/ui/separator"
|
||||
import Link from "next/link"
|
||||
import { FcGoogle } from "react-icons/fc"
|
||||
import { FaApple } from "react-icons/fa"
|
||||
|
||||
export default function SignInPage() {
|
||||
const { isLoaded, signIn, setActive } = useSignIn();
|
||||
const { signUp } = useSignUp();
|
||||
const [emailAddress, setEmailAddress] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!isLoaded) return;
|
||||
|
||||
try {
|
||||
const result = await signIn.create({
|
||||
identifier: emailAddress,
|
||||
password,
|
||||
});
|
||||
|
||||
if (result.status === "complete") {
|
||||
await setActive({ session: result.createdSessionId });
|
||||
router.push("/dash");
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as { errors?: { message: string }[] };
|
||||
console.error("Error:", error.errors?.[0]?.message ?? "Unknown error");
|
||||
|
||||
// If the error indicates the user does not exist, trigger sign-up
|
||||
if (error.errors?.[0]?.message.includes("not found")) {
|
||||
if (!signUp) {
|
||||
console.error("Sign-up functionality is not available.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const signUpResult = await signUp.create({
|
||||
emailAddress,
|
||||
password,
|
||||
});
|
||||
|
||||
if (signUpResult.status === "complete") {
|
||||
await setActive({ session: signUpResult.createdSessionId });
|
||||
|
||||
// Create a user entry in the database
|
||||
const response = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: emailAddress }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
console.error("Error creating user in database:", errorData.error);
|
||||
return; // Optionally handle the error (e.g., show a message to the user)
|
||||
}
|
||||
|
||||
router.push("/dash");
|
||||
}
|
||||
} catch (signUpErr) {
|
||||
const signUpError = signUpErr as { errors?: { message: string }[] };
|
||||
console.error("Sign-up Error:", signUpError.errors?.[0]?.message ?? "Unknown error");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const signInWith = (strategy: "oauth_google" | "oauth_apple") => {
|
||||
if (!isLoaded) return
|
||||
signIn.authenticateWithRedirect({
|
||||
strategy,
|
||||
redirectUrl: "/sso-callback",
|
||||
redirectUrlComplete: "/dash",
|
||||
}).catch((error) => {
|
||||
console.error("Authentication error:", error); // Handle any potential errors
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-blue-100 to-white flex items-center justify-center">
|
||||
<Card className="w-[350px]">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign in to HRIStudio</CardTitle>
|
||||
<CardDescription>Enter your email and password to sign in</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid w-full items-center gap-4">
|
||||
<Button variant="outline" onClick={() => signInWith("oauth_google")}>
|
||||
<FcGoogle className="mr-2 h-4 w-4" />
|
||||
Sign in with Google
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => signInWith("oauth_apple")}>
|
||||
<FaApple className="mr-2 h-4 w-4" />
|
||||
Sign in with Apple
|
||||
</Button>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid w-full items-center gap-4">
|
||||
<div className="flex flex-col space-y-1.5">
|
||||
<Input
|
||||
id="email"
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
value={emailAddress}
|
||||
onChange={(e) => setEmailAddress(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-1.5">
|
||||
<Input
|
||||
id="password"
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button className="w-full" type="submit">Sign In</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col">
|
||||
<p className="mt-4 text-sm text-center">
|
||||
Don't have an account?{" "}
|
||||
<Link href="/sign-up" className="text-blue-600 hover:underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useSignUp } from "@clerk/nextjs"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { Separator } from "~/components/ui/separator"
|
||||
import Link from "next/link"
|
||||
import { FcGoogle } from "react-icons/fc"
|
||||
import { FaApple } from "react-icons/fa"
|
||||
|
||||
export default function SignUpPage() {
|
||||
const { isLoaded, signUp, setActive } = useSignUp()
|
||||
const [emailAddress, setEmailAddress] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const router = useRouter()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!isLoaded) return
|
||||
|
||||
try {
|
||||
const result = await signUp.create({
|
||||
emailAddress,
|
||||
password,
|
||||
})
|
||||
|
||||
if (result.status === "complete") {
|
||||
await setActive({ session: result.createdSessionId })
|
||||
|
||||
// Create a user entry in the database
|
||||
const response = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: emailAddress }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
console.error("Error creating user in database:", errorData.error)
|
||||
return // Optionally handle the error (e.g., show a message to the user)
|
||||
}
|
||||
|
||||
router.push("/dash")
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as { errors?: { message: string }[] }; // Specify type
|
||||
console.error("Error:", error.errors?.[0]?.message ?? "Unknown error") // Use optional chaining
|
||||
}
|
||||
}
|
||||
|
||||
const signUpWith = (strategy: "oauth_google" | "oauth_apple") => {
|
||||
if (!isLoaded) return
|
||||
signUp.authenticateWithRedirect({
|
||||
strategy,
|
||||
redirectUrl: "/sso-callback",
|
||||
redirectUrlComplete: "/dash",
|
||||
}).catch((error) => {
|
||||
console.error("Authentication error:", error); // Handle any potential errors
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-blue-100 to-white flex items-center justify-center">
|
||||
<Card className="w-[350px]">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign up for HRIStudio</CardTitle>
|
||||
<CardDescription>Create an account to get started</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid w-full items-center gap-4">
|
||||
<Button variant="outline" onClick={() => signUpWith("oauth_google")}>
|
||||
<FcGoogle className="mr-2 h-4 w-4" />
|
||||
Sign up with Google
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => signUpWith("oauth_apple")}>
|
||||
<FaApple className="mr-2 h-4 w-4" />
|
||||
Sign up with Apple
|
||||
</Button>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid w-full items-center gap-4">
|
||||
<div className="flex flex-col space-y-1.5">
|
||||
<Input
|
||||
id="email"
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
value={emailAddress}
|
||||
onChange={(e) => setEmailAddress(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-1.5">
|
||||
<Input
|
||||
id="password"
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button className="w-full" type="submit">Sign Up</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col">
|
||||
<p className="mt-4 text-sm text-center">
|
||||
Already have an account?{" "}
|
||||
<Link href="/sign-in" className="text-blue-600 hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import Layout from "~/components/layout";
|
||||
import { Studies } from "~/components/study/Studies";
|
||||
|
||||
export default function StudiesPage() {
|
||||
return (
|
||||
<Layout pageTitle="Studies">
|
||||
<Studies />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import Layout from "~/components/layout";
|
||||
import { Trials } from "~/components/trial/Trials";
|
||||
|
||||
export default function TrialsPage() {
|
||||
return (
|
||||
<Layout pageTitle="Trials">
|
||||
<Trials />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
import { type ThemeProviderProps } from "next-themes/dist/types"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handleChange = () => {
|
||||
document.documentElement.classList.toggle('dark', mediaQuery.matches)
|
||||
}
|
||||
mediaQuery.addListener(handleChange)
|
||||
handleChange() // Initial check
|
||||
return () => mediaQuery.removeListener(handleChange)
|
||||
}, [])
|
||||
|
||||
if (!mounted) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { MoonIcon, SunIcon, LaptopIcon } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "~/components/ui/popover"
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!mounted) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<SunIcon className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<MoonIcon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-2">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setTheme('light')}
|
||||
className={theme === 'light' ? 'bg-accent' : ''}
|
||||
>
|
||||
<SunIcon className="h-4 w-4 mr-2" />
|
||||
Light
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setTheme('dark')}
|
||||
className={theme === 'dark' ? 'bg-accent' : ''}
|
||||
>
|
||||
<MoonIcon className="h-4 w-4 mr-2" />
|
||||
Dark
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setTheme('system')}
|
||||
className={theme === 'system' ? 'bg-accent' : ''}
|
||||
>
|
||||
<LaptopIcon className="h-4 w-4 mr-2" />
|
||||
System
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import Image from 'next/image';
|
||||
import { Card, CardContent, CardFooter } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
interface FormCardProps {
|
||||
form: {
|
||||
id: number;
|
||||
title: string;
|
||||
location: string;
|
||||
studyId: number;
|
||||
studyTitle: string;
|
||||
participantId: number;
|
||||
participantName: string;
|
||||
previewLocation: string;
|
||||
};
|
||||
onDelete: (formId: number) => void;
|
||||
}
|
||||
|
||||
export function FormCard({ form, onDelete }: FormCardProps) {
|
||||
const handleCardClick = () => {
|
||||
window.open(form.location, '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden cursor-pointer" onClick={handleCardClick} style={{ backgroundColor: 'var(--primary-card-background)' }}>
|
||||
<CardContent className="p-0 h-40 relative">
|
||||
<Image
|
||||
src={form.previewLocation}
|
||||
alt={form.title}
|
||||
fill
|
||||
className="object-cover object-top"
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = '/placeholder-image.png';
|
||||
console.error('Error loading image:', form.previewLocation);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start p-4" style={{ backgroundColor: 'var(--secondary-card-background)' }}>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<h3 className="font-semibold mb-2">{form.title}</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(form.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
<Badge variant="secondary">{form.studyTitle}</Badge>
|
||||
<Badge variant="outline">{form.participantName}</Badge>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { FormCard } from "~/components/forms/FormCard";
|
||||
import { useToast } from "~/hooks/use-toast";
|
||||
|
||||
interface Form {
|
||||
id: number;
|
||||
title: string;
|
||||
location: string;
|
||||
studyId: number;
|
||||
studyTitle: string;
|
||||
participantId: number;
|
||||
participantName: string;
|
||||
previewLocation: string;
|
||||
}
|
||||
|
||||
export function FormsGrid() {
|
||||
const [forms, setForms] = useState<Form[]>([]);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
fetchForms();
|
||||
}, []);
|
||||
|
||||
const fetchForms = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/forms");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch forms");
|
||||
}
|
||||
const data = await response.json();
|
||||
setForms(data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching forms:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to load forms. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (formId: number) => {
|
||||
try {
|
||||
const response = await fetch(`/api/forms/${formId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to delete form");
|
||||
}
|
||||
setForms(forms.filter((form) => form.id !== formId));
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Form deleted successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error deleting form:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to delete form. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
|
||||
{forms.map((form) => (
|
||||
<FormCard key={form.id} form={form} onDelete={handleDelete} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import { useStudyContext } from "~/context/StudyContext";
|
||||
|
||||
export function UploadFormButton() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [participantId, setParticipantId] = useState("");
|
||||
const { toast } = useToast();
|
||||
const { selectedStudy } = useStudyContext();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!file || !title || !participantId || !selectedStudy) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Please fill in all fields and select a file.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("title", title);
|
||||
formData.append("studyId", selectedStudy.id.toString());
|
||||
formData.append("participantId", participantId);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/forms", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to upload form");
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Form uploaded successfully",
|
||||
});
|
||||
setIsOpen(false);
|
||||
setFile(null);
|
||||
setTitle("");
|
||||
setParticipantId("");
|
||||
} catch (error) {
|
||||
console.error("Error uploading form:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to upload form. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>Upload Form</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Upload New Form</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="participantId">Participant ID</Label>
|
||||
<Input
|
||||
id="participantId"
|
||||
value={participantId}
|
||||
onChange={(e) => setParticipantId(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="file">File</Label>
|
||||
<Input
|
||||
id="file"
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
onChange={(e) => setFile(e.target.files?.[0] || null)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit">Upload</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { PropsWithChildren } from "react";
|
||||
import { Sidebar } from "~/components/sidebar";
|
||||
import { StudyHeader } from "~/components/study/StudyHeader";
|
||||
import { Toaster } from "~/components/ui/toaster";
|
||||
|
||||
interface LayoutProps {
|
||||
pageTitle: string;
|
||||
}
|
||||
|
||||
const Layout = ({ children, pageTitle }: PropsWithChildren<LayoutProps>) => {
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-y-auto bg-gradient-to-b from-[hsl(var(--gradient-start))] to-[hsl(var(--gradient-end))]">
|
||||
<div className="container mx-auto space-y-4 p-4 pt-16 lg:pt-4">
|
||||
<StudyHeader pageTitle={pageTitle} />
|
||||
{children}
|
||||
<Toaster />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -1,52 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { PlusCircle } from 'lucide-react';
|
||||
|
||||
interface CreateParticipantDialogProps {
|
||||
onCreateParticipant: (name: string) => void;
|
||||
}
|
||||
|
||||
export function CreateParticipantDialog({ onCreateParticipant }: CreateParticipantDialogProps) {
|
||||
const [newParticipant, setNewParticipant] = useState({ name: '' });
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleCreate = () => {
|
||||
if (newParticipant.name) {
|
||||
onCreateParticipant(newParticipant.name);
|
||||
setNewParticipant({ name: '' });
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<PlusCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Participant</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="name" className="text-right">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={newParticipant.name}
|
||||
onChange={(e) => setNewParticipant({ name: e.target.value })}
|
||||
className="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleCreate}>Add Participant</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Card, CardContent, CardFooter } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Participant } from "../../types/Participant";
|
||||
|
||||
interface ParticipantCardProps {
|
||||
participant: Participant;
|
||||
onDelete: (participantId: number) => void;
|
||||
}
|
||||
|
||||
export function ParticipantCard({ participant, onDelete }: ParticipantCardProps) {
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="p-4">
|
||||
<h3 className="font-semibold mb-2">{participant.name}</h3>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end p-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
onClick={() => onDelete(participant.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { useStudyContext } from '../../context/StudyContext';
|
||||
import { Participant } from '../../types/Participant';
|
||||
import { CreateParticipantDialog } from './CreateParticipantDialog';
|
||||
import { useToast } from '~/hooks/use-toast';
|
||||
import { ParticipantCard } from './ParticipantCard';
|
||||
import { Avatar, AvatarFallback } from "~/components/ui/avatar";
|
||||
|
||||
interface ParticipantWithTrial {
|
||||
id: number;
|
||||
name: string;
|
||||
latestTrialTimestamp: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function Participants() {
|
||||
const [participants, setParticipants] = useState<ParticipantWithTrial[]>([]);
|
||||
const { selectedStudy } = useStudyContext();
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedStudy) {
|
||||
fetchParticipants();
|
||||
}
|
||||
}, [selectedStudy]);
|
||||
|
||||
const fetchParticipants = async () => {
|
||||
if (!selectedStudy) return;
|
||||
try {
|
||||
const response = await fetch(`/api/participants?studyId=${selectedStudy.id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
setParticipants(data);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse JSON:', text);
|
||||
throw new Error('Invalid JSON in response');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching participants:', error);
|
||||
// Handle the error appropriately, e.g., show a toast notification
|
||||
}
|
||||
};
|
||||
|
||||
const createParticipant = async (name: string) => {
|
||||
if (!selectedStudy) return;
|
||||
const response = await fetch('/api/participants', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, studyId: selectedStudy.id }),
|
||||
});
|
||||
const createdParticipant = await response.json();
|
||||
setParticipants([...participants, createdParticipant]);
|
||||
};
|
||||
|
||||
const deleteParticipant = async (id: number) => {
|
||||
if (!selectedStudy) return;
|
||||
try {
|
||||
const response = await fetch(`/api/participants/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete participant');
|
||||
}
|
||||
|
||||
setParticipants(participants.filter(p => p.id !== id));
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Participant deleted successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error instanceof Error ? error.message : 'Failed to delete participant',
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!selectedStudy) {
|
||||
return <div>Please select a study to manage participants.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="card-level-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-2xl font-bold">Participants for {selectedStudy.title}</CardTitle>
|
||||
<CreateParticipantDialog onCreateParticipant={createParticipant} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{participants.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{participants.map(participant => (
|
||||
<Card key={participant.id} className="card-level-2 p-3 flex items-center w-full">
|
||||
<Avatar className="mr-4">
|
||||
<AvatarFallback>{participant.name.split(' ').map(n => n[0]).join('')}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">{participant.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{participant.latestTrialTimestamp
|
||||
? `Last trial: ${new Date(participant.latestTrialTimestamp).toLocaleString()}`
|
||||
: 'No trials yet'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
onClick={() => deleteParticipant(participant.id)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p>No participants added yet.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import { UserButton, useUser } from "@clerk/nextjs"
|
||||
import {
|
||||
BarChartIcon,
|
||||
UsersRoundIcon,
|
||||
UsersRoundIcon,
|
||||
LandPlotIcon,
|
||||
BotIcon,
|
||||
FolderIcon,
|
||||
@@ -16,18 +16,17 @@ import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet"
|
||||
import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "~/components/ui/sheet"
|
||||
import { cn } from "~/lib/utils"
|
||||
import { ThemeToggle } from "~/components/ThemeToggle"
|
||||
|
||||
const navItems = [
|
||||
{ name: "Dashboard", href: "/dash", icon: LayoutDashboard },
|
||||
{ name: "Studies", href: "/studies", icon: FolderIcon },
|
||||
{ name: "Participants", href: "/participants", icon: UsersRoundIcon },
|
||||
{ name: "Trials", href: "/trials", icon: LandPlotIcon },
|
||||
{ name: "Forms", href: "/forms", icon: FileTextIcon },
|
||||
{ name: "Data Analysis", href: "/analysis", icon: BarChartIcon },
|
||||
{ name: "Settings", href: "/settings", icon: Settings },
|
||||
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
||||
{ name: "Studies", href: "/dashboard/studies", icon: FolderIcon },
|
||||
{ name: "Participants", href: "/dashboard/participants", icon: UsersRoundIcon },
|
||||
{ name: "Trials", href: "/dashboard/trials", icon: LandPlotIcon },
|
||||
{ name: "Forms", href: "/dashboard/forms", icon: FileTextIcon },
|
||||
{ name: "Data Analysis", href: "/dashboard/analysis", icon: BarChartIcon },
|
||||
{ name: "Settings", href: "/dashboard/settings", icon: Settings },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
@@ -36,7 +35,7 @@ export function Sidebar() {
|
||||
const { user } = useUser()
|
||||
|
||||
const HRIStudioLogo = () => (
|
||||
<Link href="/dash" className="flex items-center font-sans text-xl text-[hsl(var(--sidebar-foreground))]">
|
||||
<Link href="/dashboard" className="flex items-center font-sans text-xl text-[hsl(var(--sidebar-foreground))]">
|
||||
<BotIcon className="h-6 w-6 mr-1 text-[hsl(var(--sidebar-muted))]" />
|
||||
<span className="font-extrabold">HRI</span>
|
||||
<span className="font-normal">Studio</span>
|
||||
@@ -78,7 +77,6 @@ export function Sidebar() {
|
||||
<p className="text-xs text-[hsl(var(--sidebar-muted))]">{user?.primaryEmailAddress?.emailAddress ?? 'user@example.com'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,6 +94,7 @@ export function Sidebar() {
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="top" className="w-full">
|
||||
<SheetTitle className="sr-only">Navigation Menu</SheetTitle>
|
||||
<SidebarContent />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { PlusCircle } from 'lucide-react';
|
||||
import { Study } from '../../types/Study';
|
||||
|
||||
interface CreateStudyDialogProps {
|
||||
onCreateStudy: (study: Omit<Study, 'id'>) => void;
|
||||
}
|
||||
|
||||
export function CreateStudyDialog({ onCreateStudy }: CreateStudyDialogProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [newStudy, setNewStudy] = useState({ title: '', description: '' });
|
||||
const [touched, setTouched] = useState({ title: false, description: false });
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setNewStudy({ ...newStudy, [name]: value });
|
||||
setTouched({ ...touched, [name]: true });
|
||||
};
|
||||
|
||||
const isFieldInvalid = (field: 'title' | 'description') => {
|
||||
return field === 'title' ? (touched.title && !newStudy.title) : false;
|
||||
};
|
||||
|
||||
const handleCreateStudy = () => {
|
||||
setTouched({ title: true, description: true });
|
||||
|
||||
if (!newStudy.title) {
|
||||
return;
|
||||
}
|
||||
|
||||
onCreateStudy({
|
||||
title: newStudy.title,
|
||||
description: newStudy.description || undefined
|
||||
});
|
||||
|
||||
setNewStudy({ title: '', description: '' });
|
||||
setTouched({ title: false, description: false });
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<PlusCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Study</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="title" className="text-right">
|
||||
Title
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
className={`col-span-3 ${isFieldInvalid('title') ? 'border-red-500' : ''}`}
|
||||
value={newStudy.title}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
{isFieldInvalid('title') && (
|
||||
<p className="text-red-500 text-sm col-span-4">Title is required</p>
|
||||
)}
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="description" className="text-right">
|
||||
Description
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
className="col-span-3"
|
||||
value={newStudy.description}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleCreateStudy}>Create Study</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { useStudies } from '~/hooks/useStudies';
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
export function Studies() {
|
||||
const { studies, deleteStudy } = useStudies();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{studies.map((study) => (
|
||||
<Card key={study.id}>
|
||||
<CardHeader>
|
||||
<CardTitle>{study.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>{study.description}</p>
|
||||
<div className="flex space-x-2 mt-2">
|
||||
<Button variant="destructive" onClick={() => deleteStudy(study.id)}>Delete</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/components/ui/tooltip";
|
||||
import { useStudyContext } from '~/context/StudyContext';
|
||||
import { StudySelector } from './StudySelector';
|
||||
import { CreateStudyDialog } from '~/components/study/CreateStudyDialog';
|
||||
import { Study } from '~/types/Study';
|
||||
|
||||
interface StudyHeaderProps {
|
||||
pageTitle: string;
|
||||
}
|
||||
|
||||
export const StudyHeader: React.FC<StudyHeaderProps> = ({ pageTitle }) => {
|
||||
const { studies, selectedStudy, setSelectedStudy, validateAndSetSelectedStudy, fetchAndSetStudies } = useStudyContext();
|
||||
|
||||
useEffect(() => {
|
||||
const savedStudyId = localStorage.getItem('selectedStudyId');
|
||||
if (savedStudyId) {
|
||||
validateAndSetSelectedStudy(parseInt(savedStudyId, 10));
|
||||
}
|
||||
}, [validateAndSetSelectedStudy]);
|
||||
|
||||
const handleStudyChange = (studyId: string) => {
|
||||
const study = studies.find(s => s.id.toString() === studyId);
|
||||
if (study) {
|
||||
setSelectedStudy(study);
|
||||
localStorage.setItem('selectedStudyId', studyId);
|
||||
}
|
||||
};
|
||||
|
||||
const createStudy = async (newStudy: Omit<Study, "id">) => {
|
||||
const response = await fetch('/api/studies', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newStudy),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create study');
|
||||
}
|
||||
const createdStudy = await response.json();
|
||||
await fetchAndSetStudies();
|
||||
return createdStudy;
|
||||
};
|
||||
|
||||
const handleCreateStudy = async (newStudy: Omit<Study, "id">) => {
|
||||
const createdStudy = await createStudy(newStudy);
|
||||
setSelectedStudy(createdStudy);
|
||||
localStorage.setItem('selectedStudyId', createdStudy.id.toString());
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="mt-2 lg:mt-0">
|
||||
<CardContent className="flex justify-between items-center p-4">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<h2 className="text-2xl font-bold truncate max-w-[200px]">
|
||||
{pageTitle}
|
||||
</h2>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{selectedStudy ? selectedStudy.title : 'No study selected'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div className="flex items-center space-x-2">
|
||||
<StudySelector
|
||||
studies={studies}
|
||||
selectedStudy={selectedStudy}
|
||||
onStudyChange={handleStudyChange}
|
||||
/>
|
||||
<CreateStudyDialog onCreateStudy={(study: Omit<Study, "id">) => handleCreateStudy(study as Study)} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
||||
import { Study } from '../../types/Study';
|
||||
|
||||
interface StudySelectorProps {
|
||||
studies: Study[];
|
||||
selectedStudy: Study | null;
|
||||
onStudyChange: (studyId: string) => void;
|
||||
}
|
||||
|
||||
export function StudySelector({ studies, selectedStudy, onStudyChange }: StudySelectorProps) {
|
||||
return (
|
||||
<Select onValueChange={onStudyChange} value={selectedStudy?.id?.toString() || ""}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Select a study" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{studies.length > 0 ? (
|
||||
studies.map((study) => (
|
||||
<SelectItem key={study.id} value={study.id.toString()}>
|
||||
{study.title}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value="no-studies" disabled className="text-gray-400 italic">
|
||||
No studies available
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog";
|
||||
import { Label } from "~/components/ui/label";
|
||||
|
||||
interface CreateTrialDialogProps {
|
||||
onCreateTrial: (title: string, participantIds: number[]) => void;
|
||||
}
|
||||
|
||||
export function CreateTrialDialog({ onCreateTrial }: CreateTrialDialogProps) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [participantIds, setParticipantIds] = useState<string>('');
|
||||
|
||||
const handleCreate = () => {
|
||||
const ids = participantIds.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id));
|
||||
if (title && ids.length > 0) {
|
||||
onCreateTrial(title, ids);
|
||||
setTitle('');
|
||||
setParticipantIds('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">Add Trial</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Trial</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="title" className="text-right">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="participants" className="text-right">Participant IDs (comma-separated)</Label>
|
||||
<Input
|
||||
id="participants"
|
||||
value={participantIds}
|
||||
onChange={(e) => setParticipantIds(e.target.value)}
|
||||
className="col-span-3"
|
||||
placeholder="e.g. 1, 2, 3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleCreate}>Add Trial</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { useToast } from '~/hooks/use-toast';
|
||||
import { CreateTrialDialog } from '~/components/trial/CreateTrialDialog';
|
||||
|
||||
interface Trial {
|
||||
id: number;
|
||||
title: string;
|
||||
participantIds: number[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function Trials() {
|
||||
const [trials, setTrials] = useState<Trial[]>([]);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
fetchTrials();
|
||||
}, []);
|
||||
|
||||
const fetchTrials = async () => {
|
||||
const response = await fetch('/api/trials');
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Error fetching trials:', response.status, errorText);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data || data.length === 0) {
|
||||
console.warn('No trials found');
|
||||
setTrials([]); // Set to an empty array if no trials are found
|
||||
return;
|
||||
}
|
||||
|
||||
setTrials(data);
|
||||
};
|
||||
|
||||
const createTrial = async (title: string, participantIds: number[]) => {
|
||||
const response = await fetch('/api/trials', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, participantIds }),
|
||||
});
|
||||
const newTrial = await response.json();
|
||||
setTrials([...trials, newTrial]);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Trial created successfully",
|
||||
});
|
||||
};
|
||||
|
||||
const deleteTrial = async (id: number) => {
|
||||
const response = await fetch(`/api/trials/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setTrials(trials.filter(trial => trial.id !== id));
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Trial deleted successfully",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to delete trial",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="card-level-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-2xl font-bold">Trials</CardTitle>
|
||||
<CreateTrialDialog onCreateTrial={createTrial} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{trials.length > 0 ? (
|
||||
<div className="grid sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{trials.map(trial => (
|
||||
<Card key={trial.id} className="card-level-2 p-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold">{trial.title}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
Participants: {trial.participantIds ? trial.participantIds.join(', ') : 'None'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
onClick={() => deleteTrial(trial.id)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p>No trials added yet.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
@@ -1,36 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -35,7 +35,7 @@ const CardTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight text-2xl", className)}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { Cross2Icon } from "@radix-ui/react-icons"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@@ -2,14 +2,16 @@ import * as React from "react"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
// Use React.InputHTMLAttributes<HTMLInputElement> directly in the component
|
||||
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
@@ -1,31 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
@@ -1,129 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Cross2Icon } from "@radix-ui/react-icons"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useToast } from "~/hooks/use-toast"
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "~/components/ui/toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -1,77 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { Study } from '~/types/Study';
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
|
||||
interface StudyContextType {
|
||||
selectedStudy: Study | null;
|
||||
setSelectedStudy: (study: Study | null) => void;
|
||||
validateAndSetSelectedStudy: (studyId: number) => Promise<void>;
|
||||
studies: Study[];
|
||||
fetchAndSetStudies: () => Promise<void>;
|
||||
selectedStudyId: number | null;
|
||||
setSelectedStudyId: (id: number | null) => void;
|
||||
}
|
||||
|
||||
const StudyContext = createContext<StudyContextType | undefined>(undefined);
|
||||
|
||||
export const StudyProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
|
||||
const [selectedStudy, setSelectedStudy] = useState<Study | null>(null);
|
||||
const [studies, setStudies] = useState<Study[]>([]);
|
||||
|
||||
const fetchAndSetStudies = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/studies');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch studies');
|
||||
}
|
||||
const fetchedStudies = await response.json();
|
||||
setStudies(fetchedStudies);
|
||||
} catch (error) {
|
||||
console.error('Error fetching studies:', error);
|
||||
setStudies([]);
|
||||
}
|
||||
};
|
||||
|
||||
const validateAndSetSelectedStudy = async (studyId: number) => {
|
||||
const existingStudy = studies.find(s => s.id === studyId);
|
||||
if (existingStudy) {
|
||||
setSelectedStudy(existingStudy);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/studies/${studyId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Study not found');
|
||||
}
|
||||
const study = await response.json();
|
||||
setSelectedStudy(study);
|
||||
} catch (error) {
|
||||
console.warn(`Study with id ${studyId} not found`);
|
||||
setSelectedStudy(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAndSetStudies();
|
||||
}, []);
|
||||
export const StudyProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [selectedStudyId, setSelectedStudyId] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<StudyContext.Provider value={{
|
||||
selectedStudy,
|
||||
setSelectedStudy,
|
||||
validateAndSetSelectedStudy,
|
||||
studies,
|
||||
fetchAndSetStudies
|
||||
}}>
|
||||
<StudyContext.Provider value={{ selectedStudyId, setSelectedStudyId }}>
|
||||
{children}
|
||||
</StudyContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useStudyContext = () => {
|
||||
export const useStudy = () => {
|
||||
const context = useContext(StudyContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useStudyContext must be used within a StudyProvider');
|
||||
if (!context) {
|
||||
throw new Error('useStudy must be used within a StudyProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
};
|
||||
|
||||
4
src/db/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { sql } from '@vercel/postgres';
|
||||
import { drizzle } from 'drizzle-orm/vercel-postgres';
|
||||
|
||||
export const db = drizzle(sql);
|
||||
68
src/db/schema.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { sql, relations } from 'drizzle-orm';
|
||||
import { integer, pgTable, serial, text, timestamp, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const usersTable = pgTable("users", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
name: varchar({ length: 255 }).notNull(),
|
||||
age: integer().notNull(),
|
||||
email: varchar({ length: 255 }).notNull().unique(),
|
||||
});
|
||||
|
||||
export const studyTable = pgTable("study", {
|
||||
id: serial("id").primaryKey(),
|
||||
title: varchar("title", { length: 256 }).notNull(),
|
||||
description: varchar("description", { length: 1000 }),
|
||||
userId: varchar("user_id", { length: 256 }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.$onUpdate(() => new Date()),
|
||||
});
|
||||
|
||||
export const participants = pgTable(
|
||||
"participant",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
name: varchar("name", { length: 256 }).notNull(),
|
||||
studyId: integer("study_id").references(() => studyTable.id).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
}
|
||||
);
|
||||
|
||||
export const roles = pgTable("roles", {
|
||||
id: serial("id").primaryKey(),
|
||||
name: varchar("name", { length: 256 }).notNull().unique(),
|
||||
description: text("description"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const permissions = pgTable("permissions", {
|
||||
id: serial("id").primaryKey(),
|
||||
name: varchar("name", { length: 256 }).notNull().unique(),
|
||||
description: text("description"),
|
||||
code: varchar("code", { length: 100 }).notNull().unique(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const rolePermissions = pgTable("role_permissions", {
|
||||
roleId: integer("role_id").references(() => roles.id).notNull(),
|
||||
permissionId: integer("permission_id").references(() => permissions.id).notNull(),
|
||||
});
|
||||
|
||||
export const userRoles = pgTable("user_roles", {
|
||||
userId: varchar("user_id", { length: 256 }).notNull(),
|
||||
roleId: integer("role_id").references(() => roles.id).notNull(),
|
||||
});
|
||||
|
||||
// Add relations
|
||||
export const rolesRelations = relations(roles, ({ many }) => ({
|
||||
permissions: many(rolePermissions),
|
||||
users: many(userRoles),
|
||||
}));
|
||||
|
||||
export const permissionsRelations = relations(permissions, ({ many }) => ({
|
||||
roles: many(rolePermissions),
|
||||
}));
|
||||
44
src/env.js
@@ -1,44 +0,0 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
export const env = createEnv({
|
||||
/**
|
||||
* Specify your server-side environment variables schema here. This way you can ensure the app
|
||||
* isn't built with invalid env vars.
|
||||
*/
|
||||
server: {
|
||||
DATABASE_URL: z.string().url(),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "test", "production"])
|
||||
.default("development"),
|
||||
},
|
||||
|
||||
/**
|
||||
* Specify your client-side environment variables schema here. This way you can ensure the app
|
||||
* isn't built with invalid env vars. To expose them to the client, prefix them with
|
||||
* `NEXT_PUBLIC_`.
|
||||
*/
|
||||
client: {
|
||||
// NEXT_PUBLIC_CLIENTVAR: z.string(),
|
||||
},
|
||||
|
||||
/**
|
||||
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
|
||||
* middlewares) or client-side so we need to destruct manually.
|
||||
*/
|
||||
runtimeEnv: {
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
||||
},
|
||||
/**
|
||||
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
|
||||
* useful for Docker builds.
|
||||
*/
|
||||
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
|
||||
/**
|
||||
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
|
||||
* `SOME_VAR=''` will throw an error.
|
||||
*/
|
||||
emptyStringAsUndefined: true,
|
||||
});
|
||||
@@ -1,194 +0,0 @@
|
||||
"use client"
|
||||
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from "react"
|
||||
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "~/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
36
src/hooks/usePermissions.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useUser } from "@clerk/nextjs";
|
||||
import { useEffect, useState } from "react";
|
||||
import { PERMISSIONS, type PermissionCode } from "~/lib/permissions";
|
||||
|
||||
export function usePermissions() {
|
||||
const { user } = useUser();
|
||||
const [permissions, setPermissions] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchPermissions() {
|
||||
if (!user?.id) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/permissions');
|
||||
const data = await response.json();
|
||||
setPermissions(new Set(data));
|
||||
} catch (error) {
|
||||
console.error('Error fetching permissions:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchPermissions();
|
||||
}, [user?.id]);
|
||||
|
||||
const hasPermission = (permission: PermissionCode) => {
|
||||
return permissions.has(PERMISSIONS[permission]);
|
||||
};
|
||||
|
||||
return {
|
||||
hasPermission,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Study } from '../types/Study';
|
||||
import { useStudyContext } from '../context/StudyContext';
|
||||
|
||||
export function useStudies() {
|
||||
const [studies, setStudies] = useState<Study[]>([]);
|
||||
const { selectedStudy, setSelectedStudy } = useStudyContext();
|
||||
|
||||
useEffect(() => {
|
||||
fetchStudies();
|
||||
}, []);
|
||||
|
||||
const fetchStudies = async () => {
|
||||
const response = await fetch('/api/studies');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch studies');
|
||||
}
|
||||
const data = await response.json();
|
||||
setStudies(data);
|
||||
};
|
||||
|
||||
const handleStudyChange = (studyId: string) => {
|
||||
const study = studies.find(s => s.id.toString() === studyId);
|
||||
setSelectedStudy(study || null);
|
||||
};
|
||||
|
||||
const addStudy = async (newStudy: Omit<Study, 'id'>) => {
|
||||
const response = await fetch('/api/studies', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newStudy),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create study');
|
||||
}
|
||||
const createdStudy = await response.json();
|
||||
setStudies(prevStudies => [...prevStudies, createdStudy]);
|
||||
setSelectedStudy(createdStudy);
|
||||
};
|
||||
|
||||
const deleteStudy = async (id: number) => {
|
||||
const response = await fetch(`/api/studies/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete study');
|
||||
}
|
||||
setStudies(studies.filter(s => s.id !== id));
|
||||
if (selectedStudy?.id === id) {
|
||||
setSelectedStudy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return { studies, selectedStudy, handleStudyChange, addStudy, deleteStudy };
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { fromBuffer } from 'pdf2pic';
|
||||
|
||||
const CONTENT_DIR = path.join(process.cwd(), 'content');
|
||||
|
||||
export async function saveFile(file: File, filePath: string, previewContentTypeId: number): Promise<{ pdfPath: string; previewPath: string }> {
|
||||
const fullPath = path.join(CONTENT_DIR, filePath);
|
||||
const dir = path.dirname(fullPath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await fs.writeFile(fullPath, buffer);
|
||||
|
||||
// Generate preview image
|
||||
const previewFileName = path.basename(filePath, '.pdf') + '_preview.png';
|
||||
const previewDir = path.join(CONTENT_DIR, previewContentTypeId.toString());
|
||||
await fs.mkdir(previewDir, { recursive: true });
|
||||
const previewPath = path.join(previewDir, previewFileName);
|
||||
|
||||
const options = {
|
||||
density: 100,
|
||||
saveFilename: path.basename(previewPath, '.png'),
|
||||
savePath: previewDir,
|
||||
format: "png",
|
||||
width: 600,
|
||||
height: 800
|
||||
};
|
||||
|
||||
const convert = fromBuffer(buffer, options);
|
||||
const result = await convert(1);
|
||||
|
||||
// Rename the file to remove the ".1" suffix
|
||||
const generatedFilePath = result.path;
|
||||
if (generatedFilePath) {
|
||||
await fs.rename(generatedFilePath, previewPath);
|
||||
}
|
||||
|
||||
// Return relative paths that can be used in URLs
|
||||
return {
|
||||
pdfPath: `/content/${filePath}`,
|
||||
previewPath: `/content/${previewContentTypeId}/${previewFileName}`
|
||||
};
|
||||
}
|
||||
@@ -1,20 +1,48 @@
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { Participant } from "../types/Participant";
|
||||
import { db } from "~/db";
|
||||
import { permissions, rolePermissions, userRoles } from "~/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export const isUserAuthorized = (userId: string | null): boolean => {
|
||||
// Implement your logic to determine if the user is authorized to see participant names
|
||||
// For example, you might check if the user is an admin or has a specific role
|
||||
// return userId !== null; // Placeholder logic, replace with your actual authorization logic
|
||||
return false;
|
||||
};
|
||||
// Define permission codes
|
||||
export const PERMISSIONS = {
|
||||
VIEW_PARTICIPANT_NAMES: 'view_participant_names',
|
||||
CREATE_PARTICIPANT: 'create_participant',
|
||||
DELETE_PARTICIPANT: 'delete_participant',
|
||||
CREATE_STUDY: 'create_study',
|
||||
DELETE_STUDY: 'delete_study',
|
||||
MANAGE_ROLES: 'manage_roles',
|
||||
} as const;
|
||||
|
||||
export const anonymizeParticipants = (participants: Participant[], userId: string | null): Participant[] => {
|
||||
if (isUserAuthorized(userId)) {
|
||||
return participants; // Return original participants if authorized
|
||||
}
|
||||
export type PermissionCode = keyof typeof PERMISSIONS;
|
||||
|
||||
return participants.map(participant => ({
|
||||
...participant,
|
||||
name: `Participant ${participant.id}`, // Anonymize the name
|
||||
}));
|
||||
};
|
||||
// Cache user permissions
|
||||
const userPermissionsCache = new Map<string, Set<string>>();
|
||||
|
||||
export async function getUserPermissions(userId: string): Promise<Set<string>> {
|
||||
// Check cache first
|
||||
const cached = userPermissionsCache.get(userId);
|
||||
if (cached) return cached;
|
||||
|
||||
// Query permissions from database
|
||||
const userPerms = await db
|
||||
.select({
|
||||
permissionCode: permissions.code,
|
||||
})
|
||||
.from(userRoles)
|
||||
.leftJoin(rolePermissions, eq(userRoles.roleId, rolePermissions.roleId))
|
||||
.leftJoin(permissions, eq(rolePermissions.permissionId, permissions.id))
|
||||
.where(eq(userRoles.userId, userId));
|
||||
const permSet = new Set<string>(userPerms.map(p => p.permissionCode).filter((code): code is string => code !== null));
|
||||
userPermissionsCache.set(userId, permSet);
|
||||
|
||||
return permSet;
|
||||
}
|
||||
|
||||
export async function hasPermission(userId: string, permissionCode: string): Promise<boolean> {
|
||||
const userPerms = await getUserPermissions(userId);
|
||||
return userPerms.has(permissionCode);
|
||||
}
|
||||
|
||||
// Clear cache for user
|
||||
export function clearUserPermissionsCache(userId: string) {
|
||||
userPermissionsCache.delete(userId);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
|
||||
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
|
||||
|
||||
const isPublicRoute = createRouteMatcher(['/sign-in(.*)', '/sign-up(.*)', '/'])
|
||||
const isProtectedRoute = createRouteMatcher(['/dashboard(.*)'])
|
||||
|
||||
export default clerkMiddleware((auth, request) => {
|
||||
if (!isPublicRoute(request)) {
|
||||
auth().protect()
|
||||
}
|
||||
export default clerkMiddleware(async (auth, req) => {
|
||||
if (isProtectedRoute(req)) await auth.protect()
|
||||
})
|
||||
|
||||
export const config = {
|
||||
@@ -14,7 +12,5 @@ export const config = {
|
||||
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
|
||||
// Always run for API routes
|
||||
'/(api|trpc)(.*)',
|
||||
// Add this line to include the /content route
|
||||
'/content/(.*)',
|
||||
],
|
||||
}
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
|
||||
import { env } from "~/env";
|
||||
import * as schema from "./schema";
|
||||
|
||||
/**
|
||||
* Cache the database connection in development. This avoids creating a new connection on every HMR
|
||||
* update.
|
||||
*/
|
||||
const globalForDb = globalThis as unknown as {
|
||||
conn: postgres.Sql | undefined;
|
||||
};
|
||||
|
||||
const conn = globalForDb.conn ?? postgres(env.DATABASE_URL);
|
||||
if (env.NODE_ENV !== "production") globalForDb.conn = conn;
|
||||
|
||||
export const db = drizzle(conn, { schema });
|
||||
|
||||
import { initializeContentTypes, initializeRoles } from "./init";
|
||||
|
||||
// Initialize content types
|
||||
initializeContentTypes().catch(console.error);
|
||||
initializeRoles().catch(console.error);
|
||||
@@ -1,32 +0,0 @@
|
||||
import { db } from "./index";
|
||||
import { contentTypes } from "./schema";
|
||||
import { roles } from "./schema";
|
||||
|
||||
export async function initializeContentTypes() {
|
||||
const existingTypes = await db.select().from(contentTypes);
|
||||
|
||||
if (existingTypes.length === 0) {
|
||||
await db.insert(contentTypes).values([
|
||||
{ name: "Informed Consent Form" },
|
||||
{ name: "Preview Image" }, // New content type
|
||||
// Add other content types as needed
|
||||
]);
|
||||
console.log("Content types initialized");
|
||||
} else {
|
||||
console.log("Content types already initialized");
|
||||
}
|
||||
}
|
||||
|
||||
export async function initializeRoles() {
|
||||
const existingRoles = await db.select().from(roles);
|
||||
|
||||
if (existingRoles.length === 0) {
|
||||
await db.insert(roles).values([
|
||||
{ name: "Basic User" }, // Role ID 0
|
||||
{ name: "Admin" }, // Role ID 1
|
||||
]);
|
||||
console.log("Roles initialized");
|
||||
} else {
|
||||
console.log("Roles already initialized");
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
// Example model schema from the Drizzle docs
|
||||
// https://orm.drizzle.team/docs/sql-schema-declaration
|
||||
|
||||
import { pgTable } from "drizzle-orm/pg-core";
|
||||
import {
|
||||
serial,
|
||||
varchar,
|
||||
timestamp,
|
||||
integer,
|
||||
boolean
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
||||
* database instance for multiple projects.
|
||||
*
|
||||
* @see https://orm.drizzle.team/docs/goodies#multi-project-schema
|
||||
*/
|
||||
export const studies = pgTable(
|
||||
"study",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
title: varchar("title", { length: 256 }).notNull(),
|
||||
description: varchar("description", { length: 1000 }),
|
||||
userId: varchar("user_id", { length: 256 }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).$onUpdate(
|
||||
() => new Date()
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
export const participants = pgTable(
|
||||
"participant",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
name: varchar("name", { length: 256 }).notNull(),
|
||||
studyId: integer("study_id").references(() => studies.id).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
}
|
||||
);
|
||||
|
||||
export const contentTypes = pgTable(
|
||||
"content_type",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
name: varchar("name", { length: 50 }).notNull().unique(),
|
||||
}
|
||||
);
|
||||
|
||||
export const contents = pgTable(
|
||||
"content",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
contentTypeId: integer("content_type_id").references(() => contentTypes.id).notNull(),
|
||||
uploader: varchar("uploader", { length: 256 }).notNull(),
|
||||
location: varchar("location", { length: 1000 }).notNull(),
|
||||
previewLocation: varchar("preview_location", { length: 1000 }),
|
||||
title: varchar("title", { length: 256 }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
}
|
||||
);
|
||||
|
||||
export const informedConsentForms = pgTable(
|
||||
"informed_consent_form",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
studyId: integer("study_id").references(() => studies.id).notNull(),
|
||||
participantId: integer("participant_id").references(() => participants.id).notNull(),
|
||||
contentId: integer("content_id").references(() => contents.id).notNull(),
|
||||
uploadedAt: timestamp("uploaded_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
}
|
||||
);
|
||||
|
||||
export const users = pgTable(
|
||||
"user",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
email: varchar("email", { length: 256 }).notNull().unique(),
|
||||
roleId: integer("role_id").references(() => roles.id).default(0), // Link to roles
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
}
|
||||
);
|
||||
|
||||
export const trials = pgTable(
|
||||
"trial",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
title: varchar("title", { length: 256 }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
}
|
||||
);
|
||||
|
||||
export const trialParticipants = pgTable(
|
||||
"trial_participants",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
trialId: integer("trial_id").references(() => trials.id).notNull(),
|
||||
participantId: integer("participant_id").references(() => participants.id).notNull(),
|
||||
}
|
||||
);
|
||||
|
||||
export const roles = pgTable(
|
||||
"role",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
name: varchar("name", { length: 50 }).notNull().unique(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
}
|
||||
);
|
||||
|
||||
export const permissions = pgTable(
|
||||
"permission",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
name: varchar("name", { length: 50 }).notNull().unique(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
}
|
||||
);
|
||||
|
||||
export const rolePermissions = pgTable(
|
||||
"role_permissions",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
roleId: integer("role_id").references(() => roles.id).notNull(),
|
||||
permissionId: integer("permission_id").references(() => permissions.id).notNull(),
|
||||
}
|
||||
);
|
||||
|
||||
export const permissionTypes = pgTable(
|
||||
"permission_type",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
name: varchar("name", { length: 50 }).notNull().unique(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
}
|
||||
);
|
||||
@@ -1,5 +0,0 @@
|
||||
export interface Participant {
|
||||
id: number;
|
||||
name: string;
|
||||
studyId: number;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export interface Study {
|
||||
id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Use this script to start a docker container for a local development database
|
||||
|
||||
# TO RUN ON WINDOWS:
|
||||
# 1. Install WSL (Windows Subsystem for Linux) - https://learn.microsoft.com/en-us/windows/wsl/install
|
||||
# 2. Install Docker Desktop for Windows - https://docs.docker.com/docker-for-windows/install/
|
||||
# 3. Open WSL - `wsl`
|
||||
# 4. Run this script - `./start-database.sh`
|
||||
|
||||
# On Linux and macOS you can run this script directly - `./start-database.sh`
|
||||
|
||||
DB_CONTAINER_NAME="hristudio-postgres"
|
||||
|
||||
if ! [ -x "$(command -v docker)" ]; then
|
||||
echo -e "Docker is not installed. Please install docker and try again.\nDocker install guide: https://docs.docker.com/engine/install/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$(docker ps -q -f name=$DB_CONTAINER_NAME)" ]; then
|
||||
echo "Database container '$DB_CONTAINER_NAME' already running"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$(docker ps -q -a -f name=$DB_CONTAINER_NAME)" ]; then
|
||||
docker start "$DB_CONTAINER_NAME"
|
||||
echo "Existing database container '$DB_CONTAINER_NAME' started"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# import env variables from .env
|
||||
set -a
|
||||
source .env
|
||||
|
||||
DB_PASSWORD=$(echo "$DATABASE_URL" | awk -F':' '{print $3}' | awk -F'@' '{print $1}')
|
||||
DB_PORT=$(echo "$DATABASE_URL" | awk -F':' '{print $4}' | awk -F'\/' '{print $1}')
|
||||
|
||||
if [ "$DB_PASSWORD" = "password" ]; then
|
||||
echo "You are using the default database password"
|
||||
read -p "Should we generate a random password for you? [y/N]: " -r REPLY
|
||||
if ! [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Please change the default password in the .env file and try again"
|
||||
exit 1
|
||||
fi
|
||||
# Generate a random URL-safe password
|
||||
DB_PASSWORD=$(openssl rand -base64 12 | tr '+/' '-_')
|
||||
sed -i -e "s#:password@#:$DB_PASSWORD@#" .env
|
||||
fi
|
||||
|
||||
docker run -d \
|
||||
--name $DB_CONTAINER_NAME \
|
||||
-e POSTGRES_USER="postgres" \
|
||||
-e POSTGRES_PASSWORD="$DB_PASSWORD" \
|
||||
-e POSTGRES_DB=hristudio \
|
||||
-p "$DB_PORT":5432 \
|
||||
docker.io/postgres && echo "Database container '$DB_CONTAINER_NAME' was successfully created"
|
||||
@@ -1,20 +1,14 @@
|
||||
import { type Config } from "tailwindcss";
|
||||
import { fontFamily } from "tailwindcss/defaultTheme";
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
export default {
|
||||
const config: Config = {
|
||||
darkMode: ["class"],
|
||||
content: ["./src/**/*.tsx"],
|
||||
content: [
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["var(--font-sans)"],
|
||||
// ... other theme extensions
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)'
|
||||
},
|
||||
colors: {
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
@@ -56,8 +50,14 @@ export default {
|
||||
'4': 'hsl(var(--chart-4))',
|
||||
'5': 'hsl(var(--chart-5))'
|
||||
}
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
} satisfies Config;
|
||||
};
|
||||
export default config;
|
||||
|
||||
@@ -1,51 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Base Options: */
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"target": "es2022",
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"resolveJsonModule": true,
|
||||
"moduleDetection": "force",
|
||||
"isolatedModules": true,
|
||||
/* Strictness */
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"checkJs": true,
|
||||
/* Bundled projects */
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"ES2022"
|
||||
],
|
||||
"noEmit": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "preserve", // or "react" for older versions
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"incremental": true,
|
||||
/* Path Aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": [
|
||||
"./src/*"
|
||||
]
|
||||
"~/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
".eslintrc.cjs",
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"**/*.cjs",
|
||||
"**/*.js",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||