diff --git a/.gitignore b/.gitignore
index eadb5b3..3cf36b9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -58,3 +58,5 @@ pnpm-lock.yaml
# End of https://www.toptal.com/developers/gitignore/api/nextjs,react
pnpm-lock.yaml
+
+/content
\ No newline at end of file
diff --git a/package.json b/package.json
index 074d96b..ddfc4cb 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
+ "@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
diff --git a/src/app/api/informed-consent/route.ts b/src/app/api/informed-consent/route.ts
new file mode 100644
index 0000000..a0ef527
--- /dev/null
+++ b/src/app/api/informed-consent/route.ts
@@ -0,0 +1,67 @@
+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';
+
+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 studyId = formData.get('studyId') as string;
+ const participantId = formData.get('participantId') as string;
+
+ if (!file || !studyId || !participantId) {
+ return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
+ }
+
+ try {
+ const [contentType] = await db
+ .select()
+ .from(contentTypes)
+ .where(eq(contentTypes.name, "Informed Consent Form"));
+
+ if (!contentType) {
+ return NextResponse.json({ error: 'Content type not found' }, { status: 500 });
+ }
+
+ const [content] = await db
+ .insert(contents)
+ .values({
+ contentTypeId: contentType.id,
+ uploader: userId,
+ location: '', // We'll update this after saving the file
+ })
+ .returning();
+
+ if (!content) {
+ throw new Error("Content not found");
+ }
+
+ const fileLocation = await saveFile(file, content.id);
+
+ await db
+ .update(contents)
+ .set({ location: fileLocation })
+ .where(eq(contents.id, content.id));
+
+ 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 informed consent form:', error);
+ return NextResponse.json({ error: 'Failed to upload form' }, { status: 500 });
+ }
+}
\ No newline at end of file
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 6d1ae34..f67bf0a 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,7 +1,7 @@
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"
const inter = Inter({
@@ -19,13 +19,15 @@ export const metadata = {
export default function RootLayout({ children }: React.PropsWithChildren) {
return (
-
-
-
- {children}
-
-
-
+
+
+
+
+ {children}
+
+
+
+
)
}
\ No newline at end of file
diff --git a/src/components/ThemeProvider.tsx b/src/components/ThemeProvider.tsx
new file mode 100644
index 0000000..e953b44
--- /dev/null
+++ b/src/components/ThemeProvider.tsx
@@ -0,0 +1,35 @@
+"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 (
+
+ {children}
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx
new file mode 100644
index 0000000..7e22b95
--- /dev/null
+++ b/src/components/ThemeToggle.tsx
@@ -0,0 +1,67 @@
+"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 (
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/layout.tsx b/src/components/layout.tsx
index 0233804..d3fd3e7 100644
--- a/src/components/layout.tsx
+++ b/src/components/layout.tsx
@@ -1,15 +1,17 @@
import { PropsWithChildren } from "react";
import { Sidebar } from "~/components/sidebar";
import { StudyHeader } from "~/components/study/StudyHeader";
+import { Toaster } from "~/components/ui/toaster";
const Layout = ({ children }: PropsWithChildren) => {
return (
-
+
-
-
diff --git a/src/components/participant/Participants.tsx b/src/components/participant/Participants.tsx
index def6d41..7a23cf2 100644
--- a/src/components/participant/Participants.tsx
+++ b/src/components/participant/Participants.tsx
@@ -8,6 +8,7 @@ import { Participant } from '../../types/Participant';
import { CreateParticipantDialog } from './CreateParticipantDialog';
import { Trash2 } from 'lucide-react';
import { useToast } from '~/hooks/use-toast';
+import { UploadConsentForm } from './UploadConsentForm';
export function Participants() {
const [participants, setParticipants] = useState([]);
@@ -88,18 +89,21 @@ export function Participants() {
{participants.length > 0 ? (
-
+
diff --git a/src/components/participant/UploadConsentForm.tsx b/src/components/participant/UploadConsentForm.tsx
new file mode 100644
index 0000000..918ee3a
--- /dev/null
+++ b/src/components/participant/UploadConsentForm.tsx
@@ -0,0 +1,89 @@
+import React, { useState, useEffect } from 'react';
+import { Button } from "~/components/ui/button";
+import { Input } from "~/components/ui/input";
+import { useToast } from "~/hooks/use-toast";
+
+interface UploadConsentFormProps {
+ studyId: number;
+ participantId: number;
+}
+
+export function UploadConsentForm({ studyId, participantId }: UploadConsentFormProps) {
+ const [file, setFile] = useState(null);
+ const [isUploading, setIsUploading] = useState(false);
+ const { toast } = useToast();
+
+ useEffect(() => {
+ toast({
+ title: "Test Toast",
+ description: "This is a test toast message",
+ });
+ }, []);
+
+ const handleFileChange = (e: React.ChangeEvent) => {
+ if (e.target.files && e.target.files[0]) {
+ setFile(e.target.files[0]);
+ }
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!file) {
+ toast({
+ title: "Error",
+ description: "Please select a file to upload",
+ variant: "destructive",
+ });
+ return;
+ }
+
+ setIsUploading(true);
+
+ const formData = new FormData();
+ formData.append('file', file);
+ formData.append('studyId', studyId.toString());
+ formData.append('participantId', participantId.toString());
+
+ try {
+ const response = await fetch('/api/informed-consent', {
+ method: 'POST',
+ body: formData,
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to upload form');
+ }
+
+ toast({
+ title: "Success",
+ description: "Informed consent form uploaded successfully",
+ });
+ setFile(null);
+ } catch (error) {
+ console.error('Error uploading form:', error);
+ toast({
+ title: "Error",
+ description: "Failed to upload informed consent form",
+ variant: "destructive",
+ });
+ } finally {
+ setIsUploading(false);
+ }
+ };
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx
index e4b9798..3e12ad8 100644
--- a/src/components/sidebar.tsx
+++ b/src/components/sidebar.tsx
@@ -16,6 +16,7 @@ import { useState } from "react"
import { Button } from "~/components/ui/button"
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet"
import { cn } from "~/lib/utils"
+import { ThemeToggle } from "~/components/ThemeToggle"
const navItems = [
{ name: "Dashboard", href: "/dash", icon: LayoutDashboard },
@@ -30,8 +31,16 @@ export function Sidebar() {
const [isOpen, setIsOpen] = useState(false)
const { user } = useUser()
+ const HRIStudioLogo = () => (
+
+
+ HRI
+ Studio
+
+ )
+
const SidebarContent = () => (
-
+
-
+
-
{user?.fullName ?? 'User'}
-
{user?.primaryEmailAddress?.emailAddress ?? 'user@example.com'}
+
{user?.fullName ?? 'User'}
+
{user?.primaryEmailAddress?.emailAddress ?? 'user@example.com'}
+
)
- const HRIStudioLogo = () => (
-
-
-
HRI
-
Studio
-
- )
-
return (
<>
-
+
@@ -95,7 +97,7 @@ export function Sidebar() {
-
+
diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx
new file mode 100644
index 0000000..726b9e6
--- /dev/null
+++ b/src/components/ui/popover.tsx
@@ -0,0 +1,33 @@
+"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
,
+ React.ComponentPropsWithoutRef
+>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
+
+
+
+))
+PopoverContent.displayName = PopoverPrimitive.Content.displayName
+
+export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
diff --git a/src/context/StudyContext.tsx b/src/context/StudyContext.tsx
index a97e48f..6b7fcaf 100644
--- a/src/context/StudyContext.tsx
+++ b/src/context/StudyContext.tsx
@@ -18,9 +18,17 @@ export const StudyProvider: React.FC = ({ children }) =
const [studies, setStudies] = useState([]);
const fetchAndSetStudies = async () => {
- const response = await fetch('/api/studies');
- const fetchedStudies = await response.json();
- setStudies(fetchedStudies);
+ 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) => {
diff --git a/src/lib/fileStorage.ts b/src/lib/fileStorage.ts
new file mode 100644
index 0000000..d3edab7
--- /dev/null
+++ b/src/lib/fileStorage.ts
@@ -0,0 +1,15 @@
+import fs from 'fs/promises';
+import path from 'path';
+
+const CONTENT_DIR = path.join(process.cwd(), 'content');
+
+export async function saveFile(file: File, contentId: number): Promise {
+ const contentFolder = path.join(CONTENT_DIR, contentId.toString());
+ await fs.mkdir(contentFolder, { recursive: true });
+
+ const buffer = Buffer.from(await file.arrayBuffer());
+ const filePath = path.join(contentFolder, file.name);
+ await fs.writeFile(filePath, buffer);
+
+ return filePath;
+}
\ No newline at end of file
diff --git a/src/server/db/index.ts b/src/server/db/index.ts
index 1287189..e345847 100644
--- a/src/server/db/index.ts
+++ b/src/server/db/index.ts
@@ -16,3 +16,8 @@ const conn = globalForDb.conn ?? postgres(env.DATABASE_URL);
if (env.NODE_ENV !== "production") globalForDb.conn = conn;
export const db = drizzle(conn, { schema });
+
+import { initializeContentTypes } from "./init";
+
+// Initialize content types
+initializeContentTypes().catch(console.error);
diff --git a/src/server/db/init.ts b/src/server/db/init.ts
new file mode 100644
index 0000000..6f68695
--- /dev/null
+++ b/src/server/db/init.ts
@@ -0,0 +1,16 @@
+import { db } from "./index";
+import { contentTypes } 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" },
+ // Add other content types as needed
+ ]);
+ console.log("Content types initialized");
+ } else {
+ console.log("Content types already exist");
+ }
+}
diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts
index 2e88b6b..ef87b82 100644
--- a/src/server/db/schema.ts
+++ b/src/server/db/schema.ts
@@ -45,4 +45,38 @@ export const participants = createTable(
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
}
+);
+
+export const contentTypes = createTable(
+ "content_type",
+ {
+ id: serial("id").primaryKey(),
+ name: varchar("name", { length: 50 }).notNull().unique(),
+ }
+);
+
+export const contents = createTable(
+ "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(),
+ createdAt: timestamp("created_at", { withTimezone: true })
+ .default(sql`CURRENT_TIMESTAMP`)
+ .notNull(),
+ }
+);
+
+export const informedConsentForms = createTable(
+ "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(),
+ }
);
\ No newline at end of file
diff --git a/src/styles/globals.css b/src/styles/globals.css
index 1346636..4c0e2f8 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -1,61 +1,76 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+
@layer base {
:root {
- --background: 0 0% 100%;
- --foreground: 240 10% 3.9%;
- --card: 0 0% 100%;
- --card-foreground: 240 10% 3.9%;
- --popover: 0 0% 100%;
- --popover-foreground: 240 10% 3.9%;
- --primary: 240 5.9% 10%;
- --primary-foreground: 0 0% 98%;
- --secondary: 240 4.8% 95.9%;
- --secondary-foreground: 240 5.9% 10%;
- --muted: 240 4.8% 95.9%;
- --muted-foreground: 240 3.8% 46.1%;
- --accent: 240 4.8% 95.9%;
- --accent-foreground: 240 5.9% 10%;
- --destructive: 0 84.2% 60.2%;
- --destructive-foreground: 0 0% 98%;
- --border: 240 5.9% 90%;
- --input: 240 5.9% 90%;
- --ring: 240 10% 3.9%;
- --chart-1: 12 76% 61%;
- --chart-2: 173 58% 39%;
- --chart-3: 197 37% 24%;
- --chart-4: 43 74% 66%;
- --chart-5: 27 87% 67%;
- --radius: 0.5rem
+ --background: 210 50% 98%;
+ --foreground: 215 25% 27%;
+ --card: 210 50% 98%;
+ --card-foreground: 215 25% 27%;
+ --popover: 210 50% 98%;
+ --popover-foreground: 215 25% 27%;
+ --primary: 215 60% 40%;
+ --primary-foreground: 210 50% 98%;
+ --secondary: 210 55% 92%;
+ --secondary-foreground: 215 25% 27%;
+ --muted: 210 55% 92%;
+ --muted-foreground: 215 20% 50%;
+ --accent: 210 55% 92%;
+ --accent-foreground: 215 25% 27%;
+ --destructive: 0 84% 60%;
+ --destructive-foreground: 210 50% 98%;
+ --border: 214 32% 91%;
+ --input: 214 32% 91%;
+ --ring: 215 60% 40%;
+ --radius: 0.5rem;
+
+ /* Update gradient variables */
+ --gradient-start: 210 50% 96%;
+ --gradient-end: 210 50% 98%;
+
+ /* Update sidebar variables */
+ --sidebar-background-top: 210 55% 92%;
+ --sidebar-background-bottom: 210 55% 88%;
+ --sidebar-foreground: 215 25% 27%;
+ --sidebar-muted: 215 20% 50%;
+ --sidebar-hover: 210 60% 86%;
}
+
.dark {
- --background: 240 10% 3.9%;
- --foreground: 0 0% 98%;
- --card: 240 10% 3.9%;
- --card-foreground: 0 0% 98%;
- --popover: 240 10% 3.9%;
- --popover-foreground: 0 0% 98%;
- --primary: 0 0% 98%;
- --primary-foreground: 240 5.9% 10%;
- --secondary: 240 3.7% 15.9%;
- --secondary-foreground: 0 0% 98%;
- --muted: 240 3.7% 15.9%;
- --muted-foreground: 240 5% 64.9%;
- --accent: 240 3.7% 15.9%;
- --accent-foreground: 0 0% 98%;
- --destructive: 0 62.8% 30.6%;
- --destructive-foreground: 0 0% 98%;
- --border: 240 3.7% 15.9%;
- --input: 240 3.7% 15.9%;
- --ring: 240 4.9% 83.9%;
- --chart-1: 220 70% 50%;
- --chart-2: 160 60% 45%;
- --chart-3: 30 80% 55%;
- --chart-4: 280 65% 60%;
- --chart-5: 340 75% 55%
+ --background: 220 20% 15%;
+ --foreground: 220 15% 85%;
+ --card: 220 20% 15%;
+ --card-foreground: 220 15% 85%;
+ --popover: 220 20% 15%;
+ --popover-foreground: 220 15% 85%;
+ --primary: 220 60% 50%;
+ --primary-foreground: 220 15% 85%;
+ --secondary: 220 25% 20%;
+ --secondary-foreground: 220 15% 85%;
+ --muted: 220 25% 20%;
+ --muted-foreground: 220 15% 65%;
+ --accent: 220 25% 20%;
+ --accent-foreground: 220 15% 85%;
+ --destructive: 0 62% 30%;
+ --destructive-foreground: 220 15% 85%;
+ --border: 220 25% 20%;
+ --input: 220 25% 20%;
+ --ring: 220 60% 50%;
+
+ /* Update gradient variables for dark mode */
+ --gradient-start: 220 20% 13%;
+ --gradient-end: 220 20% 15%;
+
+ /* Update sidebar variables for dark mode */
+ --sidebar-background-top: 220 20% 15%;
+ --sidebar-background-bottom: 220 20% 12%;
+ --sidebar-foreground: 220 15% 85%;
+ --sidebar-muted: 220 15% 65%;
+ --sidebar-hover: 220 25% 18%;
}
}
+
@layer base {
* {
@apply border-border;