Clean codebase- start from scratch

This commit is contained in:
2024-11-19 22:14:54 -05:00
parent 9d9aa52285
commit b4a05e0bcd
97 changed files with 6376 additions and 3624 deletions

43
.cursorrules Normal file
View 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
View File

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

View File

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

View File

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

@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}

41
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -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": ""
},

View File

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

View File

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

View File

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

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +0,0 @@
const config = {
plugins: {
tailwindcss: {},
},
};
module.exports = config;

8
postcss.config.mjs Normal file
View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

1
public/file.svg Normal file
View 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
View 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

1
public/next.svg Normal file
View 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
View 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
View 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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,8 @@
export default function Dashboard() {
return (
<div>
<p>Dashboard</p>
</div>
);
}

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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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&apos;t have an account?{" "}
<Link href="/sign-up" className="text-blue-600 hover:underline">
Sign up
</Link>
</p>
</CardFooter>
</Card>
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +0,0 @@

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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/(.*)',
],
}
};

View File

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

View File

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

View File

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

View File

@@ -1,5 +0,0 @@
export interface Participant {
id: number;
name: string;
studyId: number;
}

View File

@@ -1,5 +0,0 @@
export interface Study {
id: number;
title: string;
description?: string;
}

View File

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

View File

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

View File

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