diff --git a/.env b/.env
index 1603226..87b0984 100644
--- a/.env
+++ b/.env
@@ -5,8 +5,8 @@
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_PUBLISHABLE_KEY=pk_test_YWxsb3dlZC1zYWxtb24tNjMuY2xlcmsuYWNjb3VudHMuZGV2JA
+CLERK_SECRET_KEY=sk_test_nUKl0GTM5ibgUH12WbTH6pNVHAyRshlSFi64IrEeWD
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
diff --git a/.gitignore b/.gitignore
index 40f873f..eadb5b3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@
### NextJS ###
# dependencies
/node_modules
+.pnpm-store/
/.pnp
.pnp.js
diff --git a/docker-compose.yml b/docker-compose.yml
index 5d7db53..1073ca7 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,6 +1,3 @@
-# Use Docker Compose file format version 3.9
-# version: '3.9'
-
services:
app:
build:
@@ -8,13 +5,15 @@ services:
dockerfile: Dockerfile
ports:
- '3000:3000' # Node.js
- - '4983:4983' # Drizzle Studio
+ # - '4983:4983' # Drizzle Studio
volumes:
- .:/app
- /app/node_modules
environment:
NODE_ENV: development
- command: ["pnpm", "run", "dev"]
+ command: ["sh", "-c", "pnpm db:push && pnpm run dev"]
+ depends_on:
+ - db
db:
image: postgres
@@ -33,6 +32,6 @@ services:
restart: always
ports:
- 8080:8080
-
+
volumes:
postgres:
diff --git a/package.json b/package.json
index c793ae1..074d96b 100644
--- a/package.json
+++ b/package.json
@@ -18,8 +18,12 @@
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-icons": "^1.3.0",
+ "@radix-ui/react-label": "^2.1.0",
+ "@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0",
"@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",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
@@ -30,6 +34,7 @@
"next": "^14.2.12",
"next-themes": "^0.3.0",
"postgres": "^3.4.4",
+ "radix-ui": "^1.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-icons": "^5.3.0",
diff --git a/src/app/api/participants/[id]/route.ts b/src/app/api/participants/[id]/route.ts
new file mode 100644
index 0000000..e3eea2e
--- /dev/null
+++ b/src/app/api/participants/[id]/route.ts
@@ -0,0 +1,37 @@
+import { db } from "~/server/db";
+import { participants } from "~/server/db/schema";
+import { NextResponse } from "next/server";
+import { eq } from "drizzle-orm";
+
+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 });
+ }
+
+ 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 });
+ }
+
+ 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 });
+ }
+}
\ No newline at end of file
diff --git a/src/app/api/participants/route.ts b/src/app/api/participants/route.ts
new file mode 100644
index 0000000..6a01750
--- /dev/null
+++ b/src/app/api/participants/route.ts
@@ -0,0 +1,25 @@
+import { db } from "~/server/db";
+import { participants } from "~/server/db/schema";
+import { NextResponse } from "next/server";
+import { eq } from "drizzle-orm";
+
+export async function GET(request: Request) {
+ const { searchParams } = new URL(request.url);
+ const studyId = searchParams.get('studyId');
+
+ if (!studyId) {
+ return NextResponse.json({ error: 'Study ID is required' }, { status: 400 });
+ }
+
+ const allParticipants = await db.select().from(participants).where(eq(participants.studyId, parseInt(studyId)));
+ return NextResponse.json(allParticipants);
+}
+
+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]);
+}
\ No newline at end of file
diff --git a/src/app/api/studies/[id]/route.ts b/src/app/api/studies/[id]/route.ts
new file mode 100644
index 0000000..3faeec0
--- /dev/null
+++ b/src/app/api/studies/[id]/route.ts
@@ -0,0 +1,86 @@
+import { db } from "~/server/db";
+import { studies } from "~/server/db/schema";
+import { NextResponse } from "next/server";
+import { eq, and } from "drizzle-orm";
+import { auth } from "@clerk/nextjs/server";
+
+export async function GET(
+ 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 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" });
+}
diff --git a/src/app/api/studies/route.ts b/src/app/api/studies/route.ts
index f7d08c9..5111865 100644
--- a/src/app/api/studies/route.ts
+++ b/src/app/api/studies/route.ts
@@ -2,30 +2,29 @@ import { db } from "~/server/db";
import { studies } from "~/server/db/schema";
import { NextResponse } from "next/server";
import { eq } from "drizzle-orm";
+import { auth } from "@clerk/nextjs/server";
-export async function GET() {
- const allStudies = await db.select().from(studies);
+export async function GET(request: Request) {
+ const { userId } = auth();
+ if (!userId) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const allStudies = await db.select().from(studies).where(eq(studies.userId, userId));
return NextResponse.json(allStudies);
}
export async function POST(request: Request) {
+ const { userId } = auth();
+ if (!userId) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
const { title, description } = await request.json();
- const newStudy = await db.insert(studies).values({ title, description }).returning();
+ 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]);
-}
-
-export async function PUT(request: Request) {
- const { id, title, description } = await request.json();
- const updatedStudy = await db
- .update(studies)
- .set({ title, description })
- .where(eq(studies.id, id))
- .returning();
- return NextResponse.json(updatedStudy[0]);
-}
-
-export async function DELETE(request: Request) {
- const { id } = await request.json();
- await db.delete(studies).where(eq(studies.id, id));
- return NextResponse.json({ message: "Study deleted" });
}
\ No newline at end of file
diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts
new file mode 100644
index 0000000..2dd2050
--- /dev/null
+++ b/src/app/api/users/route.ts
@@ -0,0 +1,21 @@
+import { db } from "~/server/db";
+import { users } from "~/server/db/schema";
+import { NextResponse } from "next/server";
+
+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 });
+ }
+
+ // 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 });
+ }
+}
\ No newline at end of file
diff --git a/src/app/dash/layout.tsx b/src/app/dash/layout.tsx
deleted file mode 100644
index f0a43f7..0000000
--- a/src/app/dash/layout.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import { type PropsWithChildren } from "react"
-import { Sidebar } from "~/components/sidebar"
-import { Inter } from "next/font/google"
-
-import "~/styles/globals.css"
-
-const inter = Inter({
- subsets: ["latin"],
- display: "swap",
- variable: "--font-sans",
-})
-
-export default function RootLayout({ children }: PropsWithChildren) {
- return (
-
-
-
-
-
- {children}
-
-
-
-
- )
-}
\ No newline at end of file
diff --git a/src/app/dash/page.tsx b/src/app/dash/page.tsx
index e66226f..cb6622a 100644
--- a/src/app/dash/page.tsx
+++ b/src/app/dash/page.tsx
@@ -1,21 +1,45 @@
-import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '~/components/ui/card';
-import { Button } from '~/components/ui/button';
-import { Studies } from "~/components/Studies";
+import Layout from "~/components/layout";
+import { Card, CardHeader, CardTitle, CardContent } from "~/components/ui/card";
-const HomePage: React.FC = () => {
- return (
-
- );
+const DashboardPage: React.FC = () => {
+ return (
+
+
+
+
+ Platform Information
+
+
+ {/* Add content for Platform Information */}
+
+
+
+
+ Participants
+
+
+ {/* Add content for Participants */}
+
+
+
+
+ Project Members
+
+
+ {/* Add content for Project Members */}
+
+
+
+
+ Completed Trials
+
+
+ {/* Add content for Completed Trials */}
+
+
+
+
+ );
};
-export default HomePage;
\ No newline at end of file
+export default DashboardPage;
\ No newline at end of file
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index c56279c..6d1ae34 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,5 +1,6 @@
import { ClerkProvider } from '@clerk/nextjs'
import { Inter } from "next/font/google"
+import { StudyProvider } from '~/context/StudyContext'
import "~/styles/globals.css"
@@ -10,7 +11,7 @@ const inter = Inter({
})
export const metadata = {
- title: "T3 App",
+ title: "HRIStudio",
description: "Created with create-t3-app",
icons: [{ rel: "icon", url: "/favicon.ico" }],
}
@@ -18,13 +19,13 @@ export const metadata = {
export default function RootLayout({ children }: React.PropsWithChildren) {
return (
- {/* */}
+
{children}
- {/* */}
+
)
}
\ No newline at end of file
diff --git a/src/app/participants/page.tsx b/src/app/participants/page.tsx
new file mode 100644
index 0000000..69c1bcc
--- /dev/null
+++ b/src/app/participants/page.tsx
@@ -0,0 +1,12 @@
+import Layout from "~/components/layout";
+import { Participants } from "~/components/participant/Participants";
+
+const ParticipantsPage = () => {
+ return (
+
+
+
+ );
+};
+
+export default ParticipantsPage;
\ No newline at end of file
diff --git a/src/app/sign-up/[[...sign-up]]/page.tsx b/src/app/sign-up/[[...sign-up]]/page.tsx
index 8b35c9c..8b3b523 100644
--- a/src/app/sign-up/[[...sign-up]]/page.tsx
+++ b/src/app/sign-up/[[...sign-up]]/page.tsx
@@ -29,6 +29,20 @@ export default function SignUpPage() {
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) {
diff --git a/src/app/studies/page.tsx b/src/app/studies/page.tsx
new file mode 100644
index 0000000..8c1c7c4
--- /dev/null
+++ b/src/app/studies/page.tsx
@@ -0,0 +1,10 @@
+import Layout from "~/components/layout";
+import { Studies } from "~/components/study/Studies";
+
+export default function StudiesPage() {
+ return (
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/Studies.tsx b/src/components/Studies.tsx
deleted file mode 100644
index 2b4888f..0000000
--- a/src/components/Studies.tsx
+++ /dev/null
@@ -1,122 +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 { Input } from "~/components/ui/input";
-import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog";
-
-interface Study {
- id: number;
- title: string;
- description: string;
-}
-
-export function Studies() {
- const [studies, setStudies] = useState([]);
- const [newStudy, setNewStudy] = useState({ title: '', description: '' });
- const [editingStudy, setEditingStudy] = useState(null);
-
- useEffect(() => {
- fetchStudies();
- }, []);
-
- const fetchStudies = async () => {
- const response = await fetch('/api/studies');
- const data = await response.json();
- setStudies(data);
- };
-
- const createStudy = async () => {
- const response = await fetch('/api/studies', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(newStudy),
- });
- const createdStudy = await response.json();
- setStudies([...studies, createdStudy]);
- setNewStudy({ title: '', description: '' });
- };
-
- const updateStudy = async () => {
- if (!editingStudy) return;
- const response = await fetch('/api/studies', {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(editingStudy),
- });
- const updatedStudy = await response.json();
- setStudies(studies.map(s => s.id === updatedStudy.id ? updatedStudy : s));
- setEditingStudy(null);
- };
-
- const deleteStudy = async (id: number) => {
- await fetch('/api/studies', {
- method: 'DELETE',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ id }),
- });
- setStudies(studies.filter(s => s.id !== id));
- };
-
- return (
-
- );
-}
\ No newline at end of file
diff --git a/src/components/layout.tsx b/src/components/layout.tsx
new file mode 100644
index 0000000..0233804
--- /dev/null
+++ b/src/components/layout.tsx
@@ -0,0 +1,19 @@
+import { PropsWithChildren } from "react";
+import { Sidebar } from "~/components/sidebar";
+import { StudyHeader } from "~/components/study/StudyHeader";
+
+const Layout = ({ children }: PropsWithChildren) => {
+ return (
+
+
+
+
+
+ {children}
+
+
+
+ );
+};
+
+export default Layout;
\ No newline at end of file
diff --git a/src/components/participant/CreateParticipantDialog.tsx b/src/components/participant/CreateParticipantDialog.tsx
new file mode 100644
index 0000000..fb5f317
--- /dev/null
+++ b/src/components/participant/CreateParticipantDialog.tsx
@@ -0,0 +1,52 @@
+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 (
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/participant/Participants.tsx b/src/components/participant/Participants.tsx
new file mode 100644
index 0000000..def6d41
--- /dev/null
+++ b/src/components/participant/Participants.tsx
@@ -0,0 +1,112 @@
+"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 { Trash2 } from 'lucide-react';
+import { useToast } from '~/hooks/use-toast';
+
+export function Participants() {
+ const [participants, setParticipants] = useState([]);
+ const { selectedStudy } = useStudyContext();
+ const { toast } = useToast();
+
+ useEffect(() => {
+ if (selectedStudy) {
+ fetchParticipants();
+ }
+ }, [selectedStudy]);
+
+ const fetchParticipants = async () => {
+ if (!selectedStudy) return;
+ const response = await fetch(`/api/participants?studyId=${selectedStudy.id}`);
+ const data = await response.json();
+ setParticipants(data);
+ };
+
+ 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 {
+ console.log(`Attempting to delete participant with ID: ${id}`);
+ const response = await fetch(`/api/participants/${id}`, {
+ method: 'DELETE',
+ });
+ console.log('Delete response:', response);
+
+ const contentType = response.headers.get("content-type");
+ if (contentType && contentType.indexOf("application/json") !== -1) {
+ const result = await response.json();
+ console.log('Delete result:', result);
+
+ if (!response.ok) {
+ throw new Error(result.error || `Failed to delete participant. Status: ${response.status}`);
+ }
+
+ setParticipants(participants.filter(p => p.id !== id));
+ toast({
+ title: "Success",
+ description: "Participant deleted successfully",
+ });
+ } else {
+ const text = await response.text();
+ console.error('Unexpected response:', text);
+ throw new Error(`Unexpected response from server. Status: ${response.status}`);
+ }
+ } catch (error) {
+ console.error('Error deleting participant:', error);
+ toast({
+ title: "Error",
+ description: error instanceof Error ? error.message : 'Failed to delete participant',
+ variant: "destructive",
+ });
+ }
+ };
+
+ if (!selectedStudy) {
+ return Please select a study to manage participants.
;
+ }
+
+ return (
+
+
+ Participants for {selectedStudy.title}
+
+
+
+ {participants.length > 0 ? (
+
+ {participants.map(participant => (
+ -
+ {participant.name}
+
+
+ ))}
+
+ ) : (
+ No participants added yet.
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx
index 2ffd6e5..e4b9798 100644
--- a/src/components/sidebar.tsx
+++ b/src/components/sidebar.tsx
@@ -19,8 +19,8 @@ import { cn } from "~/lib/utils"
const navItems = [
{ name: "Dashboard", href: "/dash", icon: LayoutDashboard },
- { name: "Projects", href: "/projects", icon: FolderIcon },
- { name: "Experiments", href: "/experiments", icon: BeakerIcon },
+ { name: "Studies", href: "/studies", icon: FolderIcon },
+ { name: "Participants", href: "/participants", icon: BeakerIcon },
{ name: "Data Analysis", href: "/analysis", icon: BarChartIcon },
{ name: "Settings", href: "/settings", icon: Settings },
];
diff --git a/src/components/study/CreateStudyDialog.tsx b/src/components/study/CreateStudyDialog.tsx
new file mode 100644
index 0000000..3526974
--- /dev/null
+++ b/src/components/study/CreateStudyDialog.tsx
@@ -0,0 +1,90 @@
+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) => 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) => {
+ 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 (
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/study/Studies.tsx b/src/components/study/Studies.tsx
new file mode 100644
index 0000000..aa64b97
--- /dev/null
+++ b/src/components/study/Studies.tsx
@@ -0,0 +1,28 @@
+"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 (
+
+ {studies.map((study) => (
+
+
+ {study.title}
+
+
+ {study.description}
+
+
+
+
+
+ ))}
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/study/StudyHeader.tsx b/src/components/study/StudyHeader.tsx
new file mode 100644
index 0000000..8edc4b5
--- /dev/null
+++ b/src/components/study/StudyHeader.tsx
@@ -0,0 +1,75 @@
+"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';
+
+export function StudyHeader() {
+ 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) => {
+ 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) => {
+ const createdStudy = await createStudy(newStudy);
+ setSelectedStudy(createdStudy);
+ localStorage.setItem('selectedStudyId', createdStudy.id.toString());
+ };
+
+ return (
+
+
+
+
+
+
+ {selectedStudy ? selectedStudy.title : 'Select a Study'}
+
+
+
+ {selectedStudy ? selectedStudy.title : 'No study selected'}
+
+
+
+
+
+ ) => handleCreateStudy(study as Study)} />
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/study/StudySelector.tsx b/src/components/study/StudySelector.tsx
new file mode 100644
index 0000000..ee76bc9
--- /dev/null
+++ b/src/components/study/StudySelector.tsx
@@ -0,0 +1,31 @@
+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 (
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx
new file mode 100644
index 0000000..8f40738
--- /dev/null
+++ b/src/components/ui/label.tsx
@@ -0,0 +1,26 @@
+"use client"
+
+import * as React from "react"
+import * as LabelPrimitive from "@radix-ui/react-label"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "~/lib/utils"
+
+const labelVariants = cva(
+ "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+)
+
+const Label = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, ...props }, ref) => (
+
+))
+Label.displayName = LabelPrimitive.Root.displayName
+
+export { Label }
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
new file mode 100644
index 0000000..d7901ee
--- /dev/null
+++ b/src/components/ui/select.tsx
@@ -0,0 +1,164 @@
+"use client"
+
+import * as React from "react"
+import {
+ CaretSortIcon,
+ CheckIcon,
+ ChevronDownIcon,
+ ChevronUpIcon,
+} from "@radix-ui/react-icons"
+import * as SelectPrimitive from "@radix-ui/react-select"
+
+import { cn } from "~/lib/utils"
+
+const Select = SelectPrimitive.Root
+
+const SelectGroup = SelectPrimitive.Group
+
+const SelectValue = SelectPrimitive.Value
+
+const SelectTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+ span]:line-clamp-1",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+
+))
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
+
+const SelectScrollUpButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
+
+const SelectScrollDownButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+SelectScrollDownButton.displayName =
+ SelectPrimitive.ScrollDownButton.displayName
+
+const SelectContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, position = "popper", ...props }, ref) => (
+
+
+
+
+ {children}
+
+
+
+
+))
+SelectContent.displayName = SelectPrimitive.Content.displayName
+
+const SelectLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SelectLabel.displayName = SelectPrimitive.Label.displayName
+
+const SelectItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+SelectItem.displayName = SelectPrimitive.Item.displayName
+
+const SelectSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName
+
+export {
+ Select,
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectLabel,
+ SelectItem,
+ SelectSeparator,
+ SelectScrollUpButton,
+ SelectScrollDownButton,
+}
diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..44f6dc0
--- /dev/null
+++ b/src/components/ui/textarea.tsx
@@ -0,0 +1,24 @@
+import * as React from "react"
+
+import { cn } from "~/lib/utils"
+
+export interface TextareaProps
+ extends React.TextareaHTMLAttributes {}
+
+const Textarea = React.forwardRef(
+ ({ className, ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+Textarea.displayName = "Textarea"
+
+export { Textarea }
diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx
new file mode 100644
index 0000000..a4c6bc4
--- /dev/null
+++ b/src/components/ui/toast.tsx
@@ -0,0 +1,129 @@
+"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,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+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,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, variant, ...props }, ref) => {
+ return (
+
+ )
+})
+Toast.displayName = ToastPrimitives.Root.displayName
+
+const ToastAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastAction.displayName = ToastPrimitives.Action.displayName
+
+const ToastClose = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+ToastClose.displayName = ToastPrimitives.Close.displayName
+
+const ToastTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastTitle.displayName = ToastPrimitives.Title.displayName
+
+const ToastDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastDescription.displayName = ToastPrimitives.Description.displayName
+
+type ToastProps = React.ComponentPropsWithoutRef
+
+type ToastActionElement = React.ReactElement
+
+export {
+ type ToastProps,
+ type ToastActionElement,
+ ToastProvider,
+ ToastViewport,
+ Toast,
+ ToastTitle,
+ ToastDescription,
+ ToastClose,
+ ToastAction,
+}
diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx
new file mode 100644
index 0000000..f94998a
--- /dev/null
+++ b/src/components/ui/toaster.tsx
@@ -0,0 +1,35 @@
+"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 (
+
+ {toasts.map(function ({ id, title, description, action, ...props }) {
+ return (
+
+
+ {title && {title}}
+ {description && (
+ {description}
+ )}
+
+ {action}
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000..eda550e
--- /dev/null
+++ b/src/components/ui/tooltip.tsx
@@ -0,0 +1,30 @@
+"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,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+))
+TooltipContent.displayName = TooltipPrimitive.Content.displayName
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
diff --git a/src/context/StudyContext.tsx b/src/context/StudyContext.tsx
new file mode 100644
index 0000000..a97e48f
--- /dev/null
+++ b/src/context/StudyContext.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import React, { createContext, useContext, useState, useEffect } from 'react';
+import { Study } from '~/types/Study';
+
+interface StudyContextType {
+ selectedStudy: Study | null;
+ setSelectedStudy: (study: Study | null) => void;
+ validateAndSetSelectedStudy: (studyId: number) => Promise;
+ studies: Study[];
+ fetchAndSetStudies: () => Promise;
+}
+
+const StudyContext = createContext(undefined);
+
+export const StudyProvider: React.FC = ({ children }) => {
+ const [selectedStudy, setSelectedStudy] = useState(null);
+ const [studies, setStudies] = useState([]);
+
+ const fetchAndSetStudies = async () => {
+ const response = await fetch('/api/studies');
+ const fetchedStudies = await response.json();
+ setStudies(fetchedStudies);
+ };
+
+ 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();
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useStudyContext = () => {
+ const context = useContext(StudyContext);
+ if (context === undefined) {
+ throw new Error('useStudyContext must be used within a StudyProvider');
+ }
+ return context;
+};
\ No newline at end of file
diff --git a/src/hooks/use-toast.ts b/src/hooks/use-toast.ts
new file mode 100644
index 0000000..d6698ef
--- /dev/null
+++ b/src/hooks/use-toast.ts
@@ -0,0 +1,194 @@
+"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
+ }
+ | {
+ type: ActionType["DISMISS_TOAST"]
+ toastId?: ToasterToast["id"]
+ }
+ | {
+ type: ActionType["REMOVE_TOAST"]
+ toastId?: ToasterToast["id"]
+ }
+
+interface State {
+ toasts: ToasterToast[]
+}
+
+const toastTimeouts = new Map>()
+
+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
+
+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(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 }
diff --git a/src/hooks/useStudies.ts b/src/hooks/useStudies.ts
new file mode 100644
index 0000000..2def28f
--- /dev/null
+++ b/src/hooks/useStudies.ts
@@ -0,0 +1,55 @@
+import { useState, useEffect } from 'react';
+import { Study } from '../types/Study';
+import { useStudyContext } from '../context/StudyContext';
+
+export function useStudies() {
+ const [studies, setStudies] = useState([]);
+ 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) => {
+ 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 };
+}
\ No newline at end of file
diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts
index 963a953..2e88b6b 100644
--- a/src/server/db/schema.ts
+++ b/src/server/db/schema.ts
@@ -2,16 +2,14 @@
// https://orm.drizzle.team/docs/sql-schema-declaration
import { pgTableCreator } from "drizzle-orm/pg-core";
-import { ColumnBaseConfig, ColumnDataType, SQL, sql } from "drizzle-orm";
-import {
- index,
- pgTable,
- serial,
- integer,
- timestamp,
- varchar,
- ExtraConfigColumn,
+import {
+ serial,
+ varchar,
+ timestamp,
+ integer,
+ pgTable
} 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
@@ -21,37 +19,30 @@ import {
*/
export const createTable = pgTableCreator((name) => `hristudio_${name}`);
-export const posts = createTable(
- "post",
- {
- id: serial("id").primaryKey(),
- name: varchar("name", { length: 256 }),
- createdAt: timestamp("created_at", { withTimezone: true })
- .default(sql`CURRENT_TIMESTAMP`)
- .notNull(),
- updatedAt: timestamp("updated_at", { withTimezone: true }).$onUpdate(
- () => new Date()
- ),
- },
- (example: { name: SQL | Partial>>; }) => ({
- nameIndex: index("name_idx").on(example.name),
- })
-);
-
export const studies = createTable(
"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()
),
- },
- (study: { title: SQL | Partial>>; }) => ({
- titleIndex: index("title_idx").on(study.title),
- })
+ }
+);
+
+export const participants = createTable(
+ "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(),
+ }
);
\ No newline at end of file
diff --git a/src/types/Participant.ts b/src/types/Participant.ts
new file mode 100644
index 0000000..48fb438
--- /dev/null
+++ b/src/types/Participant.ts
@@ -0,0 +1,5 @@
+export interface Participant {
+ id: number;
+ name: string;
+ studyId: number;
+ }
\ No newline at end of file
diff --git a/src/types/Study.ts b/src/types/Study.ts
new file mode 100644
index 0000000..ac1edab
--- /dev/null
+++ b/src/types/Study.ts
@@ -0,0 +1,5 @@
+export interface Study {
+ id: number;
+ title: string;
+ description?: string;
+ }
\ No newline at end of file