mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 22:54:45 -05:00
Update participant and study API routes
This commit is contained in:
4
.env
4
.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
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,6 +4,7 @@
|
||||
### NextJS ###
|
||||
# dependencies
|
||||
/node_modules
|
||||
.pnpm-store/
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
37
src/app/api/participants/[id]/route.ts
Normal file
37
src/app/api/participants/[id]/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
25
src/app/api/participants/route.ts
Normal file
25
src/app/api/participants/route.ts
Normal file
@@ -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]);
|
||||
}
|
||||
86
src/app/api/studies/[id]/route.ts
Normal file
86
src/app/api/studies/[id]/route.ts
Normal file
@@ -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" });
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
21
src/app/api/users/route.ts
Normal file
21
src/app/api/users/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<html lang="en">
|
||||
<body className={`font-sans ${inter.variable}`}>
|
||||
<div className="flex h-screen">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="min-h-screen bg-gradient-to-b from-blue-100 to-white pt-14 lg:pt-0">
|
||||
<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 the HRIStudio Dashboard!</h1>
|
||||
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||
Manage your Human-Robot Interaction projects and experiments
|
||||
</p>
|
||||
</header>
|
||||
<Studies />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const DashboardPage: React.FC = () => {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Platform Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Add content for Platform Information */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Participants</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Add content for Participants */}
|
||||
</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 HomePage;
|
||||
export default DashboardPage;
|
||||
@@ -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 (
|
||||
<ClerkProvider>
|
||||
{/* <ThemeProvider attribute="class" defaultTheme="system" enableSystem> */}
|
||||
<StudyProvider>
|
||||
<html lang="en" className={inter.variable}>
|
||||
<body className="font-sans">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
{/* </ThemeProvider> */}
|
||||
</StudyProvider>
|
||||
</ClerkProvider>
|
||||
)
|
||||
}
|
||||
12
src/app/participants/page.tsx
Normal file
12
src/app/participants/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import Layout from "~/components/layout";
|
||||
import { Participants } from "~/components/participant/Participants";
|
||||
|
||||
const ParticipantsPage = () => {
|
||||
return (
|
||||
<Layout>
|
||||
<Participants />
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ParticipantsPage;
|
||||
@@ -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) {
|
||||
|
||||
10
src/app/studies/page.tsx
Normal file
10
src/app/studies/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import Layout from "~/components/layout";
|
||||
import { Studies } from "~/components/study/Studies";
|
||||
|
||||
export default function StudiesPage() {
|
||||
return (
|
||||
<Layout>
|
||||
<Studies />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -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<Study[]>([]);
|
||||
const [newStudy, setNewStudy] = useState({ title: '', description: '' });
|
||||
const [editingStudy, setEditingStudy] = useState<Study | null>(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 (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-bold">Studies</h2>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button>Create New Study</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Study</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
placeholder="Title"
|
||||
value={newStudy.title}
|
||||
onChange={(e) => setNewStudy({ ...newStudy, title: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Description"
|
||||
value={newStudy.description}
|
||||
onChange={(e) => setNewStudy({ ...newStudy, description: e.target.value })}
|
||||
/>
|
||||
<Button onClick={createStudy}>Create</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{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">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" onClick={() => setEditingStudy(study)}>Edit</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Study</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
placeholder="Title"
|
||||
value={editingStudy?.title || ''}
|
||||
onChange={(e) => setEditingStudy({ ...editingStudy!, title: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Description"
|
||||
value={editingStudy?.description || ''}
|
||||
onChange={(e) => setEditingStudy({ ...editingStudy!, description: e.target.value })}
|
||||
/>
|
||||
<Button onClick={updateStudy}>Update</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button variant="destructive" onClick={() => deleteStudy(study.id)}>Delete</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
src/components/layout.tsx
Normal file
19
src/components/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { PropsWithChildren } from "react";
|
||||
import { Sidebar } from "~/components/sidebar";
|
||||
import { StudyHeader } from "~/components/study/StudyHeader";
|
||||
|
||||
const Layout = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<div className="flex h-screen bg-gradient-to-b from-blue-100 to-white">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-y-auto p-4 pt-16 lg:pt-4">
|
||||
<div className="container mx-auto space-y-4">
|
||||
<StudyHeader />
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
52
src/components/participant/CreateParticipantDialog.tsx
Normal file
52
src/components/participant/CreateParticipantDialog.tsx
Normal file
@@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
112
src/components/participant/Participants.tsx
Normal file
112
src/components/participant/Participants.tsx
Normal file
@@ -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<Participant[]>([]);
|
||||
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 <div>Please select a study to manage participants.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<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 ? (
|
||||
<ul className="space-y-2">
|
||||
{participants.map(participant => (
|
||||
<li key={participant.id} className="bg-gray-100 p-2 rounded flex justify-between items-center">
|
||||
<span>{participant.name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => deleteParticipant(participant.id)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p>No participants added yet.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
90
src/components/study/CreateStudyDialog.tsx
Normal file
90
src/components/study/CreateStudyDialog.tsx
Normal file
@@ -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<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>
|
||||
);
|
||||
}
|
||||
28
src/components/study/Studies.tsx
Normal file
28
src/components/study/Studies.tsx
Normal file
@@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
75
src/components/study/StudyHeader.tsx
Normal file
75
src/components/study/StudyHeader.tsx
Normal file
@@ -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<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]">
|
||||
{selectedStudy ? selectedStudy.title : 'Select a Study'}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
31
src/components/study/StudySelector.tsx
Normal file
31
src/components/study/StudySelector.tsx
Normal file
@@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
26
src/components/ui/label.tsx
Normal file
26
src/components/ui/label.tsx
Normal file
@@ -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<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
164
src/components/ui/select.tsx
Normal file
164
src/components/ui/select.tsx
Normal file
@@ -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<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<CaretSortIcon className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
24
src/components/ui/textarea.tsx
Normal file
24
src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm 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}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
129
src/components/ui/toast.tsx
Normal file
129
src/components/ui/toast.tsx
Normal file
@@ -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<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,
|
||||
}
|
||||
35
src/components/ui/toaster.tsx
Normal file
35
src/components/ui/toaster.tsx
Normal file
@@ -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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
30
src/components/ui/tooltip.tsx
Normal file
30
src/components/ui/tooltip.tsx
Normal file
@@ -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<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 }
|
||||
69
src/context/StudyContext.tsx
Normal file
69
src/context/StudyContext.tsx
Normal file
@@ -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<void>;
|
||||
studies: Study[];
|
||||
fetchAndSetStudies: () => Promise<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 () => {
|
||||
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 (
|
||||
<StudyContext.Provider value={{
|
||||
selectedStudy,
|
||||
setSelectedStudy,
|
||||
validateAndSetSelectedStudy,
|
||||
studies,
|
||||
fetchAndSetStudies
|
||||
}}>
|
||||
{children}
|
||||
</StudyContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useStudyContext = () => {
|
||||
const context = useContext(StudyContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useStudyContext must be used within a StudyProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
194
src/hooks/use-toast.ts
Normal file
194
src/hooks/use-toast.ts
Normal file
@@ -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<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 }
|
||||
55
src/hooks/useStudies.ts
Normal file
55
src/hooks/useStudies.ts
Normal file
@@ -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<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 };
|
||||
}
|
||||
@@ -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,
|
||||
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<unknown> | Partial<ExtraConfigColumn<ColumnBaseConfig<ColumnDataType, string>>>; }) => ({
|
||||
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<unknown> | Partial<ExtraConfigColumn<ColumnBaseConfig<ColumnDataType, string>>>; }) => ({
|
||||
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(),
|
||||
}
|
||||
);
|
||||
5
src/types/Participant.ts
Normal file
5
src/types/Participant.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface Participant {
|
||||
id: number;
|
||||
name: string;
|
||||
studyId: number;
|
||||
}
|
||||
5
src/types/Study.ts
Normal file
5
src/types/Study.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface Study {
|
||||
id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user