From 66137ff7b43d98d0f98411182c9e2c3dbfd40660 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Thu, 26 Sep 2024 01:00:46 -0400 Subject: [PATCH] Begin file upload, add theme change --- .gitignore | 2 + package.json | 1 + src/app/api/informed-consent/route.ts | 67 +++++++++++ src/app/layout.tsx | 18 +-- src/components/ThemeProvider.tsx | 35 ++++++ src/components/ThemeToggle.tsx | 67 +++++++++++ src/components/layout.tsx | 8 +- src/components/participant/Participants.tsx | 26 ++-- .../participant/UploadConsentForm.tsx | 89 ++++++++++++++ src/components/sidebar.tsx | 34 +++--- src/components/ui/popover.tsx | 33 +++++ src/context/StudyContext.tsx | 14 ++- src/lib/fileStorage.ts | 15 +++ src/server/db/index.ts | 5 + src/server/db/init.ts | 16 +++ src/server/db/schema.ts | 34 ++++++ src/styles/globals.css | 113 ++++++++++-------- 17 files changed, 487 insertions(+), 90 deletions(-) create mode 100644 src/app/api/informed-consent/route.ts create mode 100644 src/components/ThemeProvider.tsx create mode 100644 src/components/ThemeToggle.tsx create mode 100644 src/components/participant/UploadConsentForm.tsx create mode 100644 src/components/ui/popover.tsx create mode 100644 src/lib/fileStorage.ts create mode 100644 src/server/db/init.ts 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 ( -
+
-
-
+
+
{children} +
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 ? ( -
    +
      {participants.map(participant => ( -
    • - {participant.name} - +
    • +
      + {participant.name} + +
      +
    • ))}
    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;