From 7ef91800261a34b519903923725670890a2a4fa4 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Thu, 3 Oct 2024 16:35:19 -0400 Subject: [PATCH] ability to create trials added; form uploader cleaned up --- public/favicon.ico | Bin 15406 -> 3133 bytes src/app/api/participants/route.ts | 38 ++++-- src/app/api/trials/route.ts | 52 ++++++++ src/app/dash/page.tsx | 66 ++++++++++- src/app/forms/page.tsx | 19 ++- src/app/participants/page.tsx | 2 +- src/app/studies/page.tsx | 2 +- src/app/trials/page.tsx | 10 ++ src/components/forms/FormCard.tsx | 18 ++- src/components/forms/Forms.tsx | 1 + src/components/layout.tsx | 8 +- src/components/participant/Participants.tsx | 85 +++++++++----- src/components/sidebar.tsx | 6 +- src/components/study/StudyHeader.tsx | 124 ++++++++++---------- src/components/trial/CreateTrialDialog.tsx | 58 +++++++++ src/components/trial/Trials.tsx | 109 +++++++++++++++++ src/server/db/schema.ts | 20 ++++ src/styles/globals.css | 67 +++++++---- tsconfig.json | 25 ++-- 19 files changed, 559 insertions(+), 151 deletions(-) create mode 100644 src/app/api/trials/route.ts create mode 100644 src/app/trials/page.tsx create mode 100644 src/components/forms/Forms.tsx create mode 100644 src/components/trial/CreateTrialDialog.tsx create mode 100644 src/components/trial/Trials.tsx diff --git a/public/favicon.ico b/public/favicon.ico index 60c702aac13409c82c4040f8fee9603eb84aa10c..0f0c01dec5a00b9dc8c3483e8e9115ead39ec426 100644 GIT binary patch literal 3133 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 A#Q*>R diff --git a/src/app/api/participants/route.ts b/src/app/api/participants/route.ts index 6a01750..6ad7aec 100644 --- a/src/app/api/participants/route.ts +++ b/src/app/api/participants/route.ts @@ -1,18 +1,36 @@ import { db } from "~/server/db"; -import { participants } from "~/server/db/schema"; +import { participants, trialParticipants, trials } from "~/server/db/schema"; import { NextResponse } from "next/server"; -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; export async function GET(request: Request) { - const { searchParams } = new URL(request.url); - const studyId = searchParams.get('studyId'); - - if (!studyId) { - return NextResponse.json({ error: 'Study ID is required' }, { status: 400 }); - } + 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 allParticipants = await db.select().from(participants).where(eq(participants.studyId, parseInt(studyId))); - return NextResponse.json(allParticipants); + const participantsWithLatestTrial = await db + .select({ + id: participants.id, + name: participants.name, + createdAt: participants.createdAt, + latestTrialTimestamp: sql`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`); + + return NextResponse.json(participantsWithLatestTrial); + } catch (error) { + console.error('Error in GET /api/participants:', error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } } export async function POST(request: Request) { diff --git a/src/app/api/trials/route.ts b/src/app/api/trials/route.ts new file mode 100644 index 0000000..d19c837 --- /dev/null +++ b/src/app/api/trials/route.ts @@ -0,0 +1,52 @@ +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" }); +} diff --git a/src/app/dash/page.tsx b/src/app/dash/page.tsx index cb6622a..56ee694 100644 --- a/src/app/dash/page.tsx +++ b/src/app/dash/page.tsx @@ -1,11 +1,46 @@ +"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([]); + + 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 ( - +
- + Platform Information @@ -13,12 +48,35 @@ const DashboardPage: React.FC = () => { {/* Add content for Platform Information */} - + Participants - {/* Add content for Participants */} +
+ {participants.slice(0, 4).map(participant => ( + + + {participant.name.split(' ').map(n => n[0]).join('')} + +
+

{participant.name}

+

+ Last trial: {formatDate(participant.latestTrialTimestamp)} +

+
+
+ ))} +
+ {participants.length > 4 && ( +
+ + + +
+ )}
diff --git a/src/app/forms/page.tsx b/src/app/forms/page.tsx index fc28550..c8bffcc 100644 --- a/src/app/forms/page.tsx +++ b/src/app/forms/page.tsx @@ -1,15 +1,22 @@ 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 ( - -
-

Forms

- -
- + + + + + Forms + + + + + + + ); } \ No newline at end of file diff --git a/src/app/participants/page.tsx b/src/app/participants/page.tsx index 69c1bcc..aae21d0 100644 --- a/src/app/participants/page.tsx +++ b/src/app/participants/page.tsx @@ -3,7 +3,7 @@ import { Participants } from "~/components/participant/Participants"; const ParticipantsPage = () => { return ( - + ); diff --git a/src/app/studies/page.tsx b/src/app/studies/page.tsx index 8c1c7c4..1b485c6 100644 --- a/src/app/studies/page.tsx +++ b/src/app/studies/page.tsx @@ -3,7 +3,7 @@ import { Studies } from "~/components/study/Studies"; export default function StudiesPage() { return ( - + ); diff --git a/src/app/trials/page.tsx b/src/app/trials/page.tsx new file mode 100644 index 0000000..d8b97e9 --- /dev/null +++ b/src/app/trials/page.tsx @@ -0,0 +1,10 @@ +import Layout from "~/components/layout"; +import { Trials } from "~/components/trial/Trials"; + +export default function TrialsPage() { + return ( + + + + ); +} diff --git a/src/components/forms/FormCard.tsx b/src/components/forms/FormCard.tsx index effaac5..8853a09 100644 --- a/src/components/forms/FormCard.tsx +++ b/src/components/forms/FormCard.tsx @@ -2,6 +2,7 @@ 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: { @@ -23,7 +24,7 @@ export function FormCard({ form, onDelete }: FormCardProps) { }; return ( - + - +

{form.title}

- { e.stopPropagation(); onDelete(form.id); - }} - /> + }} + > + + Delete +
{form.studyTitle} diff --git a/src/components/forms/Forms.tsx b/src/components/forms/Forms.tsx new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/src/components/forms/Forms.tsx @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/layout.tsx b/src/components/layout.tsx index d3fd3e7..ee8cd44 100644 --- a/src/components/layout.tsx +++ b/src/components/layout.tsx @@ -3,13 +3,17 @@ import { Sidebar } from "~/components/sidebar"; import { StudyHeader } from "~/components/study/StudyHeader"; import { Toaster } from "~/components/ui/toaster"; -const Layout = ({ children }: PropsWithChildren) => { +interface LayoutProps { + pageTitle: string; +} + +const Layout = ({ children, pageTitle }: PropsWithChildren) => { return (
- + {children}
diff --git a/src/components/participant/Participants.tsx b/src/components/participant/Participants.tsx index 4639b84..29b312d 100644 --- a/src/components/participant/Participants.tsx +++ b/src/components/participant/Participants.tsx @@ -8,9 +8,17 @@ 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([]); + const [participants, setParticipants] = useState([]); const { selectedStudy } = useStudyContext(); const { toast } = useToast(); @@ -22,9 +30,23 @@ export function Participants() { const fetchParticipants = async () => { if (!selectedStudy) return; - const response = await fetch(`/api/participants?studyId=${selectedStudy.id}`); - const data = await response.json(); - setParticipants(data); + 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) => { @@ -41,33 +63,20 @@ export function Participants() { const deleteParticipant = async (id: number) => { if (!selectedStudy) return; try { - console.log(`Attempting to delete participant with ID: ${id}`); const response = await fetch(`/api/participants/${id}`, { method: 'DELETE', }); - console.log('Delete response:', response); - const contentType = response.headers.get("content-type"); - if (contentType && contentType.indexOf("application/json") !== -1) { - const result = await response.json(); - console.log('Delete result:', result); - - if (!response.ok) { - throw new Error(result.error || `Failed to delete participant. Status: ${response.status}`); - } - - setParticipants(participants.filter(p => p.id !== id)); - toast({ - title: "Success", - description: "Participant deleted successfully", - }); - } else { - const text = await response.text(); - console.error('Unexpected response:', text); - throw new Error(`Unexpected response from server. Status: ${response.status}`); + 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) { - console.error('Error deleting participant:', error); toast({ title: "Error", description: error instanceof Error ? error.message : 'Failed to delete participant', @@ -81,16 +90,36 @@ export function Participants() { } return ( - + Participants for {selectedStudy.title} {participants.length > 0 ? ( -
+
{participants.map(participant => ( - + + + {participant.name.split(' ').map(n => n[0]).join('')} + +
+

{participant.name}

+

+ {participant.latestTrialTimestamp + ? `Last trial: ${new Date(participant.latestTrialTimestamp).toLocaleString()}` + : 'No trials yet'} +

+
+ +
))}
) : ( diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx index 979b3a0..4506dcf 100644 --- a/src/components/sidebar.tsx +++ b/src/components/sidebar.tsx @@ -3,7 +3,8 @@ import { UserButton, useUser } from "@clerk/nextjs" import { BarChartIcon, - BeakerIcon, + UsersRoundIcon, + LandPlotIcon, BotIcon, FolderIcon, FileTextIcon, @@ -22,7 +23,8 @@ import { ThemeToggle } from "~/components/ThemeToggle" const navItems = [ { name: "Dashboard", href: "/dash", icon: LayoutDashboard }, { name: "Studies", href: "/studies", icon: FolderIcon }, - { name: "Participants", href: "/participants", icon: BeakerIcon }, + { 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 }, diff --git a/src/components/study/StudyHeader.tsx b/src/components/study/StudyHeader.tsx index 8edc4b5..341bf80 100644 --- a/src/components/study/StudyHeader.tsx +++ b/src/components/study/StudyHeader.tsx @@ -8,68 +8,72 @@ import { StudySelector } from './StudySelector'; import { CreateStudyDialog } from '~/components/study/CreateStudyDialog'; import { Study } from '~/types/Study'; -export function StudyHeader() { - const { studies, selectedStudy, setSelectedStudy, validateAndSetSelectedStudy, fetchAndSetStudies } = useStudyContext(); +interface StudyHeaderProps { + pageTitle: string; +} - useEffect(() => { - const savedStudyId = localStorage.getItem('selectedStudyId'); - if (savedStudyId) { - validateAndSetSelectedStudy(parseInt(savedStudyId, 10)); - } - }, [validateAndSetSelectedStudy]); +export const StudyHeader: React.FC = ({ pageTitle }) => { + const { studies, selectedStudy, setSelectedStudy, validateAndSetSelectedStudy, fetchAndSetStudies } = useStudyContext(); - const handleStudyChange = (studyId: string) => { - const study = studies.find(s => s.id.toString() === studyId); - if (study) { - setSelectedStudy(study); - localStorage.setItem('selectedStudyId', studyId); - } - }; + useEffect(() => { + const savedStudyId = localStorage.getItem('selectedStudyId'); + if (savedStudyId) { + validateAndSetSelectedStudy(parseInt(savedStudyId, 10)); + } + }, [validateAndSetSelectedStudy]); - const createStudy = async (newStudy: Omit) => { - 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 handleStudyChange = (studyId: string) => { + const study = studies.find(s => s.id.toString() === studyId); + if (study) { + setSelectedStudy(study); + localStorage.setItem('selectedStudyId', studyId); + } + }; - const handleCreateStudy = async (newStudy: Omit) => { - const createdStudy = await createStudy(newStudy); - setSelectedStudy(createdStudy); - localStorage.setItem('selectedStudyId', createdStudy.id.toString()); - }; + const createStudy = async (newStudy: Omit) => { + 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; + }; - return ( - - - - - -

- {selectedStudy ? selectedStudy.title : 'Select a Study'} -

-
- -

{selectedStudy ? selectedStudy.title : 'No study selected'}

-
-
-
-
- - ) => handleCreateStudy(study as Study)} /> -
-
-
- ); -} \ No newline at end of file + const handleCreateStudy = async (newStudy: Omit) => { + const createdStudy = await createStudy(newStudy); + setSelectedStudy(createdStudy); + localStorage.setItem('selectedStudyId', createdStudy.id.toString()); + }; + + return ( + + + + + +

+ {pageTitle} +

+
+ +

{selectedStudy ? selectedStudy.title : 'No study selected'}

+
+
+
+
+ + ) => handleCreateStudy(study as Study)} /> +
+
+
+ ); +}; \ No newline at end of file diff --git a/src/components/trial/CreateTrialDialog.tsx b/src/components/trial/CreateTrialDialog.tsx new file mode 100644 index 0000000..9dd3af6 --- /dev/null +++ b/src/components/trial/CreateTrialDialog.tsx @@ -0,0 +1,58 @@ +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(''); + + 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 ( + + + + + + + Add New Trial + +
+
+ + setTitle(e.target.value)} + className="col-span-3" + /> +
+
+ + setParticipantIds(e.target.value)} + className="col-span-3" + placeholder="e.g. 1, 2, 3" + /> +
+
+ +
+
+ ); +} diff --git a/src/components/trial/Trials.tsx b/src/components/trial/Trials.tsx new file mode 100644 index 0000000..f918237 --- /dev/null +++ b/src/components/trial/Trials.tsx @@ -0,0 +1,109 @@ +"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([]); + 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 ( + + + Trials + + + + {trials.length > 0 ? ( +
+ {trials.map(trial => ( + +
+

{trial.title}

+

Participants: {trial.participantIds.join(', ')}

+
+ +
+ ))} +
+ ) : ( +

No trials added yet.

+ )} +
+
+ ); +} diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 5c4969e..99edfe4 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -89,4 +89,24 @@ export const users = pgTable( .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(), + } ); \ No newline at end of file diff --git a/src/styles/globals.css b/src/styles/globals.css index 4c0e2f8..451168a 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -6,8 +6,8 @@ :root { --background: 210 50% 98%; --foreground: 215 25% 27%; - --card: 210 50% 98%; - --card-foreground: 215 25% 27%; + --card: 210 50% 98%; /* Card background color */ + --card-foreground: 215 25% 27%; /* Card text color */ --popover: 210 50% 98%; --popover-foreground: 215 25% 27%; --primary: 215 60% 40%; @@ -35,39 +35,60 @@ --sidebar-foreground: 215 25% 27%; --sidebar-muted: 215 20% 50%; --sidebar-hover: 210 60% 86%; + + --card-level-1: 210 50% 95%; /* Level 1 card background color */ + --card-level-2: 210 50% 90%; /* Level 2 card background color */ + --card-level-3: 210 50% 85%; /* Level 3 card background color */ } .dark { - --background: 220 20% 15%; - --foreground: 220 15% 85%; - --card: 220 20% 15%; - --card-foreground: 220 15% 85%; + --background: 220 20% 15%; /* Dark mode background */ + --foreground: 220 20% 90%; /* Dark mode foreground */ + --card: 220 20% 15%; /* Dark mode card background color */ + --card-foreground: 220 20% 90%; /* Dark mode card text color */ --popover: 220 20% 15%; - --popover-foreground: 220 15% 85%; + --popover-foreground: 220 20% 90%; --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%; + --primary-foreground: 220 20% 90%; + --secondary: 220 30% 20%; /* Darker secondary */ + --secondary-foreground: 220 20% 90%; + --muted: 220 30% 20%; + --muted-foreground: 220 20% 70%; + --accent: 220 30% 20%; + --accent-foreground: 220 20% 90%; + --destructive: 0 62% 40%; /* Darker destructive */ + --destructive-foreground: 220 20% 90%; + --border: 220 30% 20%; + --input: 220 30% 20%; --ring: 220 60% 50%; /* Update gradient variables for dark mode */ - --gradient-start: 220 20% 13%; + --gradient-start: 220 20% 12%; --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%; + --sidebar-foreground: 220 20% 90%; + --sidebar-muted: 220 20% 70%; + --sidebar-hover: 220 30% 20%; + + --card-level-1: 220 20% 12%; /* Dark mode Level 1 card background color */ + --card-level-2: 220 20% 10%; /* Dark mode Level 2 card background color */ + --card-level-3: 220 20% 8%; /* Dark mode Level 3 card background color */ + } + + /* Add these utility classes */ + .card-level-1 { + background-color: hsl(var(--card-level-1)); + } + + .card-level-2 { + background-color: hsl(var(--card-level-2)); + } + + .card-level-3 { + background-color: hsl(var(--card-level-3)); } } @@ -117,4 +138,4 @@ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } -} +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 905062d..6b136f3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,25 +8,32 @@ "resolveJsonModule": true, "moduleDetection": "force", "isolatedModules": true, - /* Strictness */ "strict": true, "noUncheckedIndexedAccess": true, "checkJs": true, - /* Bundled projects */ - "lib": ["dom", "dom.iterable", "ES2022"], + "lib": [ + "dom", + "dom.iterable", + "ES2022" + ], "noEmit": true, "module": "ESNext", "moduleResolution": "Bundler", - "jsx": "preserve", - "plugins": [{ "name": "next" }], + "jsx": "preserve", // or "react" for older versions + "plugins": [ + { + "name": "next" + } + ], "incremental": true, - /* Path Aliases */ "baseUrl": ".", "paths": { - "~/*": ["./src/*"] + "~/*": [ + "./src/*" + ] } }, "include": [ @@ -38,5 +45,7 @@ "**/*.js", ".next/types/**/*.ts" ], - "exclude": ["node_modules"] + "exclude": [ + "node_modules" + ] }