diff --git a/drizzle/0001_add_gamification.sql b/drizzle/0001_add_gamification.sql deleted file mode 100644 index 56362d1..0000000 --- a/drizzle/0001_add_gamification.sql +++ /dev/null @@ -1,41 +0,0 @@ -CREATE TABLE "user_stats" ( - "user_id" text PRIMARY KEY REFERENCES "user"("id") ON DELETE CASCADE, - "total_points" integer NOT NULL DEFAULT 0, - "current_streak" integer NOT NULL DEFAULT 0, - "longest_streak" integer NOT NULL DEFAULT 0, - "last_study_date" date, - "today_active_minutes" integer NOT NULL DEFAULT 0, - "today_chat_count" integer NOT NULL DEFAULT 0, - "updated_at" timestamp NOT NULL DEFAULT now() -); - -CREATE TABLE "study_sessions" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), - "user_id" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE, - "note_id" uuid NOT NULL REFERENCES "note"("id") ON DELETE CASCADE, - "day_date" date NOT NULL, - "active_seconds" integer NOT NULL DEFAULT 0, - "points_awarded" integer NOT NULL DEFAULT 0, - "last_activity_at" timestamp, - "created_at" timestamp NOT NULL DEFAULT now(), - "updated_at" timestamp NOT NULL DEFAULT now() -); - -CREATE UNIQUE INDEX "study_sessions_user_note_day_idx" - ON "study_sessions" ("user_id", "note_id", "day_date"); - -CREATE INDEX "study_sessions_user_day_idx" - ON "study_sessions" ("user_id", "day_date"); - -CREATE TABLE "note_completion" ( - "user_id" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE, - "note_id" uuid NOT NULL REFERENCES "note"("id") ON DELETE CASCADE, - "completed_at" timestamp NOT NULL DEFAULT now(), - PRIMARY KEY ("user_id", "note_id") -); - -CREATE INDEX "note_completion_user_idx" - ON "note_completion" ("user_id"); - -CREATE INDEX "note_completion_completed_at_idx" - ON "note_completion" ("completed_at"); diff --git a/drizzle/0002_add_teacher_verification.sql b/drizzle/0002_add_teacher_verification.sql deleted file mode 100644 index c4e8068..0000000 --- a/drizzle/0002_add_teacher_verification.sql +++ /dev/null @@ -1,6 +0,0 @@ -ALTER TABLE "user" - ADD COLUMN "teacher_verified" boolean NOT NULL DEFAULT false; - -UPDATE "user" -SET "teacher_verified" = true -WHERE "role" = 'teacher'; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json deleted file mode 100644 index 1d6f15a..0000000 --- a/drizzle/meta/_journal.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "version": 1, - "dialect": "postgresql", - "entries": [] -} diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index bec0957..8d05a1c 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -28,27 +28,15 @@ export async function POST(req: Request) { return new Response("Note ID is required", { status: 400 }); } - // Save user message + award points for meaningful chat - // Save user message + award points for meaningful chat const lastMessage = messages[messages.length - 1]; - if (lastMessage?.role === "user") { - const lastMessageContent = lastMessage.parts - .filter((p) => p.type === "text") - .map((p) => p.text) - .join(""); - - // Fire and forget, or we could await if needed. - // For now, just calling them to resolve unused variable lint error. - void caller.chat.onStudentMessage({ - noteId, - content: lastMessageContent, - }); - - void caller.activity.ping({ - noteId, - eventType: "chat", - }); - } + const shouldTrack = + lastMessage?.role === "user" && lastMessage.parts?.length; + const lastMessageContent = shouldTrack + ? lastMessage.parts + .filter((p) => p.type === "text") + .map((p) => p.text) + .join("") + : ""; const modelMessages = await convertToModelMessages(messages); @@ -76,6 +64,18 @@ ${textContent ?? "No text content available."} noteId: noteId, }); } + + if (shouldTrack && lastMessageContent) { + void caller.chat.onStudentMessage({ + noteId, + content: lastMessageContent, + }); + + void caller.activity.ping({ + noteId, + eventType: "chat", + }); + } }, }); diff --git a/src/app/api/uploadthing/core.ts b/src/app/api/uploadthing/core.ts index 32098eb..10e2ce4 100644 --- a/src/app/api/uploadthing/core.ts +++ b/src/app/api/uploadthing/core.ts @@ -43,7 +43,15 @@ export const ourFileRouter = { }); } - if (existingCourse.teacherId !== context.session?.user.id) { + const requester = await caller.user.getById({ + id: context.session?.user.id ?? "", + }); + + const isOwner = + existingCourse.teacherId === context.session?.user.id; + const isAdmin = requester?.role === "admin"; + + if (!isOwner && !isAdmin) { // eslint-disable-next-line @typescript-eslint/only-throw-error throw new UploadThingError({ code: "FORBIDDEN", diff --git a/src/components/layout/main-layout.tsx b/src/components/layout/main-layout.tsx index bfd821c..c3040b3 100644 --- a/src/components/layout/main-layout.tsx +++ b/src/components/layout/main-layout.tsx @@ -9,7 +9,7 @@ export default function MainLayout({
-
+
{children}
diff --git a/src/modules/admin/admin-panel.tsx b/src/modules/admin/admin-panel.tsx index 5ce4fa2..df91d2a 100644 --- a/src/modules/admin/admin-panel.tsx +++ b/src/modules/admin/admin-panel.tsx @@ -10,8 +10,6 @@ export default function AdminPanel() { const [email, setEmail] = useState(""); const [searchedEmail, setSearchedEmail] = useState(null); - type UserByEmail = RouterOutputs["user"]["getByEmail"]; - const userQuery = api.user.getByEmail.useQuery( { email: searchedEmail ?? "" }, { enabled: !!searchedEmail } diff --git a/src/modules/course/features/confirm-delete-dialog.tsx b/src/modules/course/features/confirm-delete-dialog.tsx new file mode 100644 index 0000000..2f90054 --- /dev/null +++ b/src/modules/course/features/confirm-delete-dialog.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog"; + +interface ConfirmDeleteDialogProps { + open: boolean; + title: string; + description: string; + confirmLabel?: string; + isPending?: boolean; + onConfirm: () => void; + onOpenChange: (open: boolean) => void; +} + +export function ConfirmDeleteDialog({ + open, + title, + description, + confirmLabel = "Delete", + isPending, + onConfirm, + onOpenChange, +}: ConfirmDeleteDialogProps) { + return ( + + + + {title} + {description} + + + + + + + + ); +} diff --git a/src/modules/course/features/course-detail.tsx b/src/modules/course/features/course-detail.tsx index fab2d3b..99d4694 100644 --- a/src/modules/course/features/course-detail.tsx +++ b/src/modules/course/features/course-detail.tsx @@ -11,6 +11,8 @@ import { Plus, X, Trash2, + MoreVertical, + Folder, } from "lucide-react"; import { api } from "~/trpc/react"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; @@ -34,9 +36,13 @@ import { DropdownMenuTrigger, } from "~/components/ui/dropdown-menu"; import { CreateFolderDialog } from "~/modules/course/features/create-folder-dialog"; +import { EditCourseDialog } from "~/modules/course/features/edit-course-dialog"; +import { RenameFolderDialog } from "~/modules/course/features/rename-folder-dialog"; +import { RenameNoteDialog } from "~/modules/course/features/rename-note-dialog"; +import { ConfirmDeleteDialog } from "~/modules/course/features/confirm-delete-dialog"; +import { EnrollmentPanel } from "~/modules/course/features/enrollment-panel"; import { useUploadThing } from "~/lib/uploadthing"; import { toast } from "sonner"; -import { Folder } from "lucide-react"; interface CourseDetailProps { courseId: string; @@ -45,6 +51,22 @@ interface CourseDetailProps { export function CourseDetail({ courseId }: CourseDetailProps) { const [currentFolderId, setCurrentFolderId] = useState(null); const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false); + const [renamingFolder, setRenamingFolder] = useState<{ + id: string; + name: string; + } | null>(null); + const [renamingNote, setRenamingNote] = useState<{ + id: string; + title: string; + } | null>(null); + const [deletingFolder, setDeletingFolder] = useState<{ + id: string; + name: string; + } | null>(null); + const [deletingNote, setDeletingNote] = useState<{ + id: string; + title: string; + } | null>(null); const [course] = api.course.getById.useSuspenseQuery({ id: courseId }); // Fetch folder contents (folders + notes) @@ -108,8 +130,36 @@ export function CourseDetail({ courseId }: CourseDetailProps) { const user = session?.user as { id?: string; role?: string } | undefined; const isOwner = user?.id === course?.teacherId; - const isTeacherOrAdmin = user?.role === "teacher" || user?.role === "admin"; - const canUpload = isOwner && isTeacherOrAdmin; + const isAdmin = user?.role === "admin"; + const canManage = isOwner || isAdmin; + + const deleteFolder = api.folder.delete.useMutation({ + onSuccess: () => { + toast.success("Folder deleted"); + setDeletingFolder(null); + void utils.folder.getContents.invalidate({ + courseId, + folderId: currentFolderId, + }); + }, + onError: (error) => { + toast.error(error.message || "Failed to delete folder"); + }, + }); + + const deleteNote = api.note.delete.useMutation({ + onSuccess: () => { + toast.success("Note deleted"); + setDeletingNote(null); + void utils.folder.getContents.invalidate({ + courseId, + folderId: currentFolderId, + }); + }, + onError: (error) => { + toast.error(error.message || "Failed to delete note"); + }, + }); if (!course) { return ( @@ -133,187 +183,316 @@ export function CourseDetail({ courseId }: CourseDetailProps) { return ( <> -
+
{/* Header */} -
-
-
- {currentFolderId ? ( - - ) : ( - - - - )} -
-

- {currentFolderId - ? currentFolder?.name - : course.title} -

-

- {currentFolderId - ? "Folder" - : (course.description ?? - "No description")} -

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

+ {currentFolderId + ? currentFolder?.name + : course.title} +

+

+ {currentFolderId + ? "Folder" + : (course.description ?? + "No description")} +

+
-
-
- - Join Code: {course.joinCode} - - {canUpload && ( - - - - - + {canManage && ( + - - setIsCreateFolderOpen(true) - } - > - - New Folder - - - setIsUploadModalOpen(true) - } + Join Code: {course.joinCode} + + )} + {canManage && ( + + Edit Course + + } + /> + )} + {canManage && ( + + + + + - - Upload PDF - - - - )} -
-
- - {/* Content Section */} -
-
-

- Contents -

- - {contents.folders.length} folder - {contents.folders.length !== 1 ? "s" : ""},{" "} - {contents.notes.length} note - {contents.notes.length !== 1 ? "s" : ""} - -
- - {contents.folders.length === 0 && - contents.notes.length === 0 ? ( - - - -

No content yet

- {canUpload && ( -
- - -
- )} -
-
- ) : ( -
- {/* Folders */} - {contents.folders.map((folder) => ( -
- setCurrentFolderId(folder.id) - } - > - - - - - - {folder.name} - - - - -
- ))} + + + + )} +
+
- {/* Notes */} - {contents.notes.map((note) => ( - - - - - - - {note.title} - - - - -
- - - {new Date( - note.createdAt - ).toLocaleDateString()} - -
-
-
- - ))} + {/* Content Section */} +
+
+

