mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 22:54:45 -05:00
Clean codebase- start from scratch
This commit is contained in:
@@ -1,46 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { path: string[] } }
|
||||
) {
|
||||
const { userId } = auth();
|
||||
if (!userId) {
|
||||
return new NextResponse('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
// Construct the file path relative to the project root
|
||||
const filePath = path.join(process.cwd(), 'content', ...params.path);
|
||||
|
||||
console.log('Attempting to read file:', filePath); // Add this log
|
||||
|
||||
try {
|
||||
const file = await fs.readFile(filePath);
|
||||
const response = new NextResponse(file);
|
||||
|
||||
// Determine content type based on file extension
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
switch (ext) {
|
||||
case '.pdf':
|
||||
response.headers.set('Content-Type', 'application/pdf');
|
||||
break;
|
||||
case '.png':
|
||||
response.headers.set('Content-Type', 'image/png');
|
||||
break;
|
||||
case '.jpg':
|
||||
case '.jpeg':
|
||||
response.headers.set('Content-Type', 'image/jpeg');
|
||||
break;
|
||||
default:
|
||||
response.headers.set('Content-Type', 'application/octet-stream');
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Error reading file:', error);
|
||||
return new NextResponse('File not found', { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "~/server/db";
|
||||
import { informedConsentForms, contents } from "~/server/db/schema";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const { userId } = auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) {
|
||||
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// First, get the content associated with this form
|
||||
const [form] = await db
|
||||
.select({
|
||||
contentId: informedConsentForms.contentId,
|
||||
location: contents.location,
|
||||
previewLocation: contents.previewLocation,
|
||||
})
|
||||
.from(informedConsentForms)
|
||||
.innerJoin(contents, eq(informedConsentForms.contentId, contents.id))
|
||||
.where(eq(informedConsentForms.id, id));
|
||||
|
||||
if (!form) {
|
||||
return NextResponse.json({ error: 'Form not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Delete the file and preview from the file system
|
||||
const fullPath = path.join(process.cwd(), form.location ?? '');
|
||||
const previewPath = path.join(process.cwd(), form.previewLocation ?? '');
|
||||
try {
|
||||
await fs.access(fullPath);
|
||||
await fs.unlink(fullPath);
|
||||
} catch (error) {
|
||||
console.warn(`File not found or couldn't be deleted: ${fullPath}`);
|
||||
}
|
||||
try {
|
||||
await fs.access(previewPath);
|
||||
await fs.unlink(previewPath);
|
||||
} catch (error) {
|
||||
console.warn(`Preview file not found or couldn't be deleted: ${previewPath}`);
|
||||
}
|
||||
|
||||
// Delete the form and content from the database
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(informedConsentForms).where(eq(informedConsentForms.id, id));
|
||||
await tx.delete(contents).where(eq(contents.id, form.contentId));
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: "Form and preview deleted successfully" });
|
||||
} catch (error) {
|
||||
console.error('Error deleting form:', error);
|
||||
return NextResponse.json({ error: 'Failed to delete form' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "~/server/db";
|
||||
import { contents, informedConsentForms, contentTypes } from "~/server/db/schema";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { saveFile } from "~/lib/fileStorage";
|
||||
import fs from 'fs/promises';
|
||||
import { studies, participants } from "~/server/db/schema";
|
||||
import { anonymizeParticipants } from "~/lib/permissions"; // Import the anonymize function
|
||||
|
||||
// Function to generate a random string
|
||||
const generateRandomString = (length: number) => {
|
||||
const characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += characters.charAt(Math.floor(Math.random() * characters.length));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { userId } = auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const forms = await db.select({
|
||||
id: informedConsentForms.id,
|
||||
title: contents.title,
|
||||
location: contents.location,
|
||||
previewLocation: contents.previewLocation,
|
||||
studyId: informedConsentForms.studyId,
|
||||
studyTitle: studies.title,
|
||||
participantId: informedConsentForms.participantId,
|
||||
participantName: participants.name,
|
||||
contentId: informedConsentForms.contentId,
|
||||
}).from(informedConsentForms)
|
||||
.innerJoin(contents, eq(informedConsentForms.contentId, contents.id))
|
||||
.innerJoin(studies, eq(informedConsentForms.studyId, studies.id))
|
||||
.innerJoin(participants, eq(informedConsentForms.participantId, participants.id));
|
||||
|
||||
// Anonymize participant names
|
||||
const anonymizedForms = forms.map(form => ({
|
||||
...form,
|
||||
participantName: `Participant ${form.participantId}` // Anonymizing logic
|
||||
}));
|
||||
|
||||
return NextResponse.json(anonymizedForms);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { userId } = auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File;
|
||||
const title = formData.get('title') as string;
|
||||
const studyId = formData.get('studyId') as string;
|
||||
const participantId = formData.get('participantId') as string;
|
||||
|
||||
if (!file || !title || !studyId || !participantId) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const [formContentType] = await db
|
||||
.select()
|
||||
.from(contentTypes)
|
||||
.where(eq(contentTypes.name, "Informed Consent Form"));
|
||||
|
||||
const [previewContentType] = await db
|
||||
.select()
|
||||
.from(contentTypes)
|
||||
.where(eq(contentTypes.name, "Preview Image"));
|
||||
|
||||
if (!formContentType || !previewContentType) {
|
||||
return NextResponse.json({ error: 'Content type not found' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Generate a random filename with the same extension
|
||||
const fileExtension = file.name.split('.').pop(); // Get the file extension
|
||||
const randomFileName = `${generateRandomString(12)}.${fileExtension}`; // Generate random filename with 12 characters
|
||||
const { pdfPath, previewPath } = await saveFile(file, `${formContentType.id}/${randomFileName}`, previewContentType.id);
|
||||
|
||||
const [content] = await db
|
||||
.insert(contents)
|
||||
.values({
|
||||
contentTypeId: formContentType.id,
|
||||
uploader: userId,
|
||||
location: pdfPath,
|
||||
previewLocation: previewPath,
|
||||
title: title,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!content) {
|
||||
throw new Error("Content not found");
|
||||
}
|
||||
|
||||
const [form] = await db
|
||||
.insert(informedConsentForms)
|
||||
.values({
|
||||
studyId: parseInt(studyId),
|
||||
participantId: parseInt(participantId),
|
||||
contentId: content.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return NextResponse.json(form);
|
||||
} catch (error) {
|
||||
console.error('Error uploading form:', error);
|
||||
return NextResponse.json({ error: 'Failed to upload form' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,32 @@
|
||||
import { db } from "~/server/db";
|
||||
import { participants } from "~/server/db/schema";
|
||||
import { NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { db } from "~/db";
|
||||
import { participants } from "~/db/schema";
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
console.log('DELETE route hit, params:', params);
|
||||
const id = parseInt(params.id);
|
||||
|
||||
if (isNaN(id)) {
|
||||
console.log('Invalid ID:', id);
|
||||
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
|
||||
}
|
||||
export async function DELETE(request: Request, { params }: { params: { id: string } }) {
|
||||
const { userId } = await auth();
|
||||
|
||||
try {
|
||||
console.log('Attempting to delete participant with ID:', id);
|
||||
const deletedParticipant = await db.delete(participants)
|
||||
.where(eq(participants.id, id))
|
||||
.returning();
|
||||
|
||||
console.log('Deleted participant:', deletedParticipant);
|
||||
|
||||
if (deletedParticipant.length === 0) {
|
||||
console.log('Participant not found');
|
||||
return NextResponse.json({ error: 'Participant not found' }, { status: 404 });
|
||||
if (!userId) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
console.log('Participant deleted successfully');
|
||||
return NextResponse.json({ message: "Participant deleted successfully" });
|
||||
} catch (error) {
|
||||
console.error('Error deleting participant:', error);
|
||||
return NextResponse.json({ error: 'Failed to delete participant', details: String(error) }, { status: 500 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const participantId = parseInt(id);
|
||||
|
||||
try {
|
||||
const result = await db
|
||||
.delete(participants)
|
||||
.where(eq(participants.id, participantId))
|
||||
.execute();
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return new NextResponse("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
return new NextResponse(null, { status: 204 }); // No content for successful deletion
|
||||
} catch (error) {
|
||||
console.error("Error deleting participant:", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,52 @@
|
||||
import { db } from "~/server/db";
|
||||
import { participants, trialParticipants, trials } from "~/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { auth } from "@clerk/nextjs/server"; // Import auth to get userId
|
||||
import { anonymizeParticipants } from "~/lib/permissions"; // Import the anonymize function
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { db } from "~/db";
|
||||
import { participants } from "~/db/schema";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { userId } = auth(); // Get the userId from auth
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const studyId = searchParams.get('studyId');
|
||||
|
||||
if (!studyId) {
|
||||
return NextResponse.json({ error: 'Study ID is required' }, { status: 400 });
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const participantsWithLatestTrial = await db
|
||||
.select({
|
||||
id: participants.id,
|
||||
name: participants.name,
|
||||
studyId: participants.studyId,
|
||||
createdAt: participants.createdAt,
|
||||
latestTrialTimestamp: sql<Date | null>`MAX(${trials.createdAt})`.as('latestTrialTimestamp')
|
||||
})
|
||||
.from(participants)
|
||||
.leftJoin(trialParticipants, eq(participants.id, trialParticipants.participantId))
|
||||
.leftJoin(trials, eq(trialParticipants.trialId, trials.id))
|
||||
.where(eq(participants.studyId, parseInt(studyId)))
|
||||
.groupBy(participants.id)
|
||||
.orderBy(sql`COALESCE(MAX(${trials.createdAt}), ${participants.createdAt}) DESC`);
|
||||
const url = new URL(request.url);
|
||||
const studyId = url.searchParams.get("studyId");
|
||||
|
||||
// Anonymize participant names
|
||||
const anonymizedParticipants = anonymizeParticipants(participantsWithLatestTrial, userId);
|
||||
if (!studyId) {
|
||||
return new NextResponse("Study ID is required", { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json(anonymizedParticipants);
|
||||
} catch (error) {
|
||||
console.error('Error in GET /api/participants:', error);
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
const participantList = await db
|
||||
.select()
|
||||
.from(participants)
|
||||
.where(eq(participants.studyId, parseInt(studyId)));
|
||||
|
||||
return NextResponse.json(participantList);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { name, studyId } = await request.json();
|
||||
if (!name || !studyId) {
|
||||
return NextResponse.json({ error: 'Name and Study ID are required' }, { status: 400 });
|
||||
}
|
||||
const newParticipant = await db.insert(participants).values({ name, studyId }).returning();
|
||||
return NextResponse.json(newParticipant[0]);
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const { name, studyId } = await request.json();
|
||||
|
||||
try {
|
||||
const participant = await db
|
||||
.insert(participants)
|
||||
.values({
|
||||
name,
|
||||
studyId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return NextResponse.json(participant[0]);
|
||||
} catch (error) {
|
||||
console.error("Error adding participant:", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
14
src/app/api/permissions/route.ts
Normal file
14
src/app/api/permissions/route.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { getUserPermissions } from "~/lib/permissions";
|
||||
|
||||
export async function GET() {
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const permissions = await getUserPermissions(userId);
|
||||
return NextResponse.json(Array.from(permissions));
|
||||
}
|
||||
@@ -1,86 +1,32 @@
|
||||
import { db } from "~/server/db";
|
||||
import { studies } from "~/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { db } from "~/db";
|
||||
import { studyTable } from "~/db/schema";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const { userId } = auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
export async function DELETE(request: Request, { params }: { params: { id: string } }) {
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) {
|
||||
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const study = await db.select().from(studies).where(and(eq(studies.id, id), eq(studies.userId, userId))).limit(1);
|
||||
|
||||
if (study.length === 0) {
|
||||
return NextResponse.json({ error: 'Study not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(study[0]);
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const { userId } = auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) {
|
||||
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { title, description } = await request.json();
|
||||
if (!title) {
|
||||
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const updatedStudy = await db
|
||||
.update(studies)
|
||||
.set({ title, description })
|
||||
.where(and(eq(studies.id, id), eq(studies.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (updatedStudy.length === 0) {
|
||||
return NextResponse.json({ error: 'Study not found or unauthorized' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(updatedStudy[0]);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const { userId } = auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) {
|
||||
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const deletedStudy = await db
|
||||
.delete(studies)
|
||||
.where(and(eq(studies.id, id), eq(studies.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (deletedStudy.length === 0) {
|
||||
return NextResponse.json({ error: 'Study not found or unauthorized' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: "Study deleted successfully" });
|
||||
}
|
||||
const { id } = await params;
|
||||
const studyId = parseInt(id);
|
||||
|
||||
try {
|
||||
const result = await db
|
||||
.delete(studyTable)
|
||||
.where(eq(studyTable.id, studyId))
|
||||
.execute();
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return new NextResponse("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
console.error("Error deleting study:", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,41 @@
|
||||
import { db } from "~/server/db";
|
||||
import { studies } from "~/server/db/schema";
|
||||
import { NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { db } from "~/db";
|
||||
import { studyTable } from "~/db/schema";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { userId } = auth();
|
||||
export async function GET() {
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const allStudies = await db.select().from(studies).where(eq(studies.userId, userId));
|
||||
return NextResponse.json(allStudies);
|
||||
const studies = await db
|
||||
.select()
|
||||
.from(studyTable)
|
||||
.where(eq(studyTable.userId, userId));
|
||||
// TODO: Open up to multiple users
|
||||
return NextResponse.json(studies);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { userId } = auth();
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const { title, description } = await request.json();
|
||||
if (!title) {
|
||||
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const newStudy = await db.insert(studies).values({ title, description, userId }).returning();
|
||||
return NextResponse.json(newStudy[0]);
|
||||
}
|
||||
const study = await db
|
||||
.insert(studyTable)
|
||||
.values({
|
||||
title,
|
||||
description,
|
||||
userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return NextResponse.json(study[0]);
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { db } from "~/server/db";
|
||||
import { trials, trialParticipants } from "~/server/db/schema";
|
||||
import { NextResponse } from "next/server";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
|
||||
export async function GET() {
|
||||
const allTrials = await db
|
||||
.select({
|
||||
id: trials.id,
|
||||
title: trials.title,
|
||||
participantIds: sql`ARRAY_AGG(${trialParticipants.participantId})`.as('participantIds'),
|
||||
})
|
||||
.from(trials)
|
||||
.leftJoin(trialParticipants, eq(trials.id, trialParticipants.trialId))
|
||||
.groupBy(trials.id);
|
||||
|
||||
return NextResponse.json(allTrials);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { title, participantIds } = await request.json();
|
||||
|
||||
if (!title || !Array.isArray(participantIds) || participantIds.some(id => typeof id !== 'number')) {
|
||||
return NextResponse.json({ error: 'Title and valid Participant IDs are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Insert the new trial into the trials table
|
||||
const newTrial = await db.insert(trials).values({ title }).returning();
|
||||
// Check if newTrial is defined and has at least one element
|
||||
if (!newTrial || newTrial.length === 0) {
|
||||
throw new Error('Failed to create a new trial');
|
||||
}
|
||||
// Insert the participant associations into the trial_participants table
|
||||
const trialId = newTrial[0]?.id; // Use optional chaining to safely get the ID of the newly created trial
|
||||
if (trialId === undefined) {
|
||||
throw new Error('Trial ID is undefined');
|
||||
}
|
||||
const trialParticipantEntries = participantIds.map(participantId => ({
|
||||
trialId,
|
||||
participantId,
|
||||
}));
|
||||
|
||||
await db.insert(trialParticipants).values(trialParticipantEntries);
|
||||
|
||||
return NextResponse.json(newTrial[0]);
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
const { id } = await request.json();
|
||||
await db.delete(trials).where(eq(trials.id, id));
|
||||
return NextResponse.json({ message: "Trial deleted successfully" });
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { db } from "~/server/db";
|
||||
import { users } from "~/server/db/schema";
|
||||
import { NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { email } = await request.json();
|
||||
|
||||
// Check if email is provided
|
||||
if (!email) {
|
||||
return NextResponse.json({ error: "Email is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if the user already exists
|
||||
const existingUser = await db.select().from(users).where(eq(users.email, email)).limit(1);
|
||||
if (existingUser) {
|
||||
return NextResponse.json({ error: "User already exists" }, { status: 409 });
|
||||
}
|
||||
|
||||
// Insert the new user into the database
|
||||
const newUser = await db.insert(users).values({ email }).returning();
|
||||
return NextResponse.json(newUser[0]);
|
||||
} catch (error) {
|
||||
console.error("Error creating user:", error);
|
||||
return NextResponse.json({ error: "Failed to create user" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Layout from "~/components/layout";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "~/components/ui/card";
|
||||
import { useStudyContext } from '~/context/StudyContext';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Avatar, AvatarFallback } from "~/components/ui/avatar";
|
||||
|
||||
interface ParticipantWithTrial {
|
||||
id: number;
|
||||
name: string;
|
||||
latestTrialTimestamp: string | null;
|
||||
createdAt: string; // Add createdAt to the interface
|
||||
}
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const { selectedStudy } = useStudyContext();
|
||||
const [participants, setParticipants] = useState<ParticipantWithTrial[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchParticipants = async () => {
|
||||
if (selectedStudy) {
|
||||
const response = await fetch(`/api/participants?studyId=${selectedStudy.id}`);
|
||||
const data = await response.json();
|
||||
setParticipants(data);
|
||||
}
|
||||
};
|
||||
|
||||
fetchParticipants();
|
||||
}, [selectedStudy]);
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'No trials yet';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout pageTitle="Dashboard">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card className="card-level-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Platform Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Add content for Platform Information */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="card-level-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Participants</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{participants.slice(0, 4).map(participant => (
|
||||
<Card key={participant.id} className="card-level-2 p-3 px-4 flex items-center">
|
||||
<Avatar className="mr-4">
|
||||
<AvatarFallback>{participant.name.split(' ').map(n => n[0]).join('')}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">{participant.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Last trial: {formatDate(participant.latestTrialTimestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{participants.length > 4 && (
|
||||
<div className="mt-4 text-center">
|
||||
<Link href="/participants">
|
||||
<Button variant="outline" className="text-blue-600 hover:underline">
|
||||
View More Participants
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Project Members</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Add content for Project Members */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Completed Trials</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Add content for Completed Trials */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
24
src/app/dashboard/layout.tsx
Normal file
24
src/app/dashboard/layout.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Sidebar } from "~/components/sidebar";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { StudyProvider } from "~/context/StudyContext";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<StudyProvider>
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className={cn(
|
||||
"flex-1 overflow-y-auto",
|
||||
"lg:pt-8 p-8",
|
||||
"pt-[calc(3.5rem+2rem)]"
|
||||
)}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</StudyProvider>
|
||||
);
|
||||
}
|
||||
8
src/app/dashboard/page.tsx
Normal file
8
src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
export default function Dashboard() {
|
||||
return (
|
||||
<div>
|
||||
<p>Dashboard</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
174
src/app/dashboard/participants/page.tsx
Normal file
174
src/app/dashboard/participants/page.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
} from "~/components/ui/select";
|
||||
|
||||
interface Study {
|
||||
id: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Participant {
|
||||
id: number;
|
||||
name: string;
|
||||
studyId: number;
|
||||
}
|
||||
|
||||
export default function Participants() {
|
||||
const [studies, setStudies] = useState<Study[]>([]);
|
||||
const [participants, setParticipants] = useState<Participant[]>([]);
|
||||
const [selectedStudyId, setSelectedStudyId] = useState<number | null>(null);
|
||||
const [participantName, setParticipantName] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStudies();
|
||||
}, []);
|
||||
|
||||
const fetchStudies = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/studies');
|
||||
const data = await response.json();
|
||||
setStudies(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching studies:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchParticipants = async (studyId: number) => {
|
||||
try {
|
||||
const response = await fetch(`/api/participants?studyId=${studyId}`);
|
||||
const data = await response.json();
|
||||
setParticipants(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching participants:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStudyChange = (studyId: string) => {
|
||||
const id = parseInt(studyId); // Convert the string to a number
|
||||
setSelectedStudyId(id);
|
||||
fetchParticipants(id);
|
||||
};
|
||||
|
||||
const addParticipant = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedStudyId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/participants`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: participantName,
|
||||
studyId: selectedStudyId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const newParticipant = await response.json();
|
||||
setParticipants([...participants, newParticipant]);
|
||||
setParticipantName("");
|
||||
} else {
|
||||
console.error('Error adding participant:', response.statusText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error adding participant:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteParticipant = async (id: number) => {
|
||||
try {
|
||||
const response = await fetch(`/api/participants/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setParticipants(participants.filter(participant => participant.id !== id));
|
||||
} else {
|
||||
console.error('Error deleting participant:', response.statusText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting participant:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-4">Manage Participants</h1>
|
||||
<div className="mb-4">
|
||||
<Label htmlFor="study">Select Study</Label>
|
||||
<Select onValueChange={handleStudyChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a study" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{studies.map((study) => (
|
||||
<SelectItem key={study.id} value={study.id.toString()}>
|
||||
{study.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Add New Participant</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={addParticipant} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Participant Name</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="name"
|
||||
value={participantName}
|
||||
onChange={(e) => setParticipantName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={!selectedStudyId}>
|
||||
<PlusIcon className="w-4 h-4 mr-2" />
|
||||
Add Participant
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="mt-4">
|
||||
<h2 className="text-xl font-semibold">Participant List</h2>
|
||||
<ul>
|
||||
{participants.map((participant) => (
|
||||
<li key={participant.id} className="flex justify-between items-center">
|
||||
<span>{participant.name}</span>
|
||||
<Button onClick={() => deleteParticipant(participant.id)} variant="destructive">
|
||||
<Trash2Icon className="w-4 h-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
src/app/dashboard/studies/page.tsx
Normal file
152
src/app/dashboard/studies/page.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter
|
||||
} from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { Label } from "~/components/ui/label";
|
||||
|
||||
interface Study {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function Studies() {
|
||||
const [studies, setStudies] = useState<Study[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newStudyTitle, setNewStudyTitle] = useState("");
|
||||
const [newStudyDescription, setNewStudyDescription] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetchStudies();
|
||||
}, []);
|
||||
|
||||
const fetchStudies = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/studies');
|
||||
const data = await response.json();
|
||||
setStudies(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching studies:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createStudy = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const response = await fetch('/api/studies', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: newStudyTitle,
|
||||
description: newStudyDescription,
|
||||
}),
|
||||
});
|
||||
const newStudy = await response.json();
|
||||
setStudies([...studies, newStudy]);
|
||||
setNewStudyTitle("");
|
||||
setNewStudyDescription("");
|
||||
} catch (error) {
|
||||
console.error('Error creating study:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteStudy = async (id: number) => {
|
||||
try {
|
||||
await fetch(`/api/studies/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
setStudies(studies.filter(study => study.id !== id));
|
||||
} catch (error) {
|
||||
console.error('Error deleting study:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-3xl font-bold">Studies</h1>
|
||||
</div>
|
||||
|
||||
<Card className="mb-8">
|
||||
<CardHeader>
|
||||
<CardTitle>Create New Study</CardTitle>
|
||||
<CardDescription>
|
||||
Add a new research study to your collection
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={createStudy} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Study Title</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="title"
|
||||
value={newStudyTitle}
|
||||
onChange={(e) => setNewStudyTitle(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={newStudyDescription}
|
||||
onChange={(e) => setNewStudyDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit">
|
||||
<PlusIcon className="w-4 h-4 mr-2" />
|
||||
Create Study
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{studies.map((study) => (
|
||||
<Card key={study.id}>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle>{study.title}</CardTitle>
|
||||
{study.description && (
|
||||
<CardDescription className="mt-1.5">
|
||||
{study.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="text-destructive" onClick={() => deleteStudy(study.id)}>
|
||||
<Trash2Icon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardFooter className="text-sm text-muted-foreground">
|
||||
Created: {new Date(study.createdAt).toLocaleDateString()}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -1,22 +0,0 @@
|
||||
import Layout from "~/components/layout";
|
||||
import { FormsGrid } from "~/components/forms/FormsGrid";
|
||||
import { UploadFormButton } from "~/components/forms/UploadFormButton";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "~/components/ui/card";
|
||||
|
||||
export default function FormsPage() {
|
||||
return (
|
||||
<Layout pageTitle="Forms">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex justify-between items-center">
|
||||
<span>Forms</span>
|
||||
<UploadFormButton />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FormsGrid />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,10 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 210 50% 98%;
|
||||
@@ -100,42 +104,3 @@
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 20%, 50%, 80%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
40% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
60% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-bounce {
|
||||
animation: bounce 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.spinner {
|
||||
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 4px solid #3498db;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@@ -1,31 +1,26 @@
|
||||
import { ClerkProvider } from '@clerk/nextjs'
|
||||
import { Inter } from "next/font/google"
|
||||
import { StudyProvider } from '~/context/StudyContext'
|
||||
import { ThemeProvider } from '~/components/ThemeProvider'
|
||||
import "~/styles/globals.css"
|
||||
import {
|
||||
ClerkProvider,
|
||||
SignInButton,
|
||||
SignedIn,
|
||||
SignedOut,
|
||||
UserButton
|
||||
} from '@clerk/nextjs'
|
||||
import './globals.css'
|
||||
import { Button } from '~/components/ui/button';
|
||||
import { Inter } from 'next/font/google';
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
variable: "--font-sans",
|
||||
})
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata = {
|
||||
title: "HRIStudio",
|
||||
description: "Created with create-t3-app",
|
||||
icons: [{ rel: "icon", url: "/favicon.ico" }],
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: React.PropsWithChildren) {
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<ClerkProvider>
|
||||
<html lang="en" className={inter.variable}>
|
||||
<body className="font-sans">
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<StudyProvider>
|
||||
{children}
|
||||
</StudyProvider>
|
||||
</ThemeProvider>
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
</ClerkProvider>
|
||||
|
||||
@@ -1,74 +1,38 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { redirect } from "next/navigation";
|
||||
'use client';
|
||||
|
||||
export default function HomePage() {
|
||||
const { userId } = auth();
|
||||
import { SignedIn, SignedOut, SignInButton, SignOutButton, UserButton, useUser } from "@clerk/nextjs";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
if (userId) {
|
||||
redirect("/dash");
|
||||
export default function Home() {
|
||||
const { user, isLoaded } = useUser(); // Get user information and loading state
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoaded) {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isLoaded]);
|
||||
|
||||
if (loading) {
|
||||
return <div>Loading...</div>; // Show a loading state while fetching user data
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-blue-100 to-white">
|
||||
<div className="container mx-auto px-4 py-16">
|
||||
<header className="text-center mb-16">
|
||||
<h1 className="text-5xl font-bold mb-4 text-blue-800">Welcome to HRIStudio</h1>
|
||||
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||
Empowering Human-Robot Interaction Research and Development
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center mb-16">
|
||||
<div>
|
||||
<h2 className="text-3xl font-semibold mb-4 text-blue-700">About HRIStudio</h2>
|
||||
<p className="text-lg text-gray-700 mb-4">
|
||||
HRIStudio is a cutting-edge platform designed to streamline the process of creating,
|
||||
managing, and analyzing Human-Robot Interaction experiments. Our suite of tools
|
||||
empowers researchers and developers to push the boundaries of HRI research.
|
||||
</p>
|
||||
<p className="text-lg text-gray-700 mb-4">
|
||||
With HRIStudio, you can:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-gray-700 mb-6">
|
||||
<li>Design complex interaction scenarios with ease</li>
|
||||
<li>Collect and analyze data in real-time</li>
|
||||
<li>Collaborate seamlessly with team members</li>
|
||||
<li>Visualize results with advanced reporting tools</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="relative aspect-video w-full">
|
||||
<Image
|
||||
src="/hristudio_laptop.png"
|
||||
alt="HRIStudio Interface on Laptop"
|
||||
fill
|
||||
style={{ objectFit: 'contain' }}
|
||||
// className="rounded-lg shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-semibold mb-4 text-blue-700">Join the HRI Revolution</h2>
|
||||
<p className="text-lg text-gray-700 mb-6">
|
||||
Whether you're a seasoned researcher or just starting in the field of Human-Robot Interaction,
|
||||
HRIStudio provides the tools and support you need to succeed.
|
||||
</p>
|
||||
<div className="space-x-4">
|
||||
<Link href="/sign-in" className="bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 rounded-full transition duration-300">
|
||||
Sign In
|
||||
</Link>
|
||||
<Link href="/sign-up" className="bg-green-600 hover:bg-green-700 text-white font-bold py-3 px-6 rounded-full transition duration-300">
|
||||
Sign Up
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="text-center text-gray-600">
|
||||
<p>© 2024 HRIStudio. All rights reserved.</p>
|
||||
</footer>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-8">
|
||||
<h1 className="text-3xl font-bold mb-4">Welcome to HRIStudio</h1>
|
||||
<SignedOut>
|
||||
<SignInButton>
|
||||
<Button>Sign In</Button>
|
||||
</SignInButton>
|
||||
</SignedOut>
|
||||
<SignedIn>
|
||||
<UserButton />
|
||||
<p className="mt-4">Signed in as: {user?.emailAddresses[0].emailAddress}</p> {/* Display user's email */}
|
||||
<SignOutButton>
|
||||
<Button>Sign Out</Button>
|
||||
</SignOutButton>
|
||||
</SignedIn>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import Layout from "~/components/layout";
|
||||
import { Participants } from "~/components/participant/Participants";
|
||||
|
||||
const ParticipantsPage = () => {
|
||||
return (
|
||||
<Layout pageTitle="Participants">
|
||||
<Participants />
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ParticipantsPage;
|
||||
@@ -1,143 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useSignIn, useSignUp } from "@clerk/nextjs"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { Separator } from "~/components/ui/separator"
|
||||
import Link from "next/link"
|
||||
import { FcGoogle } from "react-icons/fc"
|
||||
import { FaApple } from "react-icons/fa"
|
||||
|
||||
export default function SignInPage() {
|
||||
const { isLoaded, signIn, setActive } = useSignIn();
|
||||
const { signUp } = useSignUp();
|
||||
const [emailAddress, setEmailAddress] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!isLoaded) return;
|
||||
|
||||
try {
|
||||
const result = await signIn.create({
|
||||
identifier: emailAddress,
|
||||
password,
|
||||
});
|
||||
|
||||
if (result.status === "complete") {
|
||||
await setActive({ session: result.createdSessionId });
|
||||
router.push("/dash");
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as { errors?: { message: string }[] };
|
||||
console.error("Error:", error.errors?.[0]?.message ?? "Unknown error");
|
||||
|
||||
// If the error indicates the user does not exist, trigger sign-up
|
||||
if (error.errors?.[0]?.message.includes("not found")) {
|
||||
if (!signUp) {
|
||||
console.error("Sign-up functionality is not available.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const signUpResult = await signUp.create({
|
||||
emailAddress,
|
||||
password,
|
||||
});
|
||||
|
||||
if (signUpResult.status === "complete") {
|
||||
await setActive({ session: signUpResult.createdSessionId });
|
||||
|
||||
// Create a user entry in the database
|
||||
const response = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: emailAddress }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
console.error("Error creating user in database:", errorData.error);
|
||||
return; // Optionally handle the error (e.g., show a message to the user)
|
||||
}
|
||||
|
||||
router.push("/dash");
|
||||
}
|
||||
} catch (signUpErr) {
|
||||
const signUpError = signUpErr as { errors?: { message: string }[] };
|
||||
console.error("Sign-up Error:", signUpError.errors?.[0]?.message ?? "Unknown error");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const signInWith = (strategy: "oauth_google" | "oauth_apple") => {
|
||||
if (!isLoaded) return
|
||||
signIn.authenticateWithRedirect({
|
||||
strategy,
|
||||
redirectUrl: "/sso-callback",
|
||||
redirectUrlComplete: "/dash",
|
||||
}).catch((error) => {
|
||||
console.error("Authentication error:", error); // Handle any potential errors
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-blue-100 to-white flex items-center justify-center">
|
||||
<Card className="w-[350px]">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign in to HRIStudio</CardTitle>
|
||||
<CardDescription>Enter your email and password to sign in</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid w-full items-center gap-4">
|
||||
<Button variant="outline" onClick={() => signInWith("oauth_google")}>
|
||||
<FcGoogle className="mr-2 h-4 w-4" />
|
||||
Sign in with Google
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => signInWith("oauth_apple")}>
|
||||
<FaApple className="mr-2 h-4 w-4" />
|
||||
Sign in with Apple
|
||||
</Button>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid w-full items-center gap-4">
|
||||
<div className="flex flex-col space-y-1.5">
|
||||
<Input
|
||||
id="email"
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
value={emailAddress}
|
||||
onChange={(e) => setEmailAddress(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-1.5">
|
||||
<Input
|
||||
id="password"
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button className="w-full" type="submit">Sign In</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col">
|
||||
<p className="mt-4 text-sm text-center">
|
||||
Don't have an account?{" "}
|
||||
<Link href="/sign-up" className="text-blue-600 hover:underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useSignUp } from "@clerk/nextjs"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { Separator } from "~/components/ui/separator"
|
||||
import Link from "next/link"
|
||||
import { FcGoogle } from "react-icons/fc"
|
||||
import { FaApple } from "react-icons/fa"
|
||||
|
||||
export default function SignUpPage() {
|
||||
const { isLoaded, signUp, setActive } = useSignUp()
|
||||
const [emailAddress, setEmailAddress] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const router = useRouter()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!isLoaded) return
|
||||
|
||||
try {
|
||||
const result = await signUp.create({
|
||||
emailAddress,
|
||||
password,
|
||||
})
|
||||
|
||||
if (result.status === "complete") {
|
||||
await setActive({ session: result.createdSessionId })
|
||||
|
||||
// Create a user entry in the database
|
||||
const response = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: emailAddress }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
console.error("Error creating user in database:", errorData.error)
|
||||
return // Optionally handle the error (e.g., show a message to the user)
|
||||
}
|
||||
|
||||
router.push("/dash")
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as { errors?: { message: string }[] }; // Specify type
|
||||
console.error("Error:", error.errors?.[0]?.message ?? "Unknown error") // Use optional chaining
|
||||
}
|
||||
}
|
||||
|
||||
const signUpWith = (strategy: "oauth_google" | "oauth_apple") => {
|
||||
if (!isLoaded) return
|
||||
signUp.authenticateWithRedirect({
|
||||
strategy,
|
||||
redirectUrl: "/sso-callback",
|
||||
redirectUrlComplete: "/dash",
|
||||
}).catch((error) => {
|
||||
console.error("Authentication error:", error); // Handle any potential errors
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-blue-100 to-white flex items-center justify-center">
|
||||
<Card className="w-[350px]">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign up for HRIStudio</CardTitle>
|
||||
<CardDescription>Create an account to get started</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid w-full items-center gap-4">
|
||||
<Button variant="outline" onClick={() => signUpWith("oauth_google")}>
|
||||
<FcGoogle className="mr-2 h-4 w-4" />
|
||||
Sign up with Google
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => signUpWith("oauth_apple")}>
|
||||
<FaApple className="mr-2 h-4 w-4" />
|
||||
Sign up with Apple
|
||||
</Button>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid w-full items-center gap-4">
|
||||
<div className="flex flex-col space-y-1.5">
|
||||
<Input
|
||||
id="email"
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
value={emailAddress}
|
||||
onChange={(e) => setEmailAddress(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-1.5">
|
||||
<Input
|
||||
id="password"
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button className="w-full" type="submit">Sign Up</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col">
|
||||
<p className="mt-4 text-sm text-center">
|
||||
Already have an account?{" "}
|
||||
<Link href="/sign-in" className="text-blue-600 hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import Layout from "~/components/layout";
|
||||
import { Studies } from "~/components/study/Studies";
|
||||
|
||||
export default function StudiesPage() {
|
||||
return (
|
||||
<Layout pageTitle="Studies">
|
||||
<Studies />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import Layout from "~/components/layout";
|
||||
import { Trials } from "~/components/trial/Trials";
|
||||
|
||||
export default function TrialsPage() {
|
||||
return (
|
||||
<Layout pageTitle="Trials">
|
||||
<Trials />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
import { type ThemeProviderProps } from "next-themes/dist/types"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handleChange = () => {
|
||||
document.documentElement.classList.toggle('dark', mediaQuery.matches)
|
||||
}
|
||||
mediaQuery.addListener(handleChange)
|
||||
handleChange() // Initial check
|
||||
return () => mediaQuery.removeListener(handleChange)
|
||||
}, [])
|
||||
|
||||
if (!mounted) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { MoonIcon, SunIcon, LaptopIcon } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "~/components/ui/popover"
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!mounted) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<SunIcon className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<MoonIcon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-2">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setTheme('light')}
|
||||
className={theme === 'light' ? 'bg-accent' : ''}
|
||||
>
|
||||
<SunIcon className="h-4 w-4 mr-2" />
|
||||
Light
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setTheme('dark')}
|
||||
className={theme === 'dark' ? 'bg-accent' : ''}
|
||||
>
|
||||
<MoonIcon className="h-4 w-4 mr-2" />
|
||||
Dark
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setTheme('system')}
|
||||
className={theme === 'system' ? 'bg-accent' : ''}
|
||||
>
|
||||
<LaptopIcon className="h-4 w-4 mr-2" />
|
||||
System
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import Image from 'next/image';
|
||||
import { Card, CardContent, CardFooter } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
interface FormCardProps {
|
||||
form: {
|
||||
id: number;
|
||||
title: string;
|
||||
location: string;
|
||||
studyId: number;
|
||||
studyTitle: string;
|
||||
participantId: number;
|
||||
participantName: string;
|
||||
previewLocation: string;
|
||||
};
|
||||
onDelete: (formId: number) => void;
|
||||
}
|
||||
|
||||
export function FormCard({ form, onDelete }: FormCardProps) {
|
||||
const handleCardClick = () => {
|
||||
window.open(form.location, '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden cursor-pointer" onClick={handleCardClick} style={{ backgroundColor: 'var(--primary-card-background)' }}>
|
||||
<CardContent className="p-0 h-40 relative">
|
||||
<Image
|
||||
src={form.previewLocation}
|
||||
alt={form.title}
|
||||
fill
|
||||
className="object-cover object-top"
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = '/placeholder-image.png';
|
||||
console.error('Error loading image:', form.previewLocation);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start p-4" style={{ backgroundColor: 'var(--secondary-card-background)' }}>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<h3 className="font-semibold mb-2">{form.title}</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(form.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
<Badge variant="secondary">{form.studyTitle}</Badge>
|
||||
<Badge variant="outline">{form.participantName}</Badge>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { FormCard } from "~/components/forms/FormCard";
|
||||
import { useToast } from "~/hooks/use-toast";
|
||||
|
||||
interface Form {
|
||||
id: number;
|
||||
title: string;
|
||||
location: string;
|
||||
studyId: number;
|
||||
studyTitle: string;
|
||||
participantId: number;
|
||||
participantName: string;
|
||||
previewLocation: string;
|
||||
}
|
||||
|
||||
export function FormsGrid() {
|
||||
const [forms, setForms] = useState<Form[]>([]);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
fetchForms();
|
||||
}, []);
|
||||
|
||||
const fetchForms = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/forms");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch forms");
|
||||
}
|
||||
const data = await response.json();
|
||||
setForms(data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching forms:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to load forms. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (formId: number) => {
|
||||
try {
|
||||
const response = await fetch(`/api/forms/${formId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to delete form");
|
||||
}
|
||||
setForms(forms.filter((form) => form.id !== formId));
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Form deleted successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error deleting form:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to delete form. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
|
||||
{forms.map((form) => (
|
||||
<FormCard key={form.id} form={form} onDelete={handleDelete} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { useToast } from "~/hooks/use-toast";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import { useStudyContext } from "~/context/StudyContext";
|
||||
|
||||
export function UploadFormButton() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [participantId, setParticipantId] = useState("");
|
||||
const { toast } = useToast();
|
||||
const { selectedStudy } = useStudyContext();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!file || !title || !participantId || !selectedStudy) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Please fill in all fields and select a file.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("title", title);
|
||||
formData.append("studyId", selectedStudy.id.toString());
|
||||
formData.append("participantId", participantId);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/forms", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to upload form");
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Form uploaded successfully",
|
||||
});
|
||||
setIsOpen(false);
|
||||
setFile(null);
|
||||
setTitle("");
|
||||
setParticipantId("");
|
||||
} catch (error) {
|
||||
console.error("Error uploading form:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to upload form. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>Upload Form</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Upload New Form</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="participantId">Participant ID</Label>
|
||||
<Input
|
||||
id="participantId"
|
||||
value={participantId}
|
||||
onChange={(e) => setParticipantId(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="file">File</Label>
|
||||
<Input
|
||||
id="file"
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
onChange={(e) => setFile(e.target.files?.[0] || null)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit">Upload</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { PropsWithChildren } from "react";
|
||||
import { Sidebar } from "~/components/sidebar";
|
||||
import { StudyHeader } from "~/components/study/StudyHeader";
|
||||
import { Toaster } from "~/components/ui/toaster";
|
||||
|
||||
interface LayoutProps {
|
||||
pageTitle: string;
|
||||
}
|
||||
|
||||
const Layout = ({ children, pageTitle }: PropsWithChildren<LayoutProps>) => {
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-y-auto bg-gradient-to-b from-[hsl(var(--gradient-start))] to-[hsl(var(--gradient-end))]">
|
||||
<div className="container mx-auto space-y-4 p-4 pt-16 lg:pt-4">
|
||||
<StudyHeader pageTitle={pageTitle} />
|
||||
{children}
|
||||
<Toaster />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -1,52 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { PlusCircle } from 'lucide-react';
|
||||
|
||||
interface CreateParticipantDialogProps {
|
||||
onCreateParticipant: (name: string) => void;
|
||||
}
|
||||
|
||||
export function CreateParticipantDialog({ onCreateParticipant }: CreateParticipantDialogProps) {
|
||||
const [newParticipant, setNewParticipant] = useState({ name: '' });
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleCreate = () => {
|
||||
if (newParticipant.name) {
|
||||
onCreateParticipant(newParticipant.name);
|
||||
setNewParticipant({ name: '' });
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<PlusCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Participant</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="name" className="text-right">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={newParticipant.name}
|
||||
onChange={(e) => setNewParticipant({ name: e.target.value })}
|
||||
className="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleCreate}>Add Participant</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Card, CardContent, CardFooter } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Participant } from "../../types/Participant";
|
||||
|
||||
interface ParticipantCardProps {
|
||||
participant: Participant;
|
||||
onDelete: (participantId: number) => void;
|
||||
}
|
||||
|
||||
export function ParticipantCard({ participant, onDelete }: ParticipantCardProps) {
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="p-4">
|
||||
<h3 className="font-semibold mb-2">{participant.name}</h3>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end p-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
onClick={() => onDelete(participant.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { useStudyContext } from '../../context/StudyContext';
|
||||
import { Participant } from '../../types/Participant';
|
||||
import { CreateParticipantDialog } from './CreateParticipantDialog';
|
||||
import { useToast } from '~/hooks/use-toast';
|
||||
import { ParticipantCard } from './ParticipantCard';
|
||||
import { Avatar, AvatarFallback } from "~/components/ui/avatar";
|
||||
|
||||
interface ParticipantWithTrial {
|
||||
id: number;
|
||||
name: string;
|
||||
latestTrialTimestamp: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function Participants() {
|
||||
const [participants, setParticipants] = useState<ParticipantWithTrial[]>([]);
|
||||
const { selectedStudy } = useStudyContext();
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedStudy) {
|
||||
fetchParticipants();
|
||||
}
|
||||
}, [selectedStudy]);
|
||||
|
||||
const fetchParticipants = async () => {
|
||||
if (!selectedStudy) return;
|
||||
try {
|
||||
const response = await fetch(`/api/participants?studyId=${selectedStudy.id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
setParticipants(data);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse JSON:', text);
|
||||
throw new Error('Invalid JSON in response');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching participants:', error);
|
||||
// Handle the error appropriately, e.g., show a toast notification
|
||||
}
|
||||
};
|
||||
|
||||
const createParticipant = async (name: string) => {
|
||||
if (!selectedStudy) return;
|
||||
const response = await fetch('/api/participants', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, studyId: selectedStudy.id }),
|
||||
});
|
||||
const createdParticipant = await response.json();
|
||||
setParticipants([...participants, createdParticipant]);
|
||||
};
|
||||
|
||||
const deleteParticipant = async (id: number) => {
|
||||
if (!selectedStudy) return;
|
||||
try {
|
||||
const response = await fetch(`/api/participants/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete participant');
|
||||
}
|
||||
|
||||
setParticipants(participants.filter(p => p.id !== id));
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Participant deleted successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error instanceof Error ? error.message : 'Failed to delete participant',
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!selectedStudy) {
|
||||
return <div>Please select a study to manage participants.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="card-level-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-2xl font-bold">Participants for {selectedStudy.title}</CardTitle>
|
||||
<CreateParticipantDialog onCreateParticipant={createParticipant} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{participants.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{participants.map(participant => (
|
||||
<Card key={participant.id} className="card-level-2 p-3 flex items-center w-full">
|
||||
<Avatar className="mr-4">
|
||||
<AvatarFallback>{participant.name.split(' ').map(n => n[0]).join('')}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">{participant.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{participant.latestTrialTimestamp
|
||||
? `Last trial: ${new Date(participant.latestTrialTimestamp).toLocaleString()}`
|
||||
: 'No trials yet'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
onClick={() => deleteParticipant(participant.id)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p>No participants added yet.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import { UserButton, useUser } from "@clerk/nextjs"
|
||||
import {
|
||||
BarChartIcon,
|
||||
UsersRoundIcon,
|
||||
UsersRoundIcon,
|
||||
LandPlotIcon,
|
||||
BotIcon,
|
||||
FolderIcon,
|
||||
@@ -16,18 +16,17 @@ import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet"
|
||||
import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "~/components/ui/sheet"
|
||||
import { cn } from "~/lib/utils"
|
||||
import { ThemeToggle } from "~/components/ThemeToggle"
|
||||
|
||||
const navItems = [
|
||||
{ name: "Dashboard", href: "/dash", icon: LayoutDashboard },
|
||||
{ name: "Studies", href: "/studies", icon: FolderIcon },
|
||||
{ name: "Participants", href: "/participants", icon: UsersRoundIcon },
|
||||
{ name: "Trials", href: "/trials", icon: LandPlotIcon },
|
||||
{ name: "Forms", href: "/forms", icon: FileTextIcon },
|
||||
{ name: "Data Analysis", href: "/analysis", icon: BarChartIcon },
|
||||
{ name: "Settings", href: "/settings", icon: Settings },
|
||||
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
||||
{ name: "Studies", href: "/dashboard/studies", icon: FolderIcon },
|
||||
{ name: "Participants", href: "/dashboard/participants", icon: UsersRoundIcon },
|
||||
{ name: "Trials", href: "/dashboard/trials", icon: LandPlotIcon },
|
||||
{ name: "Forms", href: "/dashboard/forms", icon: FileTextIcon },
|
||||
{ name: "Data Analysis", href: "/dashboard/analysis", icon: BarChartIcon },
|
||||
{ name: "Settings", href: "/dashboard/settings", icon: Settings },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
@@ -36,7 +35,7 @@ export function Sidebar() {
|
||||
const { user } = useUser()
|
||||
|
||||
const HRIStudioLogo = () => (
|
||||
<Link href="/dash" className="flex items-center font-sans text-xl text-[hsl(var(--sidebar-foreground))]">
|
||||
<Link href="/dashboard" className="flex items-center font-sans text-xl text-[hsl(var(--sidebar-foreground))]">
|
||||
<BotIcon className="h-6 w-6 mr-1 text-[hsl(var(--sidebar-muted))]" />
|
||||
<span className="font-extrabold">HRI</span>
|
||||
<span className="font-normal">Studio</span>
|
||||
@@ -78,7 +77,6 @@ export function Sidebar() {
|
||||
<p className="text-xs text-[hsl(var(--sidebar-muted))]">{user?.primaryEmailAddress?.emailAddress ?? 'user@example.com'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,6 +94,7 @@ export function Sidebar() {
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="top" className="w-full">
|
||||
<SheetTitle className="sr-only">Navigation Menu</SheetTitle>
|
||||
<SidebarContent />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { PlusCircle } from 'lucide-react';
|
||||
import { Study } from '../../types/Study';
|
||||
|
||||
interface CreateStudyDialogProps {
|
||||
onCreateStudy: (study: Omit<Study, 'id'>) => void;
|
||||
}
|
||||
|
||||
export function CreateStudyDialog({ onCreateStudy }: CreateStudyDialogProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [newStudy, setNewStudy] = useState({ title: '', description: '' });
|
||||
const [touched, setTouched] = useState({ title: false, description: false });
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setNewStudy({ ...newStudy, [name]: value });
|
||||
setTouched({ ...touched, [name]: true });
|
||||
};
|
||||
|
||||
const isFieldInvalid = (field: 'title' | 'description') => {
|
||||
return field === 'title' ? (touched.title && !newStudy.title) : false;
|
||||
};
|
||||
|
||||
const handleCreateStudy = () => {
|
||||
setTouched({ title: true, description: true });
|
||||
|
||||
if (!newStudy.title) {
|
||||
return;
|
||||
}
|
||||
|
||||
onCreateStudy({
|
||||
title: newStudy.title,
|
||||
description: newStudy.description || undefined
|
||||
});
|
||||
|
||||
setNewStudy({ title: '', description: '' });
|
||||
setTouched({ title: false, description: false });
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<PlusCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Study</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="title" className="text-right">
|
||||
Title
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
className={`col-span-3 ${isFieldInvalid('title') ? 'border-red-500' : ''}`}
|
||||
value={newStudy.title}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
{isFieldInvalid('title') && (
|
||||
<p className="text-red-500 text-sm col-span-4">Title is required</p>
|
||||
)}
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="description" className="text-right">
|
||||
Description
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
className="col-span-3"
|
||||
value={newStudy.description}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleCreateStudy}>Create Study</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { useStudies } from '~/hooks/useStudies';
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
export function Studies() {
|
||||
const { studies, deleteStudy } = useStudies();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{studies.map((study) => (
|
||||
<Card key={study.id}>
|
||||
<CardHeader>
|
||||
<CardTitle>{study.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>{study.description}</p>
|
||||
<div className="flex space-x-2 mt-2">
|
||||
<Button variant="destructive" onClick={() => deleteStudy(study.id)}>Delete</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/components/ui/tooltip";
|
||||
import { useStudyContext } from '~/context/StudyContext';
|
||||
import { StudySelector } from './StudySelector';
|
||||
import { CreateStudyDialog } from '~/components/study/CreateStudyDialog';
|
||||
import { Study } from '~/types/Study';
|
||||
|
||||
interface StudyHeaderProps {
|
||||
pageTitle: string;
|
||||
}
|
||||
|
||||
export const StudyHeader: React.FC<StudyHeaderProps> = ({ pageTitle }) => {
|
||||
const { studies, selectedStudy, setSelectedStudy, validateAndSetSelectedStudy, fetchAndSetStudies } = useStudyContext();
|
||||
|
||||
useEffect(() => {
|
||||
const savedStudyId = localStorage.getItem('selectedStudyId');
|
||||
if (savedStudyId) {
|
||||
validateAndSetSelectedStudy(parseInt(savedStudyId, 10));
|
||||
}
|
||||
}, [validateAndSetSelectedStudy]);
|
||||
|
||||
const handleStudyChange = (studyId: string) => {
|
||||
const study = studies.find(s => s.id.toString() === studyId);
|
||||
if (study) {
|
||||
setSelectedStudy(study);
|
||||
localStorage.setItem('selectedStudyId', studyId);
|
||||
}
|
||||
};
|
||||
|
||||
const createStudy = async (newStudy: Omit<Study, "id">) => {
|
||||
const response = await fetch('/api/studies', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newStudy),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create study');
|
||||
}
|
||||
const createdStudy = await response.json();
|
||||
await fetchAndSetStudies();
|
||||
return createdStudy;
|
||||
};
|
||||
|
||||
const handleCreateStudy = async (newStudy: Omit<Study, "id">) => {
|
||||
const createdStudy = await createStudy(newStudy);
|
||||
setSelectedStudy(createdStudy);
|
||||
localStorage.setItem('selectedStudyId', createdStudy.id.toString());
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="mt-2 lg:mt-0">
|
||||
<CardContent className="flex justify-between items-center p-4">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<h2 className="text-2xl font-bold truncate max-w-[200px]">
|
||||
{pageTitle}
|
||||
</h2>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{selectedStudy ? selectedStudy.title : 'No study selected'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div className="flex items-center space-x-2">
|
||||
<StudySelector
|
||||
studies={studies}
|
||||
selectedStudy={selectedStudy}
|
||||
onStudyChange={handleStudyChange}
|
||||
/>
|
||||
<CreateStudyDialog onCreateStudy={(study: Omit<Study, "id">) => handleCreateStudy(study as Study)} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
||||
import { Study } from '../../types/Study';
|
||||
|
||||
interface StudySelectorProps {
|
||||
studies: Study[];
|
||||
selectedStudy: Study | null;
|
||||
onStudyChange: (studyId: string) => void;
|
||||
}
|
||||
|
||||
export function StudySelector({ studies, selectedStudy, onStudyChange }: StudySelectorProps) {
|
||||
return (
|
||||
<Select onValueChange={onStudyChange} value={selectedStudy?.id?.toString() || ""}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Select a study" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{studies.length > 0 ? (
|
||||
studies.map((study) => (
|
||||
<SelectItem key={study.id} value={study.id.toString()}>
|
||||
{study.title}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value="no-studies" disabled className="text-gray-400 italic">
|
||||
No studies available
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog";
|
||||
import { Label } from "~/components/ui/label";
|
||||
|
||||
interface CreateTrialDialogProps {
|
||||
onCreateTrial: (title: string, participantIds: number[]) => void;
|
||||
}
|
||||
|
||||
export function CreateTrialDialog({ onCreateTrial }: CreateTrialDialogProps) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [participantIds, setParticipantIds] = useState<string>('');
|
||||
|
||||
const handleCreate = () => {
|
||||
const ids = participantIds.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id));
|
||||
if (title && ids.length > 0) {
|
||||
onCreateTrial(title, ids);
|
||||
setTitle('');
|
||||
setParticipantIds('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">Add Trial</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Trial</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="title" className="text-right">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="participants" className="text-right">Participant IDs (comma-separated)</Label>
|
||||
<Input
|
||||
id="participants"
|
||||
value={participantIds}
|
||||
onChange={(e) => setParticipantIds(e.target.value)}
|
||||
className="col-span-3"
|
||||
placeholder="e.g. 1, 2, 3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleCreate}>Add Trial</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { useToast } from '~/hooks/use-toast';
|
||||
import { CreateTrialDialog } from '~/components/trial/CreateTrialDialog';
|
||||
|
||||
interface Trial {
|
||||
id: number;
|
||||
title: string;
|
||||
participantIds: number[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function Trials() {
|
||||
const [trials, setTrials] = useState<Trial[]>([]);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
fetchTrials();
|
||||
}, []);
|
||||
|
||||
const fetchTrials = async () => {
|
||||
const response = await fetch('/api/trials');
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Error fetching trials:', response.status, errorText);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data || data.length === 0) {
|
||||
console.warn('No trials found');
|
||||
setTrials([]); // Set to an empty array if no trials are found
|
||||
return;
|
||||
}
|
||||
|
||||
setTrials(data);
|
||||
};
|
||||
|
||||
const createTrial = async (title: string, participantIds: number[]) => {
|
||||
const response = await fetch('/api/trials', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, participantIds }),
|
||||
});
|
||||
const newTrial = await response.json();
|
||||
setTrials([...trials, newTrial]);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Trial created successfully",
|
||||
});
|
||||
};
|
||||
|
||||
const deleteTrial = async (id: number) => {
|
||||
const response = await fetch(`/api/trials/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setTrials(trials.filter(trial => trial.id !== id));
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Trial deleted successfully",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to delete trial",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="card-level-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-2xl font-bold">Trials</CardTitle>
|
||||
<CreateTrialDialog onCreateTrial={createTrial} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{trials.length > 0 ? (
|
||||
<div className="grid sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{trials.map(trial => (
|
||||
<Card key={trial.id} className="card-level-2 p-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold">{trial.title}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
Participants: {trial.participantIds ? trial.participantIds.join(', ') : 'None'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
onClick={() => deleteTrial(trial.id)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p>No trials added yet.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
@@ -1,36 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -35,7 +35,7 @@ const CardTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight text-2xl", className)}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { Cross2Icon } from "@radix-ui/react-icons"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@@ -2,14 +2,16 @@ import * as React from "react"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
// Use React.InputHTMLAttributes<HTMLInputElement> directly in the component
|
||||
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
@@ -1,31 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
@@ -1,129 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Cross2Icon } from "@radix-ui/react-icons"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useToast } from "~/hooks/use-toast"
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "~/components/ui/toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -1,77 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { Study } from '~/types/Study';
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
|
||||
interface StudyContextType {
|
||||
selectedStudy: Study | null;
|
||||
setSelectedStudy: (study: Study | null) => void;
|
||||
validateAndSetSelectedStudy: (studyId: number) => Promise<void>;
|
||||
studies: Study[];
|
||||
fetchAndSetStudies: () => Promise<void>;
|
||||
selectedStudyId: number | null;
|
||||
setSelectedStudyId: (id: number | null) => void;
|
||||
}
|
||||
|
||||
const StudyContext = createContext<StudyContextType | undefined>(undefined);
|
||||
|
||||
export const StudyProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
|
||||
const [selectedStudy, setSelectedStudy] = useState<Study | null>(null);
|
||||
const [studies, setStudies] = useState<Study[]>([]);
|
||||
|
||||
const fetchAndSetStudies = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/studies');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch studies');
|
||||
}
|
||||
const fetchedStudies = await response.json();
|
||||
setStudies(fetchedStudies);
|
||||
} catch (error) {
|
||||
console.error('Error fetching studies:', error);
|
||||
setStudies([]);
|
||||
}
|
||||
};
|
||||
|
||||
const validateAndSetSelectedStudy = async (studyId: number) => {
|
||||
const existingStudy = studies.find(s => s.id === studyId);
|
||||
if (existingStudy) {
|
||||
setSelectedStudy(existingStudy);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/studies/${studyId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Study not found');
|
||||
}
|
||||
const study = await response.json();
|
||||
setSelectedStudy(study);
|
||||
} catch (error) {
|
||||
console.warn(`Study with id ${studyId} not found`);
|
||||
setSelectedStudy(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAndSetStudies();
|
||||
}, []);
|
||||
export const StudyProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [selectedStudyId, setSelectedStudyId] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<StudyContext.Provider value={{
|
||||
selectedStudy,
|
||||
setSelectedStudy,
|
||||
validateAndSetSelectedStudy,
|
||||
studies,
|
||||
fetchAndSetStudies
|
||||
}}>
|
||||
<StudyContext.Provider value={{ selectedStudyId, setSelectedStudyId }}>
|
||||
{children}
|
||||
</StudyContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useStudyContext = () => {
|
||||
export const useStudy = () => {
|
||||
const context = useContext(StudyContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useStudyContext must be used within a StudyProvider');
|
||||
if (!context) {
|
||||
throw new Error('useStudy must be used within a StudyProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
};
|
||||
|
||||
4
src/db/index.ts
Normal file
4
src/db/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { sql } from '@vercel/postgres';
|
||||
import { drizzle } from 'drizzle-orm/vercel-postgres';
|
||||
|
||||
export const db = drizzle(sql);
|
||||
68
src/db/schema.ts
Normal file
68
src/db/schema.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { sql, relations } from 'drizzle-orm';
|
||||
import { integer, pgTable, serial, text, timestamp, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const usersTable = pgTable("users", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
name: varchar({ length: 255 }).notNull(),
|
||||
age: integer().notNull(),
|
||||
email: varchar({ length: 255 }).notNull().unique(),
|
||||
});
|
||||
|
||||
export const studyTable = pgTable("study", {
|
||||
id: serial("id").primaryKey(),
|
||||
title: varchar("title", { length: 256 }).notNull(),
|
||||
description: varchar("description", { length: 1000 }),
|
||||
userId: varchar("user_id", { length: 256 }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.$onUpdate(() => new Date()),
|
||||
});
|
||||
|
||||
export const participants = pgTable(
|
||||
"participant",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
name: varchar("name", { length: 256 }).notNull(),
|
||||
studyId: integer("study_id").references(() => studyTable.id).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
}
|
||||
);
|
||||
|
||||
export const roles = pgTable("roles", {
|
||||
id: serial("id").primaryKey(),
|
||||
name: varchar("name", { length: 256 }).notNull().unique(),
|
||||
description: text("description"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const permissions = pgTable("permissions", {
|
||||
id: serial("id").primaryKey(),
|
||||
name: varchar("name", { length: 256 }).notNull().unique(),
|
||||
description: text("description"),
|
||||
code: varchar("code", { length: 100 }).notNull().unique(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const rolePermissions = pgTable("role_permissions", {
|
||||
roleId: integer("role_id").references(() => roles.id).notNull(),
|
||||
permissionId: integer("permission_id").references(() => permissions.id).notNull(),
|
||||
});
|
||||
|
||||
export const userRoles = pgTable("user_roles", {
|
||||
userId: varchar("user_id", { length: 256 }).notNull(),
|
||||
roleId: integer("role_id").references(() => roles.id).notNull(),
|
||||
});
|
||||
|
||||
// Add relations
|
||||
export const rolesRelations = relations(roles, ({ many }) => ({
|
||||
permissions: many(rolePermissions),
|
||||
users: many(userRoles),
|
||||
}));
|
||||
|
||||
export const permissionsRelations = relations(permissions, ({ many }) => ({
|
||||
roles: many(rolePermissions),
|
||||
}));
|
||||
44
src/env.js
44
src/env.js
@@ -1,44 +0,0 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
export const env = createEnv({
|
||||
/**
|
||||
* Specify your server-side environment variables schema here. This way you can ensure the app
|
||||
* isn't built with invalid env vars.
|
||||
*/
|
||||
server: {
|
||||
DATABASE_URL: z.string().url(),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "test", "production"])
|
||||
.default("development"),
|
||||
},
|
||||
|
||||
/**
|
||||
* Specify your client-side environment variables schema here. This way you can ensure the app
|
||||
* isn't built with invalid env vars. To expose them to the client, prefix them with
|
||||
* `NEXT_PUBLIC_`.
|
||||
*/
|
||||
client: {
|
||||
// NEXT_PUBLIC_CLIENTVAR: z.string(),
|
||||
},
|
||||
|
||||
/**
|
||||
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
|
||||
* middlewares) or client-side so we need to destruct manually.
|
||||
*/
|
||||
runtimeEnv: {
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
||||
},
|
||||
/**
|
||||
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
|
||||
* useful for Docker builds.
|
||||
*/
|
||||
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
|
||||
/**
|
||||
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
|
||||
* `SOME_VAR=''` will throw an error.
|
||||
*/
|
||||
emptyStringAsUndefined: true,
|
||||
});
|
||||
@@ -1,194 +0,0 @@
|
||||
"use client"
|
||||
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from "react"
|
||||
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "~/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
36
src/hooks/usePermissions.ts
Normal file
36
src/hooks/usePermissions.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useUser } from "@clerk/nextjs";
|
||||
import { useEffect, useState } from "react";
|
||||
import { PERMISSIONS, type PermissionCode } from "~/lib/permissions";
|
||||
|
||||
export function usePermissions() {
|
||||
const { user } = useUser();
|
||||
const [permissions, setPermissions] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchPermissions() {
|
||||
if (!user?.id) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/permissions');
|
||||
const data = await response.json();
|
||||
setPermissions(new Set(data));
|
||||
} catch (error) {
|
||||
console.error('Error fetching permissions:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchPermissions();
|
||||
}, [user?.id]);
|
||||
|
||||
const hasPermission = (permission: PermissionCode) => {
|
||||
return permissions.has(PERMISSIONS[permission]);
|
||||
};
|
||||
|
||||
return {
|
||||
hasPermission,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Study } from '../types/Study';
|
||||
import { useStudyContext } from '../context/StudyContext';
|
||||
|
||||
export function useStudies() {
|
||||
const [studies, setStudies] = useState<Study[]>([]);
|
||||
const { selectedStudy, setSelectedStudy } = useStudyContext();
|
||||
|
||||
useEffect(() => {
|
||||
fetchStudies();
|
||||
}, []);
|
||||
|
||||
const fetchStudies = async () => {
|
||||
const response = await fetch('/api/studies');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch studies');
|
||||
}
|
||||
const data = await response.json();
|
||||
setStudies(data);
|
||||
};
|
||||
|
||||
const handleStudyChange = (studyId: string) => {
|
||||
const study = studies.find(s => s.id.toString() === studyId);
|
||||
setSelectedStudy(study || null);
|
||||
};
|
||||
|
||||
const addStudy = async (newStudy: Omit<Study, 'id'>) => {
|
||||
const response = await fetch('/api/studies', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newStudy),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create study');
|
||||
}
|
||||
const createdStudy = await response.json();
|
||||
setStudies(prevStudies => [...prevStudies, createdStudy]);
|
||||
setSelectedStudy(createdStudy);
|
||||
};
|
||||
|
||||
const deleteStudy = async (id: number) => {
|
||||
const response = await fetch(`/api/studies/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete study');
|
||||
}
|
||||
setStudies(studies.filter(s => s.id !== id));
|
||||
if (selectedStudy?.id === id) {
|
||||
setSelectedStudy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return { studies, selectedStudy, handleStudyChange, addStudy, deleteStudy };
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { fromBuffer } from 'pdf2pic';
|
||||
|
||||
const CONTENT_DIR = path.join(process.cwd(), 'content');
|
||||
|
||||
export async function saveFile(file: File, filePath: string, previewContentTypeId: number): Promise<{ pdfPath: string; previewPath: string }> {
|
||||
const fullPath = path.join(CONTENT_DIR, filePath);
|
||||
const dir = path.dirname(fullPath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await fs.writeFile(fullPath, buffer);
|
||||
|
||||
// Generate preview image
|
||||
const previewFileName = path.basename(filePath, '.pdf') + '_preview.png';
|
||||
const previewDir = path.join(CONTENT_DIR, previewContentTypeId.toString());
|
||||
await fs.mkdir(previewDir, { recursive: true });
|
||||
const previewPath = path.join(previewDir, previewFileName);
|
||||
|
||||
const options = {
|
||||
density: 100,
|
||||
saveFilename: path.basename(previewPath, '.png'),
|
||||
savePath: previewDir,
|
||||
format: "png",
|
||||
width: 600,
|
||||
height: 800
|
||||
};
|
||||
|
||||
const convert = fromBuffer(buffer, options);
|
||||
const result = await convert(1);
|
||||
|
||||
// Rename the file to remove the ".1" suffix
|
||||
const generatedFilePath = result.path;
|
||||
if (generatedFilePath) {
|
||||
await fs.rename(generatedFilePath, previewPath);
|
||||
}
|
||||
|
||||
// Return relative paths that can be used in URLs
|
||||
return {
|
||||
pdfPath: `/content/${filePath}`,
|
||||
previewPath: `/content/${previewContentTypeId}/${previewFileName}`
|
||||
};
|
||||
}
|
||||
@@ -1,20 +1,48 @@
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { Participant } from "../types/Participant";
|
||||
import { db } from "~/db";
|
||||
import { permissions, rolePermissions, userRoles } from "~/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export const isUserAuthorized = (userId: string | null): boolean => {
|
||||
// Implement your logic to determine if the user is authorized to see participant names
|
||||
// For example, you might check if the user is an admin or has a specific role
|
||||
// return userId !== null; // Placeholder logic, replace with your actual authorization logic
|
||||
return false;
|
||||
};
|
||||
// Define permission codes
|
||||
export const PERMISSIONS = {
|
||||
VIEW_PARTICIPANT_NAMES: 'view_participant_names',
|
||||
CREATE_PARTICIPANT: 'create_participant',
|
||||
DELETE_PARTICIPANT: 'delete_participant',
|
||||
CREATE_STUDY: 'create_study',
|
||||
DELETE_STUDY: 'delete_study',
|
||||
MANAGE_ROLES: 'manage_roles',
|
||||
} as const;
|
||||
|
||||
export const anonymizeParticipants = (participants: Participant[], userId: string | null): Participant[] => {
|
||||
if (isUserAuthorized(userId)) {
|
||||
return participants; // Return original participants if authorized
|
||||
}
|
||||
export type PermissionCode = keyof typeof PERMISSIONS;
|
||||
|
||||
return participants.map(participant => ({
|
||||
...participant,
|
||||
name: `Participant ${participant.id}`, // Anonymize the name
|
||||
}));
|
||||
};
|
||||
// Cache user permissions
|
||||
const userPermissionsCache = new Map<string, Set<string>>();
|
||||
|
||||
export async function getUserPermissions(userId: string): Promise<Set<string>> {
|
||||
// Check cache first
|
||||
const cached = userPermissionsCache.get(userId);
|
||||
if (cached) return cached;
|
||||
|
||||
// Query permissions from database
|
||||
const userPerms = await db
|
||||
.select({
|
||||
permissionCode: permissions.code,
|
||||
})
|
||||
.from(userRoles)
|
||||
.leftJoin(rolePermissions, eq(userRoles.roleId, rolePermissions.roleId))
|
||||
.leftJoin(permissions, eq(rolePermissions.permissionId, permissions.id))
|
||||
.where(eq(userRoles.userId, userId));
|
||||
const permSet = new Set<string>(userPerms.map(p => p.permissionCode).filter((code): code is string => code !== null));
|
||||
userPermissionsCache.set(userId, permSet);
|
||||
|
||||
return permSet;
|
||||
}
|
||||
|
||||
export async function hasPermission(userId: string, permissionCode: string): Promise<boolean> {
|
||||
const userPerms = await getUserPermissions(userId);
|
||||
return userPerms.has(permissionCode);
|
||||
}
|
||||
|
||||
// Clear cache for user
|
||||
export function clearUserPermissionsCache(userId: string) {
|
||||
userPermissionsCache.delete(userId);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
|
||||
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
|
||||
|
||||
const isPublicRoute = createRouteMatcher(['/sign-in(.*)', '/sign-up(.*)', '/'])
|
||||
const isProtectedRoute = createRouteMatcher(['/dashboard(.*)'])
|
||||
|
||||
export default clerkMiddleware((auth, request) => {
|
||||
if (!isPublicRoute(request)) {
|
||||
auth().protect()
|
||||
}
|
||||
export default clerkMiddleware(async (auth, req) => {
|
||||
if (isProtectedRoute(req)) await auth.protect()
|
||||
})
|
||||
|
||||
export const config = {
|
||||
@@ -14,7 +12,5 @@ export const config = {
|
||||
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
|
||||
// Always run for API routes
|
||||
'/(api|trpc)(.*)',
|
||||
// Add this line to include the /content route
|
||||
'/content/(.*)',
|
||||
],
|
||||
}
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
|
||||
import { env } from "~/env";
|
||||
import * as schema from "./schema";
|
||||
|
||||
/**
|
||||
* Cache the database connection in development. This avoids creating a new connection on every HMR
|
||||
* update.
|
||||
*/
|
||||
const globalForDb = globalThis as unknown as {
|
||||
conn: postgres.Sql | undefined;
|
||||
};
|
||||
|
||||
const conn = globalForDb.conn ?? postgres(env.DATABASE_URL);
|
||||
if (env.NODE_ENV !== "production") globalForDb.conn = conn;
|
||||
|
||||
export const db = drizzle(conn, { schema });
|
||||
|
||||
import { initializeContentTypes, initializeRoles } from "./init";
|
||||
|
||||
// Initialize content types
|
||||
initializeContentTypes().catch(console.error);
|
||||
initializeRoles().catch(console.error);
|
||||
@@ -1,32 +0,0 @@
|
||||
import { db } from "./index";
|
||||
import { contentTypes } from "./schema";
|
||||
import { roles } from "./schema";
|
||||
|
||||
export async function initializeContentTypes() {
|
||||
const existingTypes = await db.select().from(contentTypes);
|
||||
|
||||
if (existingTypes.length === 0) {
|
||||
await db.insert(contentTypes).values([
|
||||
{ name: "Informed Consent Form" },
|
||||
{ name: "Preview Image" }, // New content type
|
||||
// Add other content types as needed
|
||||
]);
|
||||
console.log("Content types initialized");
|
||||
} else {
|
||||
console.log("Content types already initialized");
|
||||
}
|
||||
}
|
||||
|
||||
export async function initializeRoles() {
|
||||
const existingRoles = await db.select().from(roles);
|
||||
|
||||
if (existingRoles.length === 0) {
|
||||
await db.insert(roles).values([
|
||||
{ name: "Basic User" }, // Role ID 0
|
||||
{ name: "Admin" }, // Role ID 1
|
||||
]);
|
||||
console.log("Roles initialized");
|
||||
} else {
|
||||
console.log("Roles already initialized");
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
// Example model schema from the Drizzle docs
|
||||
// https://orm.drizzle.team/docs/sql-schema-declaration
|
||||
|
||||
import { pgTable } from "drizzle-orm/pg-core";
|
||||
import {
|
||||
serial,
|
||||
varchar,
|
||||
timestamp,
|
||||
integer,
|
||||
boolean
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
||||
* database instance for multiple projects.
|
||||
*
|
||||
* @see https://orm.drizzle.team/docs/goodies#multi-project-schema
|
||||
*/
|
||||
export const studies = pgTable(
|
||||
"study",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
title: varchar("title", { length: 256 }).notNull(),
|
||||
description: varchar("description", { length: 1000 }),
|
||||
userId: varchar("user_id", { length: 256 }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).$onUpdate(
|
||||
() => new Date()
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
export const participants = pgTable(
|
||||
"participant",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
name: varchar("name", { length: 256 }).notNull(),
|
||||
studyId: integer("study_id").references(() => studies.id).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
}
|
||||
);
|
||||
|
||||
export const contentTypes = pgTable(
|
||||
"content_type",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
name: varchar("name", { length: 50 }).notNull().unique(),
|
||||
}
|
||||
);
|
||||
|
||||
export const contents = pgTable(
|
||||
"content",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
contentTypeId: integer("content_type_id").references(() => contentTypes.id).notNull(),
|
||||
uploader: varchar("uploader", { length: 256 }).notNull(),
|
||||
location: varchar("location", { length: 1000 }).notNull(),
|
||||
previewLocation: varchar("preview_location", { length: 1000 }),
|
||||
title: varchar("title", { length: 256 }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
}
|
||||
);
|
||||
|
||||
export const informedConsentForms = pgTable(
|
||||
"informed_consent_form",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
studyId: integer("study_id").references(() => studies.id).notNull(),
|
||||
participantId: integer("participant_id").references(() => participants.id).notNull(),
|
||||
contentId: integer("content_id").references(() => contents.id).notNull(),
|
||||
uploadedAt: timestamp("uploaded_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
}
|
||||
);
|
||||
|
||||
export const users = pgTable(
|
||||
"user",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
email: varchar("email", { length: 256 }).notNull().unique(),
|
||||
roleId: integer("role_id").references(() => roles.id).default(0), // Link to roles
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
}
|
||||
);
|
||||
|
||||
export const trials = pgTable(
|
||||
"trial",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
title: varchar("title", { length: 256 }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
}
|
||||
);
|
||||
|
||||
export const trialParticipants = pgTable(
|
||||
"trial_participants",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
trialId: integer("trial_id").references(() => trials.id).notNull(),
|
||||
participantId: integer("participant_id").references(() => participants.id).notNull(),
|
||||
}
|
||||
);
|
||||
|
||||
export const roles = pgTable(
|
||||
"role",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
name: varchar("name", { length: 50 }).notNull().unique(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
}
|
||||
);
|
||||
|
||||
export const permissions = pgTable(
|
||||
"permission",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
name: varchar("name", { length: 50 }).notNull().unique(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
}
|
||||
);
|
||||
|
||||
export const rolePermissions = pgTable(
|
||||
"role_permissions",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
roleId: integer("role_id").references(() => roles.id).notNull(),
|
||||
permissionId: integer("permission_id").references(() => permissions.id).notNull(),
|
||||
}
|
||||
);
|
||||
|
||||
export const permissionTypes = pgTable(
|
||||
"permission_type",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
name: varchar("name", { length: 50 }).notNull().unique(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
}
|
||||
);
|
||||
@@ -1,5 +0,0 @@
|
||||
export interface Participant {
|
||||
id: number;
|
||||
name: string;
|
||||
studyId: number;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export interface Study {
|
||||
id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user