Update participant and study API routes

This commit is contained in:
2024-09-25 22:13:29 -04:00
parent 33d36007c8
commit ccc3423953
36 changed files with 1448 additions and 228 deletions

4
.env
View File

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

@@ -4,6 +4,7 @@
### NextJS ###
# dependencies
/node_modules
.pnpm-store/
/.pnp
.pnp.js

View File

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

View File

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

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

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

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

@@ -0,0 +1,10 @@
import Layout from "~/components/layout";
import { Studies } from "~/components/study/Studies";
export default function StudiesPage() {
return (
<Layout>
<Studies />
</Layout>
);
}

View File

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

5
src/types/Study.ts Normal file
View File

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