+ Contents +

+ + {contents.folders.length} folder + {contents.folders.length !== 1 ? "s" : ""},{" "} + {contents.notes.length} note + {contents.notes.length !== 1 ? "s" : ""} +
- )} + + {contents.folders.length === 0 && + contents.notes.length === 0 ? ( + + + +

+ No content yet +

+ {canManage && ( +
+ + +
+ )} +
+
+ ) : ( +
+ {/* Folders */} + {contents.folders.map((folder) => ( +
+ setCurrentFolderId(folder.id) + } + > + + + + + + {folder.name} + + + {canManage && ( +
{ + e.stopPropagation(); + }} + > + + + + + + + setRenamingFolder( + { + id: folder.id, + name: folder.name, + } + ) + } + > + Rename + + + setDeletingFolder( + { + id: folder.id, + name: folder.name, + } + ) + } + > + Delete + + + +
+ )} +
+
+
+ ))} + + {/* Notes */} + {contents.notes.map((note) => ( + + + + + + + {note.title} + + + {canManage && ( +
{ + e.preventDefault(); + e.stopPropagation(); + }} + > + + + + + + { + event.preventDefault(); + setRenamingNote( + { + id: note.id, + title: note.title, + } + ); + }} + > + Rename + + { + event.preventDefault(); + setDeletingNote( + { + id: note.id, + title: note.title, + } + ); + }} + > + Delete + + + +
+ )} +
+ +
+ + + {new Date( + note.createdAt + ).toLocaleDateString()} + +
+
+
+ + ))} +
+ )} +
+
+
+
@@ -413,6 +592,61 @@ export function CourseDetail({ courseId }: CourseDetailProps) { open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen} /> + + { + if (!open) setRenamingFolder(null); + }} + /> + + { + if (!open) setRenamingNote(null); + }} + /> + + + deletingFolder && + deleteFolder.mutate({ id: deletingFolder.id }) + } + onOpenChange={(open) => { + if (!open) setDeletingFolder(null); + }} + /> + + + deletingNote && deleteNote.mutate({ id: deletingNote.id }) + } + onOpenChange={(open) => { + if (!open) setDeletingNote(null); + }} + /> ); } diff --git a/src/modules/course/features/course-list.tsx b/src/modules/course/features/course-list.tsx index abdab28..ce7e6c9 100644 --- a/src/modules/course/features/course-list.tsx +++ b/src/modules/course/features/course-list.tsx @@ -140,16 +140,14 @@ function CourseCard({ {new Date(course.createdAt).toLocaleDateString()} - {course.teacherRole === "teacher" && ( -
- {course.teacherName ?? "Teacher"} - -
- )} +
+ {course.teacherName ?? "Teacher"} + +
{(isStudent && isEnrolled) || (isStudent && !isEnrolled) ? (
diff --git a/src/modules/course/features/edit-course-dialog.tsx b/src/modules/course/features/edit-course-dialog.tsx new file mode 100644 index 0000000..f423e7c --- /dev/null +++ b/src/modules/course/features/edit-course-dialog.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { api } from "~/trpc/react"; +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/ui/dialog"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { Textarea } from "~/components/ui/textarea"; +import { useToast } from "~/hooks/use-toast"; + +interface EditCourseDialogProps { + courseId: string; + title: string; + description?: string | null; + trigger?: React.ReactNode; +} + +export function EditCourseDialog({ + courseId, + title, + description, + trigger, +}: EditCourseDialogProps) { + const [open, setOpen] = useState(false); + const [courseTitle, setCourseTitle] = useState(title); + const [courseDescription, setCourseDescription] = useState( + description ?? "" + ); + const [isSubmitting, setIsSubmitting] = useState(false); + const { toast } = useToast(); + + const utils = api.useUtils(); + + useEffect(() => { + if (open) { + setCourseTitle(title); + setCourseDescription(description ?? ""); + } + }, [open, title, description]); + + const updateCourse = api.course.update.useMutation({ + onSuccess: () => { + toast({ + title: "Course updated", + description: "Your course details have been saved.", + }); + setOpen(false); + void utils.course.getById.invalidate({ id: courseId }); + void utils.course.list.invalidate(); + }, + onError: (error) => { + toast({ + title: "Update failed", + description: error.message || "Failed to update course", + variant: "destructive", + }); + }, + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!courseTitle.trim()) return; + + setIsSubmitting(true); + try { + await updateCourse.mutateAsync({ + id: courseId, + title: courseTitle.trim(), + description: courseDescription.trim() || undefined, + }); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + {trigger && {trigger}} + +
+ + Edit Course + + Update the course title and description. + + +
+
+ + setCourseTitle(e.target.value)} + required + /> +
+
+ +