diff --git a/package-lock.json b/package-lock.json index 2687744..c10a123 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@prisma/client": "^6.5.0", "@radix-ui/react-alert-dialog": "^1.1.13", "@radix-ui/react-avatar": "^1.1.9", + "@radix-ui/react-dialog": "^1.1.13", "@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-label": "^2.1.6", "@radix-ui/react-navigation-menu": "^1.2.11", diff --git a/package.json b/package.json index 0a1d527..4ac7cdd 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@prisma/client": "^6.5.0", "@radix-ui/react-alert-dialog": "^1.1.13", "@radix-ui/react-avatar": "^1.1.9", + "@radix-ui/react-dialog": "^1.1.13", "@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-label": "^2.1.6", "@radix-ui/react-navigation-menu": "^1.2.11", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 94c4d6c..664aa3e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -79,7 +79,7 @@ model Note { Title String createdAt DateTime @default(now()) url String - fileType String + //fileType String createdBy User @relation(fields: [createdById], references: [id]) createdById String diff --git a/public/blurredNote.png b/public/blurredNote.png new file mode 100644 index 0000000..badfbf4 Binary files /dev/null and b/public/blurredNote.png differ diff --git a/public/favicon.ico b/public/favicon.ico deleted file mode 100644 index 60c702a..0000000 Binary files a/public/favicon.ico and /dev/null differ diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000..81b5d6f Binary files /dev/null and b/public/logo.png differ diff --git a/src/app/admin/ApprovedCourseTable.tsx b/src/app/admin/ApprovedCourseTable.tsx index cacab3c..6e6e68b 100644 --- a/src/app/admin/ApprovedCourseTable.tsx +++ b/src/app/admin/ApprovedCourseTable.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import EditButton from "~/components/EditButton"; import { api } from "~/trpc/react"; const ITEMS_PER_PAGE = 10; @@ -8,11 +9,7 @@ const ITEMS_PER_PAGE = 10; export default function ApprovedCourseTable() { const [approvedPage, setApprovedPage] = useState(1); - const {data: courses, isLoading, error } = api.admin.getAllApprovedCourses.useQuery(); - - const handleEdit = (id: string) => { - console.log("Edit:", id); - }; + const {data: courses, isLoading, error, refetch } = api.admin.getAllApprovedCourses.useQuery(); const paginate = (data: T[], page: number) => data.slice((page - 1) * ITEMS_PER_PAGE, page * ITEMS_PER_PAGE); @@ -76,12 +73,7 @@ export default function ApprovedCourseTable() { {course.code.replace(/^([A-Z]{3})(\d{4})$/, "$1 $2")} - + )) diff --git a/src/app/admin/PendingCourseTable.tsx b/src/app/admin/PendingCourseTable.tsx index 35de419..941ba1c 100644 --- a/src/app/admin/PendingCourseTable.tsx +++ b/src/app/admin/PendingCourseTable.tsx @@ -17,13 +17,14 @@ import { import { toast } from "sonner"; import { Loader2 } from "lucide-react"; import { Button } from "~/components/ui/button"; +import ApproveButton from "~/components/ApproveButton"; const ITEMS_PER_PAGE = 10; export default function PendingCourseTable() { const [requestedPage, setRequestedPage] = useState(1); - const {data: courses, isLoading, error } = api.admin.getAllPendingCourses.useQuery(); + const {data: courses, isLoading, error, refetch } = api.admin.getAllPendingCourses.useQuery(); const denyCourse = api.admin.denyCourse.useMutation({ onSuccess: (result) => { @@ -35,10 +36,6 @@ export default function PendingCourseTable() { }, }); - const handleApprove = (id: string) => { - console.log("Approved:", id); - }; - const handleDeny = (id: string) => { denyCourse.mutate({courseId: id}); }; @@ -107,12 +104,7 @@ export default function PendingCourseTable() { {course.count} - + + ) : ( + + )} + + + ); +} diff --git a/src/components/ApproveButton.tsx b/src/components/ApproveButton.tsx new file mode 100644 index 0000000..1dad0b8 --- /dev/null +++ b/src/components/ApproveButton.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/ui/dialog"; + +import { toast } from "sonner"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { Button } from "~/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "~/components/ui/form"; +import { Input } from "~/components/ui/input"; +import type { Course } from "@prisma/client"; +import { api } from "~/trpc/react"; +import { Loader2 } from "lucide-react"; + +const formSchema = z.object({ + courseName: z.string().min(1).min(10).max(60), + courseUrl: z.string().min(1), + coursePrefix: z.string().min(1).min(3).max(3), + courseCode: z.string().min(1).min(4).max(4), +}); + +interface ApproveButtonProps { + course: Course; + refetch: () => void; +} + + +export default function ApproveButton({course, refetch}: ApproveButtonProps) { + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + courseName: course.name, + coursePrefix: course.code.slice(0, 3), + courseCode: course.code.slice(3), + } + }); + + const approveCourse = api.admin.approveCourse.useMutation({ + onSuccess: (result) => { + toast.success(`${result.code} successfully approved!`); + refetch(); + }, + onError: (error) => { + toast.error("Something went wrong"); + console.error(error.message, error.data); + }, + onSettled: () => { + form.reset({ + courseName: "", + courseCode: "", + coursePrefix: "", + courseUrl: "", + }) + } + }); + + function onSubmit(values: z.infer) { + const capitalPrefix = values.coursePrefix.toUpperCase(); + approveCourse.mutate({ + courseId: course.id, + courseName: values.courseName, + coursePrefix: capitalPrefix, + courseCode: values.courseCode, + courseUrl: values.courseUrl, + }); + + + } + + return ( + + + + + + + Approve Course + + Make any changes to the course here and add an image url. + + +
+ + ( + + Course Name + + + + + + + )} + /> + + ( + + Image URL + + + + + An image url to display for the course + + + + )} + /> + +
+
+ ( + + Course Prefix + + + + + + + )} + /> +
+ +
+ ( + + Course Code + + + + + + + )} + /> +
+
+
+ + + + {approveCourse.isPending ? ( + + ) : ( + + )} +
+ + +
+
+ ); +} diff --git a/src/components/EditButton.tsx b/src/components/EditButton.tsx new file mode 100644 index 0000000..7a067c2 --- /dev/null +++ b/src/components/EditButton.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/ui/dialog"; + +import { toast } from "sonner"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { Button } from "~/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "~/components/ui/form"; +import { Input } from "~/components/ui/input"; +import type { Course } from "@prisma/client"; +import { api } from "~/trpc/react"; +import { Loader2 } from "lucide-react"; +import { useState } from "react"; + +const formSchema = z.object({ + courseName: z.string().min(1).min(10).max(60), + courseUrl: z.string().min(1), + coursePrefix: z.string().min(1).min(3).max(3), + courseCode: z.string().min(1).min(4).max(4), +}); + +interface EditButtonProps { + course: Course; + refetch: () => void; +} + +export default function EditButton({ course, refetch }: EditButtonProps) { + const [open, setOpen] = useState(false); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + courseName: course.name, + coursePrefix: course.code.slice(0, 3), + courseCode: course.code.slice(3), + courseUrl: course.url ?? undefined, + }, + }); + + const editCourse = api.admin.editCourse.useMutation({ + onSuccess: (result) => { + if (result.succes) { + toast.success(`${result.updatedCourse?.code} successfully edited`); + refetch(); + } else { + toast.error(result.message); + } + }, + onError: (error) => { + toast.error("Something went wrong"); + console.error(error.message, error.data); + }, + onSettled: () => { + form.reset({ + courseName: "", + courseCode: "", + coursePrefix: "", + courseUrl: "", + }); + setOpen(false); + }, + }); + + function onSubmit(values: z.infer) { + const capitalPrefix = values.coursePrefix.toUpperCase(); + editCourse.mutate({ + id: course.id, + name: values.courseName, + prefix: capitalPrefix, + code: values.courseCode, + url: values.courseUrl, + }); + } + + return ( + + + + + + + Edit Course + + Make any changes to the course here. + + +
+ + ( + + Course Name + + + + + + + )} + /> + + ( + + Image URL + + + + + An image url to display for the course + + + + )} + /> + +
+
+ ( + + Course Prefix + + + + + + + )} + /> +
+ +
+ ( + + Course Code + + + + + + + )} + /> +
+
+
+ + + + {editCourse.isPending ? ( + + ) : ( + + )} +
+ + +
+
+ ); +} diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx new file mode 100644 index 0000000..3d980fb --- /dev/null +++ b/src/components/NoteCard.tsx @@ -0,0 +1,32 @@ +import Image from "next/image"; +import Link from "next/link"; +import blurredNote from "../../public/blurredNote.png" +//import { format } from "date-fns"; + +type NoteCardProps = { + id: string; + title: string; + createdAt: string; +}; + +export default function NoteCard({ id, title, createdAt }: NoteCardProps) { + return ( + +
+
+ {`Preview +
+
+

{title}

+

{"May 4, 2025, MMM d, yyyy"}

+ {/*

{format(new Date(createdAt), "MMM d, yyyy")}

*/} +
+
+ + ); +} diff --git a/src/components/ui/CourseCard.tsx b/src/components/ui/CourseCard.tsx index af04a74..4a8590f 100644 --- a/src/components/ui/CourseCard.tsx +++ b/src/components/ui/CourseCard.tsx @@ -1,20 +1,25 @@ -import Image from "next/image"; +import Link from "next/link"; type CourseCardProps = { - name: string; - code: string; - imageUrl: string; - }; - - export default function CourseCard({ name, code, imageUrl }: CourseCardProps) { - return ( -
- {name} + name: string; + code: string; + imageUrl: string | null; +}; + +export default function CourseCard({ name, code, imageUrl }: CourseCardProps) { + return ( + +
+ {name}

{name}

{code}

- ); - } - \ No newline at end of file + + ); +} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..84ab5d7 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,135 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { XIcon } from "lucide-react" + +import { cn } from "~/lib/utils" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + {children} + + + Close + + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/src/server/api/routers/admin.ts b/src/server/api/routers/admin.ts index 0dea918..8630a87 100644 --- a/src/server/api/routers/admin.ts +++ b/src/server/api/routers/admin.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { createTRPCRouter, adminProcedure } from "~/server/api/trpc"; +import { Prisma } from '@prisma/client' export const adminRouter = createTRPCRouter({ getAllApprovedCourses: adminProcedure.query(async ({ ctx }) => { @@ -28,5 +29,93 @@ export const adminRouter = createTRPCRouter({ where: {id: input.courseId}, }) return deniedCourse; - }) + }), + + approveCourse: adminProcedure + .input(z.object({ + courseId: z.string(), + courseName: z.string(), + coursePrefix: z.string(), + courseCode: z.string(), + courseUrl: z.string(), + })).mutation(async ({ctx, input}) => { + const fullCode = input.coursePrefix + input.courseCode; + + const approvedCourse = await ctx.db.course.update({ + where: { + id: input.courseId, + }, + data: { + name: input.courseName, + code: fullCode, + url: input.courseUrl, + pending: false, + } + }) + + return approvedCourse; + }), + + createCourse: adminProcedure + .input(z.object({ + name: z.string(), + prefix: z.string(), + code: z.string(), + url: z.string(), + })).mutation(async ({ctx, input}) => { + try { + const fullCode = input.prefix + input.code; + const createdCourse = await ctx.db.course.create({ + data: { + name: input.name, + code: fullCode, + url: input.url, + pending: false, + } + }); + return {success: true, createdCourse: createdCourse}; + + } catch (error) { + if(error instanceof Prisma.PrismaClientKnownRequestError) { + if(error.code === 'P2002') { + return {success: false, message: "Course already exists"}; + } + } + throw error; + } + + }), + + editCourse: adminProcedure + .input(z.object({ + id: z.string(), + name: z.string(), + prefix: z.string(), + code: z.string(), + url: z.string(), + })).mutation(async ({ ctx, input }) => { + const fullCode = input.prefix + input.code; + + try { + const updatedCourse = await ctx.db.course.update({ + where: { id: input.id }, + data: { + name: input.name, + code: fullCode, + url: input.url, + } + }); + return {succes: true, updatedCourse: updatedCourse}; + + } catch (error) { + if(error instanceof Prisma.PrismaClientKnownRequestError) { + if(error.code === 'P2002') { + return {succes: false, message: "Name or code already exists"}; + } + } + throw error; + } + }), + + }); diff --git a/src/server/api/routers/course.ts b/src/server/api/routers/course.ts index 97676bf..b9c2e94 100644 --- a/src/server/api/routers/course.ts +++ b/src/server/api/routers/course.ts @@ -39,13 +39,12 @@ export const courseRouter = createTRPCRouter({ } } else { // course is awaiting review, add new request - const newCount = course.count + 1; const createdCourse = await ctx.db.course.update({ where: { code: fullCode, }, data: { - count: newCount, + count: {increment: 1} }, }) return { @@ -56,4 +55,12 @@ export const courseRouter = createTRPCRouter({ }), + fetchCourses: publicProcedure + .query(async ({ ctx }) => { + const courses = await ctx.db.course.findMany({ + where: { pending: false }, + }); + return courses; + }), + }); \ No newline at end of file