mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 22:54:45 -05:00
Begin file upload, add theme change
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -58,3 +58,5 @@ pnpm-lock.yaml
|
|||||||
|
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/nextjs,react
|
# End of https://www.toptal.com/developers/gitignore/api/nextjs,react
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
|
|
||||||
|
/content
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"@radix-ui/react-dialog": "^1.1.1",
|
"@radix-ui/react-dialog": "^1.1.1",
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
"@radix-ui/react-label": "^2.1.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-select": "^2.1.1",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
|
|||||||
67
src/app/api/informed-consent/route.ts
Normal file
67
src/app/api/informed-consent/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ClerkProvider } from '@clerk/nextjs'
|
import { ClerkProvider } from '@clerk/nextjs'
|
||||||
import { Inter } from "next/font/google"
|
import { Inter } from "next/font/google"
|
||||||
import { StudyProvider } from '~/context/StudyContext'
|
import { StudyProvider } from '~/context/StudyContext'
|
||||||
|
import { ThemeProvider } from '~/components/ThemeProvider'
|
||||||
import "~/styles/globals.css"
|
import "~/styles/globals.css"
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
@@ -19,13 +19,15 @@ export const metadata = {
|
|||||||
export default function RootLayout({ children }: React.PropsWithChildren) {
|
export default function RootLayout({ children }: React.PropsWithChildren) {
|
||||||
return (
|
return (
|
||||||
<ClerkProvider>
|
<ClerkProvider>
|
||||||
<StudyProvider>
|
<html lang="en" className={inter.variable}>
|
||||||
<html lang="en" className={inter.variable}>
|
<body className="font-sans">
|
||||||
<body className="font-sans">
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
{children}
|
<StudyProvider>
|
||||||
</body>
|
{children}
|
||||||
</html>
|
</StudyProvider>
|
||||||
</StudyProvider>
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
</ClerkProvider>
|
</ClerkProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
35
src/components/ThemeProvider.tsx
Normal file
35
src/components/ThemeProvider.tsx
Normal file
@@ -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 (
|
||||||
|
<NextThemesProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</NextThemesProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
67
src/components/ThemeToggle.tsx
Normal file
67
src/components/ThemeToggle.tsx
Normal file
@@ -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 (
|
||||||
|
<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,15 +1,17 @@
|
|||||||
import { PropsWithChildren } from "react";
|
import { PropsWithChildren } from "react";
|
||||||
import { Sidebar } from "~/components/sidebar";
|
import { Sidebar } from "~/components/sidebar";
|
||||||
import { StudyHeader } from "~/components/study/StudyHeader";
|
import { StudyHeader } from "~/components/study/StudyHeader";
|
||||||
|
import { Toaster } from "~/components/ui/toaster";
|
||||||
|
|
||||||
const Layout = ({ children }: PropsWithChildren) => {
|
const Layout = ({ children }: PropsWithChildren) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-gradient-to-b from-blue-100 to-white">
|
<div className="flex h-screen">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="flex-1 overflow-y-auto p-4 pt-16 lg:pt-4">
|
<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">
|
<div className="container mx-auto space-y-4 p-4 pt-16 lg:pt-4">
|
||||||
<StudyHeader />
|
<StudyHeader />
|
||||||
{children}
|
{children}
|
||||||
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Participant } from '../../types/Participant';
|
|||||||
import { CreateParticipantDialog } from './CreateParticipantDialog';
|
import { CreateParticipantDialog } from './CreateParticipantDialog';
|
||||||
import { Trash2 } from 'lucide-react';
|
import { Trash2 } from 'lucide-react';
|
||||||
import { useToast } from '~/hooks/use-toast';
|
import { useToast } from '~/hooks/use-toast';
|
||||||
|
import { UploadConsentForm } from './UploadConsentForm';
|
||||||
|
|
||||||
export function Participants() {
|
export function Participants() {
|
||||||
const [participants, setParticipants] = useState<Participant[]>([]);
|
const [participants, setParticipants] = useState<Participant[]>([]);
|
||||||
@@ -88,18 +89,21 @@ export function Participants() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{participants.length > 0 ? (
|
{participants.length > 0 ? (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-4">
|
||||||
{participants.map(participant => (
|
{participants.map(participant => (
|
||||||
<li key={participant.id} className="bg-gray-100 p-2 rounded flex justify-between items-center">
|
<li key={participant.id} className="bg-gray-100 p-4 rounded">
|
||||||
<span>{participant.name}</span>
|
<div className="flex justify-between items-center mb-2">
|
||||||
<Button
|
<span className="font-semibold">{participant.name}</span>
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={() => deleteParticipant(participant.id)}
|
size="sm"
|
||||||
className="text-red-500 hover:text-red-700"
|
onClick={() => deleteParticipant(participant.id)}
|
||||||
>
|
className="text-red-500 hover:text-red-700"
|
||||||
<Trash2 className="h-4 w-4" />
|
>
|
||||||
</Button>
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<UploadConsentForm studyId={selectedStudy.id} participantId={participant.id} />
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
89
src/components/participant/UploadConsentForm.tsx
Normal file
89
src/components/participant/UploadConsentForm.tsx
Normal file
@@ -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<File | null>(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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
accept=".pdf"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
disabled={isUploading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={!file || isUploading}>
|
||||||
|
{isUploading ? 'Uploading...' : 'Upload Consent Form'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { useState } from "react"
|
|||||||
import { Button } from "~/components/ui/button"
|
import { Button } from "~/components/ui/button"
|
||||||
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet"
|
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet"
|
||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils"
|
||||||
|
import { ThemeToggle } from "~/components/ThemeToggle"
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ name: "Dashboard", href: "/dash", icon: LayoutDashboard },
|
{ name: "Dashboard", href: "/dash", icon: LayoutDashboard },
|
||||||
@@ -30,8 +31,16 @@ export function Sidebar() {
|
|||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const { user } = useUser()
|
const { user } = useUser()
|
||||||
|
|
||||||
|
const HRIStudioLogo = () => (
|
||||||
|
<Link href="/dash" 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>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
|
||||||
const SidebarContent = () => (
|
const SidebarContent = () => (
|
||||||
<div className="flex h-full flex-col lg:bg-blue-50">
|
<div className="flex h-full flex-col bg-gradient-to-b from-[hsl(var(--sidebar-background-top))] to-[hsl(var(--sidebar-background-bottom))]">
|
||||||
<nav className="flex-1 overflow-y-auto p-4">
|
<nav className="flex-1 overflow-y-auto p-4">
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
@@ -42,8 +51,8 @@ export function Sidebar() {
|
|||||||
asChild
|
asChild
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-start text-blue-800 hover:bg-blue-100",
|
"w-full justify-start text-[hsl(var(--sidebar-foreground))] hover:bg-[hsl(var(--sidebar-hover))]",
|
||||||
pathname === item.href && "bg-blue-100 font-semibold"
|
pathname === item.href && "bg-[hsl(var(--sidebar-hover))] font-semibold"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Link href={item.href} onClick={() => setIsOpen(false)}>
|
<Link href={item.href} onClick={() => setIsOpen(false)}>
|
||||||
@@ -56,31 +65,24 @@ export function Sidebar() {
|
|||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="border-t border-blue-200 p-4">
|
<div className="border-t p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<UserButton />
|
<UserButton />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-blue-800">{user?.fullName ?? 'User'}</p>
|
<p className="text-sm font-medium text-[hsl(var(--sidebar-foreground))]">{user?.fullName ?? 'User'}</p>
|
||||||
<p className="text-xs text-blue-600">{user?.primaryEmailAddress?.emailAddress ?? 'user@example.com'}</p>
|
<p className="text-xs text-[hsl(var(--sidebar-muted))]">{user?.primaryEmailAddress?.emailAddress ?? 'user@example.com'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
const HRIStudioLogo = () => (
|
|
||||||
<Link href="/dash" className="flex items-center font-sans text-xl text-blue-800">
|
|
||||||
<BotIcon className="h-6 w-6 mr-1 text-blue-600" />
|
|
||||||
<span className="font-extrabold">HRI</span>
|
|
||||||
<span className="font-normal">Studio</span>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="lg:hidden fixed top-0 left-0 right-0">
|
<div className="lg:hidden fixed top-0 left-0 right-0 z-50">
|
||||||
<div className="flex h-14 items-center justify-between border-b px-4 bg-background">
|
<div className="flex h-14 items-center justify-between border-b px-4 bg-background">
|
||||||
<HRIStudioLogo />
|
<HRIStudioLogo />
|
||||||
<Sheet open={isOpen} onOpenChange={setIsOpen}>
|
<Sheet open={isOpen} onOpenChange={setIsOpen}>
|
||||||
@@ -95,7 +97,7 @@ export function Sidebar() {
|
|||||||
</Sheet>
|
</Sheet>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden lg:flex lg:w-64 lg:flex-col lg:border-r lg:bg-background">
|
<div className="hidden lg:flex lg:w-64 lg:flex-col lg:border-r lg:bg-gradient-to-b lg:from-[hsl(var(--sidebar-background-top))] lg:to-[hsl(var(--sidebar-background-bottom))]">
|
||||||
<div className="flex h-14 items-center border-b px-4">
|
<div className="flex h-14 items-center border-b px-4">
|
||||||
<HRIStudioLogo />
|
<HRIStudioLogo />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
33
src/components/ui/popover.tsx
Normal file
33
src/components/ui/popover.tsx
Normal file
@@ -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<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 }
|
||||||
@@ -18,9 +18,17 @@ export const StudyProvider: React.FC<React.PropsWithChildren> = ({ children }) =
|
|||||||
const [studies, setStudies] = useState<Study[]>([]);
|
const [studies, setStudies] = useState<Study[]>([]);
|
||||||
|
|
||||||
const fetchAndSetStudies = async () => {
|
const fetchAndSetStudies = async () => {
|
||||||
const response = await fetch('/api/studies');
|
try {
|
||||||
const fetchedStudies = await response.json();
|
const response = await fetch('/api/studies');
|
||||||
setStudies(fetchedStudies);
|
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 validateAndSetSelectedStudy = async (studyId: number) => {
|
||||||
|
|||||||
15
src/lib/fileStorage.ts
Normal file
15
src/lib/fileStorage.ts
Normal file
@@ -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<string> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -16,3 +16,8 @@ const conn = globalForDb.conn ?? postgres(env.DATABASE_URL);
|
|||||||
if (env.NODE_ENV !== "production") globalForDb.conn = conn;
|
if (env.NODE_ENV !== "production") globalForDb.conn = conn;
|
||||||
|
|
||||||
export const db = drizzle(conn, { schema });
|
export const db = drizzle(conn, { schema });
|
||||||
|
|
||||||
|
import { initializeContentTypes } from "./init";
|
||||||
|
|
||||||
|
// Initialize content types
|
||||||
|
initializeContentTypes().catch(console.error);
|
||||||
|
|||||||
16
src/server/db/init.ts
Normal file
16
src/server/db/init.ts
Normal file
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,3 +46,37 @@ export const participants = createTable(
|
|||||||
.notNull(),
|
.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(),
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -1,61 +1,76 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
--background: 210 50% 98%;
|
||||||
--foreground: 240 10% 3.9%;
|
--foreground: 215 25% 27%;
|
||||||
--card: 0 0% 100%;
|
--card: 210 50% 98%;
|
||||||
--card-foreground: 240 10% 3.9%;
|
--card-foreground: 215 25% 27%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 210 50% 98%;
|
||||||
--popover-foreground: 240 10% 3.9%;
|
--popover-foreground: 215 25% 27%;
|
||||||
--primary: 240 5.9% 10%;
|
--primary: 215 60% 40%;
|
||||||
--primary-foreground: 0 0% 98%;
|
--primary-foreground: 210 50% 98%;
|
||||||
--secondary: 240 4.8% 95.9%;
|
--secondary: 210 55% 92%;
|
||||||
--secondary-foreground: 240 5.9% 10%;
|
--secondary-foreground: 215 25% 27%;
|
||||||
--muted: 240 4.8% 95.9%;
|
--muted: 210 55% 92%;
|
||||||
--muted-foreground: 240 3.8% 46.1%;
|
--muted-foreground: 215 20% 50%;
|
||||||
--accent: 240 4.8% 95.9%;
|
--accent: 210 55% 92%;
|
||||||
--accent-foreground: 240 5.9% 10%;
|
--accent-foreground: 215 25% 27%;
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 84% 60%;
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 210 50% 98%;
|
||||||
--border: 240 5.9% 90%;
|
--border: 214 32% 91%;
|
||||||
--input: 240 5.9% 90%;
|
--input: 214 32% 91%;
|
||||||
--ring: 240 10% 3.9%;
|
--ring: 215 60% 40%;
|
||||||
--chart-1: 12 76% 61%;
|
--radius: 0.5rem;
|
||||||
--chart-2: 173 58% 39%;
|
|
||||||
--chart-3: 197 37% 24%;
|
/* Update gradient variables */
|
||||||
--chart-4: 43 74% 66%;
|
--gradient-start: 210 50% 96%;
|
||||||
--chart-5: 27 87% 67%;
|
--gradient-end: 210 50% 98%;
|
||||||
--radius: 0.5rem
|
|
||||||
|
/* 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 {
|
.dark {
|
||||||
--background: 240 10% 3.9%;
|
--background: 220 20% 15%;
|
||||||
--foreground: 0 0% 98%;
|
--foreground: 220 15% 85%;
|
||||||
--card: 240 10% 3.9%;
|
--card: 220 20% 15%;
|
||||||
--card-foreground: 0 0% 98%;
|
--card-foreground: 220 15% 85%;
|
||||||
--popover: 240 10% 3.9%;
|
--popover: 220 20% 15%;
|
||||||
--popover-foreground: 0 0% 98%;
|
--popover-foreground: 220 15% 85%;
|
||||||
--primary: 0 0% 98%;
|
--primary: 220 60% 50%;
|
||||||
--primary-foreground: 240 5.9% 10%;
|
--primary-foreground: 220 15% 85%;
|
||||||
--secondary: 240 3.7% 15.9%;
|
--secondary: 220 25% 20%;
|
||||||
--secondary-foreground: 0 0% 98%;
|
--secondary-foreground: 220 15% 85%;
|
||||||
--muted: 240 3.7% 15.9%;
|
--muted: 220 25% 20%;
|
||||||
--muted-foreground: 240 5% 64.9%;
|
--muted-foreground: 220 15% 65%;
|
||||||
--accent: 240 3.7% 15.9%;
|
--accent: 220 25% 20%;
|
||||||
--accent-foreground: 0 0% 98%;
|
--accent-foreground: 220 15% 85%;
|
||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 62% 30%;
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 220 15% 85%;
|
||||||
--border: 240 3.7% 15.9%;
|
--border: 220 25% 20%;
|
||||||
--input: 240 3.7% 15.9%;
|
--input: 220 25% 20%;
|
||||||
--ring: 240 4.9% 83.9%;
|
--ring: 220 60% 50%;
|
||||||
--chart-1: 220 70% 50%;
|
|
||||||
--chart-2: 160 60% 45%;
|
/* Update gradient variables for dark mode */
|
||||||
--chart-3: 30 80% 55%;
|
--gradient-start: 220 20% 13%;
|
||||||
--chart-4: 280 65% 60%;
|
--gradient-end: 220 20% 15%;
|
||||||
--chart-5: 340 75% 55%
|
|
||||||
|
/* 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 {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border;
|
@apply border-border;
|
||||||
|
|||||||
Reference in New Issue
Block a user