├── .eslintrc.json ├── .gitignore ├── README.md ├── actions ├── GetAnalytics.ts ├── GetChapter.tsx ├── GetCourses.ts ├── GetDashboardCourses.ts └── GetProgress.tsx ├── app ├── (auth) │ ├── (routes) │ │ ├── sign-in │ │ │ └── [[...sign-in]] │ │ │ │ └── page.tsx │ │ └── sign-up │ │ │ └── [[...sign-up]] │ │ │ └── page.tsx │ └── layout.tsx ├── (course) │ └── courses │ │ └── [courseId] │ │ ├── _components │ │ ├── CourseMobileSidebar.tsx │ │ ├── CourseNavbar.tsx │ │ ├── CourseSidebar.tsx │ │ └── CourseSidebarItem.tsx │ │ ├── chapters │ │ └── [chapterId] │ │ │ ├── _components │ │ │ ├── CourseEnrollButton.tsx │ │ │ ├── CourseProgressButton.tsx │ │ │ └── VideoPlayer.tsx │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ └── page.tsx ├── (dashboard) │ ├── (routes) │ │ ├── (root) │ │ │ ├── _components │ │ │ │ └── InfoCard.tsx │ │ │ └── page.tsx │ │ ├── search │ │ │ ├── _components │ │ │ │ ├── Categories.tsx │ │ │ │ └── CategoryItem.tsx │ │ │ └── page.tsx │ │ └── teacher │ │ │ ├── analytics │ │ │ ├── _components │ │ │ │ ├── Chart.tsx │ │ │ │ └── DataCard.tsx │ │ │ └── page.tsx │ │ │ ├── courses │ │ │ ├── [courseId] │ │ │ │ ├── _components │ │ │ │ │ ├── Actions.tsx │ │ │ │ │ ├── AttachmentForm.tsx │ │ │ │ │ ├── CategoryForm.tsx │ │ │ │ │ ├── ChapterForm.tsx │ │ │ │ │ ├── ChaptersList.tsx │ │ │ │ │ ├── DescriptionForm.tsx │ │ │ │ │ ├── ImageForm.tsx │ │ │ │ │ ├── PriceForm.tsx │ │ │ │ │ └── TitleForm.tsx │ │ │ │ ├── chapters │ │ │ │ │ └── [chapterId] │ │ │ │ │ │ ├── _components │ │ │ │ │ │ ├── ChapterAccessForm.tsx │ │ │ │ │ │ ├── ChapterActions.tsx │ │ │ │ │ │ ├── ChapterDescriptionForm.tsx │ │ │ │ │ │ ├── ChapterTitleForm.tsx │ │ │ │ │ │ └── ChapterVideoForm.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ ├── _components │ │ │ │ ├── DataTable.tsx │ │ │ │ └── columns.tsx │ │ │ └── page.tsx │ │ │ ├── create │ │ │ └── page.tsx │ │ │ └── layout.tsx │ ├── _components │ │ ├── Logo.tsx │ │ ├── MobileSidebar.tsx │ │ ├── Navbar.tsx │ │ ├── SideRoutes.tsx │ │ ├── Sidebar.tsx │ │ └── SidebarItem.tsx │ └── layout.tsx ├── api │ ├── courses │ │ ├── [courseId] │ │ │ ├── attachments │ │ │ │ ├── [attachmentId] │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ ├── chapters │ │ │ │ ├── [chapterId] │ │ │ │ │ ├── progress │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── publish │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── route.ts │ │ │ │ │ └── unpublish │ │ │ │ │ │ └── route.ts │ │ │ │ ├── reorder │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ ├── checkout │ │ │ │ └── route.ts │ │ │ ├── publish │ │ │ │ └── route.ts │ │ │ ├── route.ts │ │ │ └── unpublish │ │ │ │ └── route.ts │ │ └── route.ts │ ├── uploadthing │ │ ├── core.ts │ │ └── route.ts │ └── webhook │ │ └── route.ts ├── favicon.ico ├── globals.css └── layout.tsx ├── components.json ├── components ├── Banner.tsx ├── CourseCard.tsx ├── CourseProgress.tsx ├── CoursesList.tsx ├── Editor.tsx ├── FileUpload.tsx ├── IconBadge.tsx ├── NavbarRoutes.tsx ├── Preview.tsx ├── SearchInput.tsx ├── modals │ └── ConfirmModal.tsx ├── providers │ ├── confetti-provider.tsx │ └── toaster-provider.tsx └── ui │ ├── alert-dialog.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── card.tsx │ ├── checkbox.tsx │ ├── combobox.tsx │ ├── command.tsx │ ├── dialog.tsx │ ├── dropdown-menu.tsx │ ├── form.tsx │ ├── input.tsx │ ├── label.tsx │ ├── popover.tsx │ ├── progress.tsx │ ├── separator.tsx │ ├── sheet.tsx │ ├── table.tsx │ └── textarea.tsx ├── hooks ├── use-confetti-store.ts └── use-debounce.tsx ├── lib ├── db.ts ├── format.ts ├── stripe.ts ├── teacher.ts ├── uploadthing.ts └── utils.ts ├── middleware.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.js ├── prisma └── schema.prisma ├── public ├── amira-logo-1.png ├── amira-logo.svg ├── amiraLogo.png ├── e-learning-screenshot.png ├── logo.svg ├── next.svg └── vercel.svg ├── scripts └── seed.ts ├── tailwind.config.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # E-Learning Platform 2 | ![Screenshot](/public/e-learning-screenshot.png) 3 | 4 | ## Overview 5 | 6 | Welcome to the E-Learning Platform, a robust solution that caters to both students and teachers, fostering a seamless educational experience. Users can effortlessly browse, filter, and purchase courses, while teachers enjoy an enhanced "Teacher Mode" for course creation and management. 7 | 8 | ### Teacher Mode Trial Account 9 | 10 | To learn more and gain firsthand experience, we've created a designated Teacher Mode trial account: 11 | 12 | - **Email:** teacher_mode@outlook.com 13 | - **Password:** teacher2024 14 | 15 | ## Demo 16 | Check out the live demo here : https://e-learning-palatform-nextjs-v0.vercel.app/ 17 | 18 | ## Key Features 19 | 20 | - **Browse & Filter Courses:** Users can easily browse and filter available courses based on their preferences. 21 | 22 | - **Purchase Courses using Stripe:** Seamless integration with Stripe for secure course purchases. 23 | 24 | - **Mark Chapters as Completed/Uncompleted:** Students can track their progress by marking chapters as completed or uncompleted. 25 | 26 | - **Progress Calculation:** The system calculates and displays the progress of each enrolled course. 27 | 28 | - **Student Dashboard:** Personalized dashboard for students to manage their courses and track progress. 29 | 30 | - **Teacher Mode:** Teachers have access to additional features, enabling them to create and manage courses. 31 | 32 | - **Create New Courses:** Teachers can create new courses with rich text descriptions, thumbnails, and attachments. 33 | 34 | - **Create New Chapters:** Easy chapter creation with drag-and-drop reordering. 35 | 36 | - **Upload Media:** Integration with UploadThing for uploading thumbnails, attachments, and videos. 37 | 38 | - **Video Processing with Mux:** Video processing capabilities using Mux for efficient multimedia handling. 39 | 40 | - **HLS Video Player using Mux:** Integration of Mux for HLS video playback, ensuring a smooth viewing experience. 41 | 42 | - **Rich Text Editor:** Teachers can use a rich text editor for creating detailed chapter descriptions. 43 | 44 | - **Authentication using Clerk:** Secure authentication provided by Clerk for user management. 45 | 46 | - **ORM using Prisma:** Object-Relational Mapping (ORM) facilitated by Prisma for efficient database interactions. 47 | 48 | - **MySQL Database using Planetscale:** Reliable MySQL database powered by Planetscale for robust data storage. 49 | 50 | ## Getting Started 51 | 52 | ### Prerequisites 53 | 54 | - Node.js 55 | - npm or yarn 56 | - Postgres Database 57 | - Stripe Account 58 | - Uploadthing Account 59 | - Mux Account : Due to current limitations(free trial), videos uploaded in this preview version are subject to automatic deletion after 60 | 24 hours and cannot exceed 10 seconds in length. 61 | - Clerk Account 62 | - Planetscale Account 63 | 64 | ### Installation 65 | 66 | 1. Clone the repository: 67 | ```bash 68 | git clone https://github.com/your-username/e-learning-platform.git 69 | cd e-learning-platform 70 | npm i 71 | ``` 72 | 2. .env: 73 | ```bash 74 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= 75 | CLERK_SECRET_KEY= 76 | 77 | NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in 78 | NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up 79 | NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/ 80 | NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/ 81 | 82 | 83 | POSTGRES_URL= 84 | POSTGRES_PRISMA_URL= 85 | POSTGRES_URL_NON_POOLING= 86 | POSTGRES_USER= 87 | POSTGRES_HOST= 88 | POSTGRES_PASSWORD= 89 | POSTGRES_DATABASE= 90 | 91 | UPLOADTHING_SECRET= 92 | 93 | MUX_TOKEN_ID= 94 | MUX_TOKEN_SECRET= 95 | 96 | STRIPE_API_KEY= 97 | NEXT_PUBLIC_APP_URL=http://localhost:3000 98 | STRIPE_WEBHOOK_SECRET= 99 | 100 | NEXT_PUBLIC_TEACHER_IDS=user_1,user_2,user_3,user_4,user_5 101 | ``` 102 | 103 | ```bash 104 | npm run dev 105 | ``` 106 | ```bash 107 | stripe listen --forward-to localhost:3000/api/webhook 108 | ``` 109 | 110 | 111 | -------------------------------------------------------------------------------- /actions/GetAnalytics.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | import { Course, Purchase } from "@prisma/client"; 3 | 4 | type PurchaseWithCourse = Purchase & { 5 | course: Course; 6 | }; 7 | 8 | const groupByCourse = (purchases: PurchaseWithCourse[]) => { 9 | const grouped: { [courseTitle: string]: number } = {}; 10 | 11 | purchases.forEach((purchase) => { 12 | const courseTitle = purchase.course.title; 13 | if (!grouped[courseTitle]) { 14 | grouped[courseTitle] = 0; 15 | } 16 | grouped[courseTitle] += purchase.course.price!; 17 | }); 18 | 19 | return grouped; 20 | }; 21 | 22 | export const getAnalytics = async (userId: string) => { 23 | try { 24 | const purchases = await db.purchase.findMany({ 25 | where: { 26 | course: { 27 | userId: userId 28 | } 29 | }, 30 | include: { 31 | course: true, 32 | } 33 | }); 34 | 35 | const groupedEarnings = groupByCourse(purchases); 36 | const data = Object.entries(groupedEarnings).map(([courseTitle, total]) => ({ 37 | name: courseTitle, 38 | total: total, 39 | })); 40 | 41 | const totalRevenue = data.reduce((acc, curr) => acc + curr.total, 0); 42 | const totalSales = purchases.length; 43 | 44 | return { 45 | data, 46 | totalRevenue, 47 | totalSales, 48 | } 49 | } catch (error) { 50 | console.log("[GET_ANALYTICS]", error); 51 | return { 52 | data: [], 53 | totalRevenue: 0, 54 | totalSales: 0, 55 | } 56 | } 57 | } -------------------------------------------------------------------------------- /actions/GetChapter.tsx: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | import { attachment, Chapter } from "@prisma/client"; 3 | 4 | interface GetChapterProps { 5 | userId: string; 6 | courseId: string; 7 | chapterId: string; 8 | }; 9 | 10 | export const getChapter = async ({ 11 | userId, 12 | courseId, 13 | chapterId, 14 | }: GetChapterProps) => { 15 | try { 16 | const purchase = await db.purchase.findUnique({ 17 | where: { 18 | userId_courseId: { 19 | userId, 20 | courseId, 21 | } 22 | } 23 | }); 24 | 25 | const course = await db.course.findUnique({ 26 | where: { 27 | isPublished: true, 28 | id: courseId, 29 | }, 30 | select: { 31 | price: true, 32 | } 33 | }); 34 | 35 | const chapter = await db.chapter.findUnique({ 36 | where: { 37 | id: chapterId, 38 | isPublished: true, 39 | } 40 | }); 41 | 42 | if (!chapter || !course) { 43 | throw new Error("Chapter or course not found"); 44 | } 45 | 46 | let muxData = null; 47 | let attachments: attachment[] = []; 48 | let nextChapter: Chapter | null = null; 49 | 50 | if (purchase) { 51 | attachments = await db.attachment.findMany({ 52 | where: { 53 | courseId: courseId 54 | } 55 | }); 56 | } 57 | 58 | if (chapter.isFree || purchase) { 59 | muxData = await db.muxData.findUnique({ 60 | where: { 61 | chapterId: chapterId, 62 | } 63 | }); 64 | 65 | nextChapter = await db.chapter.findFirst({ 66 | where: { 67 | courseId: courseId, 68 | isPublished: true, 69 | position: { 70 | gt: chapter?.position, 71 | } 72 | }, 73 | orderBy: { 74 | position: "asc", 75 | } 76 | }); 77 | } 78 | 79 | const userProgress = await db.userProgress.findUnique({ 80 | where: { 81 | userId_chapterId: { 82 | userId, 83 | chapterId, 84 | } 85 | } 86 | }); 87 | 88 | return { 89 | chapter, 90 | course, 91 | muxData, 92 | attachments, 93 | nextChapter, 94 | userProgress, 95 | purchase, 96 | }; 97 | } catch (error) { 98 | console.log("[GET_CHAPTER]", error); 99 | return { 100 | chapter: null, 101 | course: null, 102 | muxData: null, 103 | attachments: [], 104 | nextChapter: null, 105 | userProgress: null, 106 | purchase: null, 107 | } 108 | } 109 | } -------------------------------------------------------------------------------- /actions/GetCourses.ts: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import { Category, Course } from "@prisma/client"; 4 | import {getProgress} from "@/actions/GetProgress"; 5 | import { db } from "@/lib/db"; 6 | 7 | type CourseWithProgressWithCategory = Course & { 8 | category: Category | null; 9 | chapters: { 10 | id: string; 11 | }[]; 12 | progress: number | null; 13 | }; 14 | type getCourses = { 15 | userId: string; 16 | title?: string; 17 | categoryId?: string; 18 | }; 19 | 20 | export const GetCourses = async ({ 21 | userId, 22 | title, 23 | categoryId, 24 | }: getCourses): Promise => { 25 | try { 26 | const courses = await db.course.findMany({ 27 | where: { 28 | isPublished: true, 29 | title: { 30 | contains: title, 31 | }, 32 | categoryId, 33 | }, 34 | include: { 35 | category: true, 36 | chapters: { 37 | where: { 38 | isPublished: true, 39 | }, 40 | select: { 41 | id: true, 42 | }, 43 | }, 44 | purchases: { 45 | where: { 46 | userId, 47 | }, 48 | }, 49 | }, 50 | orderBy: { 51 | createdAt: "desc", 52 | }, 53 | }); 54 | 55 | const coursesWithProgress: CourseWithProgressWithCategory[] = 56 | await Promise.all( 57 | courses.map(async (course) => { 58 | if (course.purchases.length === 0) { 59 | return { 60 | ...course, 61 | progress: null, 62 | } 63 | } 64 | const progressPercentage = await getProgress(userId,course.id); 65 | return { 66 | ...course, 67 | progress:progressPercentage 68 | } 69 | }) 70 | ); 71 | return coursesWithProgress 72 | } catch (error) { 73 | console.log("[GET_COURSES]", error); 74 | return []; 75 | } 76 | }; 77 | -------------------------------------------------------------------------------- /actions/GetDashboardCourses.ts: -------------------------------------------------------------------------------- 1 | import { Category, Chapter, Course } from "@prisma/client"; 2 | 3 | import { db } from "@/lib/db"; 4 | import { getProgress } from "@/actions/GetProgress"; 5 | 6 | type CourseWithProgressWithCategory = Course & { 7 | category: Category; 8 | chapters: Chapter[]; 9 | progress: number | null; 10 | }; 11 | 12 | type DashboardCourses = { 13 | completedCourses: CourseWithProgressWithCategory[]; 14 | coursesInProgress: CourseWithProgressWithCategory[]; 15 | } 16 | 17 | export const getDashboardCourses = async (userId: string): Promise => { 18 | try { 19 | const purchasedCourses = await db.purchase.findMany({ 20 | where: { 21 | userId: userId, 22 | }, 23 | select: { 24 | course: { 25 | include: { 26 | category: true, 27 | chapters: { 28 | where: { 29 | isPublished: true, 30 | } 31 | } 32 | } 33 | } 34 | } 35 | }); 36 | 37 | const courses = purchasedCourses.map((purchase) => purchase.course) as CourseWithProgressWithCategory[]; 38 | 39 | for (let course of courses) { 40 | const progress = await getProgress(userId, course.id); 41 | course["progress"] = progress; 42 | } 43 | 44 | const completedCourses = courses.filter((course) => course.progress === 100); 45 | const coursesInProgress = courses.filter((course) => (course.progress ?? 0) < 100); 46 | 47 | return { 48 | completedCourses, 49 | coursesInProgress, 50 | } 51 | } catch (error) { 52 | console.log("[GET_DASHBOARD_COURSES]", error); 53 | return { 54 | completedCourses: [], 55 | coursesInProgress: [], 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /actions/GetProgress.tsx: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | 3 | export const getProgress = async ( 4 | userId: string, 5 | courseId: string, 6 | ): Promise => { 7 | try { 8 | const publishedChapters = await db.chapter.findMany({ 9 | where: { 10 | courseId: courseId, 11 | isPublished: true, 12 | }, 13 | select: { 14 | id: true, 15 | } 16 | }); 17 | 18 | const publishedChapterIds = publishedChapters.map((chapter) => chapter.id); 19 | 20 | const validCompletedChapters = await db.userProgress.count({ 21 | where: { 22 | userId: userId, 23 | chapterId: { 24 | in: publishedChapterIds, 25 | }, 26 | isCompleted: true, 27 | } 28 | }); 29 | 30 | const progressPercentage = (validCompletedChapters / publishedChapterIds.length) * 100; 31 | 32 | return progressPercentage; 33 | } catch (error) { 34 | console.log("[GET_PROGRESS]", error); 35 | return 0; 36 | } 37 | } -------------------------------------------------------------------------------- /app/(auth)/(routes)/sign-in/[[...sign-in]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignIn } from "@clerk/nextjs"; 2 | 3 | export default function Page() { 4 | return ; 5 | } -------------------------------------------------------------------------------- /app/(auth)/(routes)/sign-up/[[...sign-up]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignUp } from "@clerk/nextjs"; 2 | 3 | export default function Page() { 4 | return ; 5 | } -------------------------------------------------------------------------------- /app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const AuthLayout = ({children}:{children:React.ReactNode}) => { 4 | return ( 5 |
{children}
6 | ) 7 | } 8 | 9 | export default AuthLayout -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/_components/CourseMobileSidebar.tsx: -------------------------------------------------------------------------------- 1 | import { Menu } from "lucide-react"; 2 | import { Chapter, Course, UserProgress } from "@prisma/client"; 3 | 4 | import { 5 | Sheet, 6 | SheetContent, 7 | SheetTrigger 8 | } from "@/components/ui/sheet"; 9 | 10 | import CourseSidebar from "./CourseSidebar"; 11 | 12 | interface CourseMobileSidebarProps { 13 | course: Course & { 14 | chapters: (Chapter & { 15 | userProgress: UserProgress[] | null; 16 | })[]; 17 | }; 18 | progressCount: number; 19 | }; 20 | 21 | export const CourseMobileSidebar = ({ 22 | course, 23 | progressCount, 24 | }: CourseMobileSidebarProps) => { 25 | return ( 26 | 27 | 28 | 29 | 30 | 31 | 35 | 36 | 37 | ) 38 | } -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/_components/CourseNavbar.tsx: -------------------------------------------------------------------------------- 1 | import { Chapter, Course, UserProgress } from "@prisma/client" 2 | 3 | import NavbarRoutes from "@/components/NavbarRoutes"; 4 | 5 | import { CourseMobileSidebar } from "./CourseMobileSidebar"; 6 | 7 | interface CourseNavbarProps { 8 | course: Course & { 9 | chapters: (Chapter & { 10 | userProgress: UserProgress[] | null; 11 | })[]; 12 | }; 13 | progressCount: number; 14 | }; 15 | 16 | export const CourseNavbar = ({ 17 | course, 18 | progressCount, 19 | }: CourseNavbarProps) => { 20 | return ( 21 |
22 | 26 | 27 |
28 | ) 29 | } -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/_components/CourseSidebar.tsx: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import { db } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs"; 5 | import { Chapter, Course, UserProgress } from "@prisma/client"; 6 | import { redirect } from "next/navigation"; 7 | import React from "react"; 8 | import CourseSidebarItem from "./CourseSidebarItem"; 9 | import { CourseProgress } from "@/components/CourseProgress"; 10 | 11 | interface CourseSidebarProps { 12 | course: Course & { 13 | chapters: (Chapter & { 14 | userProgress: UserProgress[] | null; 15 | })[]; 16 | }; 17 | progressCount: number; 18 | } 19 | const CourseSidebar = async ({ course, progressCount }: CourseSidebarProps) => { 20 | const { userId } = auth(); 21 | 22 | if (!userId) { 23 | return redirect("/"); 24 | } 25 | 26 | const purchase = await db.purchase.findUnique({ 27 | where: { 28 | userId_courseId: { 29 | userId, 30 | courseId: course.id, 31 | }, 32 | }, 33 | }); 34 | 35 | return ( 36 |
37 |
38 |

{course.title}

39 | {purchase && ( 40 |
41 | 45 |
46 | )} 47 | {/* CHECK PURCHASE AND ADD PROGRESS */} 48 |
49 |
50 | {course.chapters.map((chapter) => ( 51 | 59 | ))} 60 |
61 |
62 | ); 63 | }; 64 | 65 | export default CourseSidebar; 66 | -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/_components/CourseSidebarItem.tsx: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | "use client"; 3 | import { cn } from "@/lib/utils"; 4 | import { CheckCircle, Lock, PlayCircle } from "lucide-react"; 5 | import { usePathname } from "next/navigation"; 6 | import { useRouter } from "next/navigation"; 7 | import React from "react"; 8 | 9 | interface CourseSidebarItemProps { 10 | label: string; 11 | id: string; 12 | isCompleted: boolean; 13 | courseId: string; 14 | isLocked: boolean; 15 | } 16 | 17 | const CourseSidebarItem = ({ 18 | label, 19 | id, 20 | isCompleted, 21 | courseId, 22 | isLocked, 23 | }: CourseSidebarItemProps) => { 24 | const pathname = usePathname(); 25 | const router = useRouter(); 26 | 27 | const Icon = isLocked ? Lock : isCompleted ? CheckCircle : PlayCircle; 28 | const isActive = pathname?.includes(id); 29 | const onClick = () => { 30 | router.push(`/courses/${courseId}/chapters/${id}`); 31 | }; 32 | return ( 33 | 62 | ); 63 | }; 64 | 65 | export default CourseSidebarItem; 66 | -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/chapters/[chapterId]/_components/CourseEnrollButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import axios from "axios"; 4 | import { useState } from "react"; 5 | import toast from "react-hot-toast"; 6 | 7 | import { Button } from "@/components/ui/button"; 8 | import { formatPrice } from "@/lib/format"; 9 | 10 | interface CourseEnrollButtonProps { 11 | price: number; 12 | courseId: string; 13 | } 14 | 15 | export const CourseEnrollButton = ({ 16 | price, 17 | courseId, 18 | }: CourseEnrollButtonProps) => { 19 | const [isLoading, setIsLoading] = useState(false); 20 | 21 | 22 | 23 | const onClick = async () => { 24 | try { 25 | setIsLoading(true); 26 | 27 | const response = await axios.post(`/api/courses/${courseId}/checkout`) 28 | 29 | window.location.assign(response.data.url); 30 | } catch { 31 | toast.error("Something went wrong"); 32 | } finally { 33 | setIsLoading(false); 34 | } 35 | } 36 | 37 | return ( 38 | 46 | ) 47 | } -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/chapters/[chapterId]/_components/CourseProgressButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import axios from "axios"; 4 | import { CheckCircle, XCircle } from "lucide-react"; 5 | import { useRouter } from "next/navigation"; 6 | import { useState } from "react"; 7 | import toast from "react-hot-toast"; 8 | 9 | import { Button } from "@/components/ui/button"; 10 | import { useConfettiStore } from "@/hooks/use-confetti-store"; 11 | 12 | interface CourseProgressButtonProps { 13 | chapterId: string; 14 | courseId: string; 15 | isCompleted?: boolean; 16 | nextChapterId?: string; 17 | }; 18 | 19 | export const CourseProgressButton = ({ 20 | chapterId, 21 | courseId, 22 | isCompleted, 23 | nextChapterId 24 | }: CourseProgressButtonProps) => { 25 | const router = useRouter(); 26 | const confetti = useConfettiStore(); 27 | const [isLoading, setIsLoading] = useState(false); 28 | 29 | const onClick = async () => { 30 | try { 31 | setIsLoading(true); 32 | 33 | await axios.put(`/api/courses/${courseId}/chapters/${chapterId}/progress`, { 34 | isCompleted: !isCompleted 35 | }); 36 | 37 | if (!isCompleted && !nextChapterId) { 38 | confetti.onOpen(); 39 | } 40 | 41 | if (!isCompleted && nextChapterId) { 42 | router.push(`/courses/${courseId}/chapters/${nextChapterId}`); 43 | } 44 | 45 | toast.success("Progress updated"); 46 | router.refresh(); 47 | } catch { 48 | toast.error("Something went wrong"); 49 | } finally { 50 | setIsLoading(false); 51 | } 52 | } 53 | 54 | const Icon = isCompleted ? XCircle : CheckCircle 55 | 56 | return ( 57 | 67 | ) 68 | } -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/chapters/[chapterId]/_components/VideoPlayer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import axios from "axios"; 4 | import MuxPlayer from "@mux/mux-player-react"; 5 | import { useState } from "react"; 6 | import { toast } from "react-hot-toast"; 7 | import { useRouter } from "next/navigation"; 8 | import { Loader2, Lock } from "lucide-react"; 9 | 10 | import { cn } from "@/lib/utils"; 11 | import { useConfettiStore } from "@/hooks/use-confetti-store"; 12 | 13 | interface VideoPlayerProps { 14 | playbackId: string; 15 | courseId: string; 16 | chapterId: string; 17 | nextChapterId?: string; 18 | isLocked: boolean; 19 | completeOnEnd: boolean; 20 | title: string; 21 | }; 22 | 23 | export const VideoPlayer = ({ 24 | playbackId, 25 | courseId, 26 | chapterId, 27 | nextChapterId, 28 | isLocked, 29 | completeOnEnd, 30 | title, 31 | }: VideoPlayerProps) => { 32 | const [isReady, setIsReady] = useState(false); 33 | const router = useRouter(); 34 | const confetti = useConfettiStore(); 35 | 36 | const onEnd = async () => { 37 | try { 38 | if (completeOnEnd) { 39 | await axios.put(`/api/courses/${courseId}/chapters/${chapterId}/progress`, { 40 | isCompleted: true, 41 | }); 42 | 43 | if (!nextChapterId) { 44 | confetti.onOpen(); 45 | } 46 | 47 | toast.success("Progress updated"); 48 | router.refresh(); 49 | 50 | if (nextChapterId) { 51 | router.push(`/courses/${courseId}/chapters/${nextChapterId}`) 52 | } 53 | } 54 | } catch { 55 | toast.error("Something went wrong"); 56 | } 57 | } 58 | 59 | return ( 60 |
61 | {!isReady && !isLocked && ( 62 |
63 | 64 |
65 | )} 66 | {isLocked && ( 67 |
68 | 69 |

70 | This chapter is locked 71 |

72 |
73 | )} 74 | {!isLocked && ( 75 | setIsReady(true)} 81 | onEnded={onEnd} 82 | autoPlay 83 | playbackId={playbackId} 84 | /> 85 | )} 86 |
87 | ) 88 | } -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/chapters/[chapterId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from "@clerk/nextjs"; 2 | import { redirect } from "next/navigation"; 3 | import { File } from "lucide-react"; 4 | 5 | import { getChapter } from "@/actions/GetChapter"; 6 | import Banner from "@/components/Banner"; 7 | import { Separator } from "@/components/ui/separator"; 8 | import { Preview } from "@/components/Preview"; 9 | 10 | import { VideoPlayer } from "./_components/VideoPlayer"; 11 | import { CourseEnrollButton } from "./_components/CourseEnrollButton"; 12 | import { CourseProgressButton } from "./_components/CourseProgressButton"; 13 | 14 | const ChapterIdPage = async ({ 15 | params 16 | }: { 17 | params: { courseId: string; chapterId: string } 18 | }) => { 19 | const { userId } = auth(); 20 | 21 | if (!userId) { 22 | return redirect("/"); 23 | } 24 | 25 | const { 26 | chapter, 27 | course, 28 | muxData, 29 | attachments, 30 | nextChapter, 31 | userProgress, 32 | purchase, 33 | } = await getChapter({ 34 | userId, 35 | chapterId: params.chapterId, 36 | courseId: params.courseId, 37 | }); 38 | 39 | if (!chapter || !course) { 40 | return redirect("/") 41 | } 42 | 43 | 44 | const isLocked = !chapter.isFree && !purchase; 45 | const completeOnEnd = !!purchase && !userProgress?.isCompleted; 46 | 47 | return ( 48 |
49 | {userProgress?.isCompleted && ( 50 | 54 | )} 55 | {isLocked && ( 56 | 60 | )} 61 |
62 |
63 | 72 |
73 |
74 |
75 |

76 | {chapter.title} 77 |

78 | {purchase ? ( 79 | 85 | ) : ( 86 | 90 | )} 91 |
92 | 93 |
94 | 95 |
96 | {!!attachments.length && ( 97 | <> 98 | 99 |
100 | {attachments.map((attachment) => ( 101 | 107 | 108 |

109 | {attachment.name} 110 |

111 |
112 | ))} 113 |
114 | 115 | )} 116 |
117 |
118 |
119 | ); 120 | } 121 | 122 | export default ChapterIdPage; -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from "@clerk/nextjs"; 2 | import { redirect } from "next/navigation"; 3 | 4 | import { db } from "@/lib/db"; 5 | import { getProgress } from "@/actions/GetProgress"; 6 | 7 | import CourseSidebar from "./_components/CourseSidebar"; 8 | import { CourseNavbar } from "./_components/CourseNavbar"; 9 | 10 | const CourseLayout = async ({ 11 | children, 12 | params 13 | }: { 14 | children: React.ReactNode; 15 | params: { courseId: string }; 16 | }) => { 17 | const { userId } = auth(); 18 | 19 | if (!userId) { 20 | return redirect("/") 21 | } 22 | 23 | const course = await db.course.findUnique({ 24 | where: { 25 | id: params.courseId, 26 | }, 27 | include: { 28 | chapters: { 29 | where: { 30 | isPublished: true, 31 | }, 32 | include: { 33 | userProgress: { 34 | where: { 35 | userId, 36 | } 37 | } 38 | }, 39 | orderBy: { 40 | position: "asc" 41 | } 42 | }, 43 | }, 44 | }); 45 | 46 | if (!course) { 47 | return redirect("/"); 48 | } 49 | 50 | const progressCount = await getProgress(userId, course.id); 51 | 52 | return ( 53 |
54 |
55 | 59 |
60 |
61 | 65 |
66 |
67 | {children} 68 |
69 |
70 | ) 71 | } 72 | 73 | export default CourseLayout -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const CourseIdPage = () => { 4 | return ( 5 |
Watch the course
6 | ) 7 | } 8 | 9 | export default CourseIdPage -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/(root)/_components/InfoCard.tsx: -------------------------------------------------------------------------------- 1 | import { LucideIcon } from "lucide-react"; 2 | 3 | import IconBadge from "@/components/IconBadge" 4 | 5 | interface InfoCardProps { 6 | numberOfItems: number; 7 | variant?: "default" | "success"; 8 | label: string; 9 | icon: LucideIcon; 10 | } 11 | 12 | export const InfoCard = ({ 13 | variant, 14 | icon: Icon, 15 | numberOfItems, 16 | label, 17 | }: InfoCardProps) => { 18 | return ( 19 |
20 | 24 |
25 |

26 | {label} 27 |

28 |

29 | {numberOfItems} {numberOfItems === 1 ? "Course" : "Courses"} 30 |

31 |
32 |
33 | ) 34 | } -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/(root)/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from "@clerk/nextjs" 2 | import { redirect } from "next/navigation"; 3 | import { CheckCircle, Clock } from "lucide-react"; 4 | 5 | import { getDashboardCourses } from "@/actions/GetDashboardCourses"; 6 | import CoursesList from "@/components/CoursesList"; 7 | 8 | import { InfoCard } from "./_components/InfoCard"; 9 | 10 | export default async function Dashboard() { 11 | const { userId } = auth(); 12 | 13 | if (!userId) { 14 | return redirect("/"); 15 | } 16 | 17 | const { 18 | completedCourses, 19 | coursesInProgress 20 | } = await getDashboardCourses(userId); 21 | 22 | return ( 23 |
24 |
25 | 30 | 36 |
37 | 40 |
41 | ) 42 | } -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/search/_components/Categories.tsx: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | "use client"; 4 | 5 | import { Category } from "@prisma/client"; 6 | import { 7 | FcEngineering, 8 | FcFilmReel, 9 | FcMultipleDevices, 10 | FcMusic, 11 | FcOldTimeCamera, 12 | FcSalesPerformance, 13 | FcSportsMode, 14 | } from "react-icons/fc"; 15 | import { IconType } from "react-icons/lib"; 16 | import CategoryItem from "./CategoryItem"; 17 | 18 | interface CategoriesProps { 19 | items: Category[]; 20 | } 21 | const iconMap: Record = { 22 | Music: FcMusic, 23 | Photography: FcOldTimeCamera, 24 | Fitness: FcSportsMode, 25 | Accounting: FcSalesPerformance, 26 | "Computer Science": FcMultipleDevices, 27 | Filming: FcFilmReel, 28 | Engineering: FcEngineering, 29 | }; 30 | 31 | const Categories = ({ items }: CategoriesProps) => { 32 | return
33 | {items.map((item) =>( 34 | 40 | ))} 41 |
; 42 | }; 43 | 44 | export default Categories; 45 | -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/search/_components/CategoryItem.tsx: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | "use client"; 4 | import qs from "query-string"; 5 | import { cn } from "@/lib/utils"; 6 | import { usePathname, useRouter, useSearchParams } from "next/navigation"; 7 | import React from "react"; 8 | import { IconType } from "react-icons/lib"; 9 | 10 | interface CategoryItemProps { 11 | label: string; 12 | value?: string; 13 | icon?: IconType; 14 | } 15 | 16 | const CategoryItem = ({ label, value, icon: Icon }: CategoryItemProps) => { 17 | const pathname = usePathname(); 18 | const router = useRouter(); 19 | const searchParams = useSearchParams(); 20 | const currentCategoryId = searchParams.get("categoryId"); 21 | const currentTitle = searchParams.get("title"); 22 | 23 | const isSelected = currentCategoryId === value; 24 | 25 | const onClick = () => { 26 | const url = qs.stringifyUrl( 27 | { 28 | url: pathname, 29 | query: { 30 | title: currentTitle, 31 | categoryId: isSelected ? null : value, 32 | }, 33 | }, 34 | { 35 | skipNull: true, 36 | skipEmptyString: true, 37 | } 38 | ); 39 | router.push(url); 40 | }; 41 | return ( 42 | 52 | ); 53 | }; 54 | 55 | export default CategoryItem; 56 | -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/search/page.tsx: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import { auth } from "@clerk/nextjs"; 4 | import { redirect } from "next/navigation"; 5 | 6 | import { db } from "@/lib/db"; 7 | import SearchInput from "@/components/SearchInput"; 8 | import { GetCourses } from "@/actions/GetCourses"; 9 | 10 | import Categories from "./_components/Categories"; 11 | import CoursesList from "@/components/CoursesList"; 12 | 13 | interface SearchPageProps { 14 | searchParams: { 15 | title: string; 16 | categoryId: string; 17 | }; 18 | } 19 | const SearchPAge = async ({ searchParams }: SearchPageProps) => { 20 | const { userId } = auth(); 21 | 22 | if (!userId) { 23 | redirect("/"); 24 | } 25 | 26 | const categories = await db.category.findMany({ 27 | orderBy: { 28 | name: "asc", 29 | }, 30 | }); 31 | 32 | const courses = await GetCourses({ 33 | userId, 34 | ...searchParams, 35 | }); 36 | 37 | return ( 38 | <> 39 |
40 | 41 |
42 |
43 | 44 | 45 |
46 | 47 | ); 48 | }; 49 | 50 | export default SearchPAge; 51 | -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/teacher/analytics/_components/Chart.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Bar, 5 | BarChart, 6 | ResponsiveContainer, 7 | XAxis, 8 | YAxis, 9 | } from "recharts"; 10 | 11 | import { Card } from "@/components/ui/card"; 12 | 13 | interface ChartProps { 14 | data: { 15 | name: string; 16 | total: number; 17 | }[]; 18 | } 19 | 20 | export const Chart = ({ 21 | data 22 | }: ChartProps) => { 23 | return ( 24 | 25 | 26 | 27 | 34 | `$${value}`} 40 | /> 41 | 46 | 47 | 48 | 49 | ) 50 | } -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/teacher/analytics/_components/DataCard.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | CardContent, 4 | CardHeader, 5 | CardTitle 6 | } from "@/components/ui/card"; 7 | import { formatPrice } from "@/lib/format"; 8 | 9 | interface DataCardProps { 10 | value: number; 11 | label: string; 12 | shouldFormat?: boolean; 13 | } 14 | 15 | export const DataCard = ({ 16 | value, 17 | label, 18 | shouldFormat, 19 | }: DataCardProps) => { 20 | return ( 21 | 22 | 23 | 24 | {label} 25 | 26 | 27 | 28 |
29 | {shouldFormat ? formatPrice(value) : value} 30 |
31 |
32 |
33 | ) 34 | } -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/teacher/analytics/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from "@clerk/nextjs"; 2 | import { redirect } from "next/navigation"; 3 | 4 | import { getAnalytics } from "@/actions/GetAnalytics"; 5 | 6 | import { DataCard } from "./_components/DataCard"; 7 | import { Chart } from "./_components/Chart"; 8 | 9 | const AnalyticsPage = async () => { 10 | const { userId } = auth(); 11 | 12 | if (!userId) { 13 | return redirect("/"); 14 | } 15 | 16 | const { 17 | data, 18 | totalRevenue, 19 | totalSales, 20 | } = await getAnalytics(userId); 21 | 22 | return ( 23 |
24 |
25 | 30 | 34 |
35 | 38 |
39 | ); 40 | } 41 | 42 | export default AnalyticsPage; -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/teacher/courses/[courseId]/_components/Actions.tsx: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | "use client"; 4 | 5 | import { useState } from "react"; 6 | 7 | import { Trash } from "lucide-react"; 8 | import toast from "react-hot-toast"; 9 | import axios from "axios"; 10 | 11 | import ConfirmModal from "@/components/modals/ConfirmModal"; 12 | import { Button } from "@/components/ui/button"; 13 | import { useRouter } from "next/navigation"; 14 | import { useConfettiStore } from "@/hooks/use-confetti-store"; 15 | 16 | interface ActionsProps { 17 | disabled: boolean; 18 | courseId: string; 19 | isPublished: boolean; 20 | } 21 | const Actions = ({ 22 | disabled, 23 | courseId, 24 | isPublished, 25 | }: ActionsProps) => { 26 | 27 | const router = useRouter(); 28 | const confetti = useConfettiStore(); 29 | const [isLoading, setIsLoading] = useState(false); 30 | 31 | 32 | const onClick = async () => { 33 | try { 34 | setIsLoading(true); 35 | if (isPublished) { 36 | await axios.patch( 37 | `/api/courses/${courseId}/unpublish` 38 | ); 39 | toast.success("Course unpublished"); 40 | } else { 41 | await axios.patch( 42 | `/api/courses/${courseId}/publish` 43 | ); 44 | toast.success("Course published"); 45 | confetti.onOpen() 46 | } 47 | router.refresh(); 48 | } catch (error) { 49 | toast.error("Something went wrong"); 50 | } finally { 51 | setIsLoading(false); 52 | } 53 | }; 54 | const onDelete = async () => { 55 | try { 56 | setIsLoading(true); 57 | await axios.delete(`/api/courses/${courseId}`); 58 | toast.success("Course deleted"); 59 | router.refresh(); 60 | router.push(`/teacher/courses/`); 61 | } catch (error) { 62 | toast.error("Something went wrong"); 63 | } finally { 64 | setIsLoading(false); 65 | } 66 | }; 67 | return ( 68 |
69 | 76 | 77 | 80 | 81 |
82 | ); 83 | }; 84 | 85 | export default Actions; 86 | -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/teacher/courses/[courseId]/_components/AttachmentForm.tsx: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | "use client"; 4 | import { useState } from "react"; 5 | import { useRouter } from "next/navigation"; 6 | import * as z from "zod"; 7 | import axios from "axios"; 8 | import toast from "react-hot-toast"; 9 | import { File, ImageIcon, Loader2, Pencil, PlusCircle, X } from "lucide-react"; 10 | 11 | import { Button } from "@/components/ui/button"; 12 | import { attachment, Course } from "@prisma/client"; 13 | import FileUpload from "@/components/FileUpload"; 14 | 15 | interface attachmentFormProps { 16 | initialData: Course & { attachments: attachment[] }; 17 | courseId: string; 18 | } 19 | 20 | const formSchema = z.object({ 21 | url: z.string().min(1), 22 | }); 23 | const AttachmentForm: React.FC = ({ 24 | initialData, 25 | courseId, 26 | }) => { 27 | const [isEditing, setIsEditing] = useState(false); 28 | const [deletingId, setDeletingId] = useState(null); 29 | 30 | const router = useRouter(); 31 | const toggleEdit = () => setIsEditing((current) => !current); 32 | 33 | const onSubmit = async (values: z.infer) => { 34 | try { 35 | await axios.post(`/api/courses/${courseId}/attachments`, values); 36 | toast.success("Course updated"); 37 | toggleEdit(); 38 | router.refresh(); 39 | } catch (error) { 40 | toast.error("Something went wrong!"); 41 | } 42 | }; 43 | 44 | const onDelete = async (id: string) => { 45 | try { 46 | setDeletingId(id); 47 | await axios.delete(`/api/courses/${courseId}/attachments/${id}`); 48 | toast.success("Attachment Deleted "); 49 | router.refresh(); 50 | } catch (error) { 51 | toast.error("Something went wrong!"); 52 | } finally { 53 | setDeletingId(null); 54 | } 55 | }; 56 | 57 | return ( 58 |
59 |
60 | Course attachments 61 | 70 |
71 | {!isEditing && ( 72 | <> 73 | {initialData.attachments.length === 0 && ( 74 |

75 | No attachments yet 76 |

77 | )} 78 | {initialData.attachments.length > 0 && ( 79 |
80 | {initialData.attachments.map((attachment) => ( 81 |
84 | 85 |

{attachment.name}

86 | {deletingId === attachment.id && ( 87 |
88 | 89 |
90 | )} 91 | {deletingId !== attachment.id && ( 92 | 97 | )} 98 |
99 | ))} 100 |
101 | )} 102 | 103 | )} 104 | 105 | {isEditing && ( 106 |
107 | { 110 | if (url) { 111 | onSubmit({ url: url }); 112 | } 113 | }} 114 | /> 115 |
116 | Add anything your students might need to complete the course 117 |
118 |
119 | )} 120 |
121 | ); 122 | }; 123 | 124 | export default AttachmentForm; 125 | -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/teacher/courses/[courseId]/_components/CategoryForm.tsx: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | "use client"; 4 | import { useState } from "react"; 5 | import { useForm } from "react-hook-form"; 6 | import { useRouter } from "next/navigation"; 7 | import * as z from "zod"; 8 | import axios from "axios"; 9 | import { zodResolver } from "@hookform/resolvers/zod"; 10 | import toast from "react-hot-toast"; 11 | import { Pencil } from "lucide-react"; 12 | 13 | import { 14 | Form, 15 | FormControl, 16 | FormField, 17 | FormItem, 18 | FormMessage, 19 | } from "@/components/ui/form"; 20 | import { Input } from "@/components/ui/input"; 21 | import { Button } from "@/components/ui/button"; 22 | import { cn } from "@/lib/utils"; 23 | import { Textarea } from "@/components/ui/textarea"; 24 | import { Course } from "@prisma/client"; 25 | import { Combobox } from "@/components/ui/combobox"; 26 | 27 | interface CategoryFormProps { 28 | initialData: Course; 29 | courseId: string; 30 | options: {label:string, value:string}[]; 31 | } 32 | 33 | const formSchema = z.object({ 34 | categoryId: z.string().min(1, { 35 | message: "Category is required", 36 | }), 37 | }); 38 | const CategoryForm: React.FC = ({ initialData, courseId,options }) => { 39 | const [isEditing, setIsEditing] = useState(false); 40 | const router = useRouter(); 41 | const toggleEdit = () => setIsEditing((current) => !current); 42 | 43 | const form = useForm>({ 44 | resolver: zodResolver(formSchema), 45 | defaultValues: {categoryId:initialData?.categoryId || " "}, 46 | }); 47 | 48 | const { isSubmitting, isValid } = form.formState; 49 | const onSubmit = async (values: z.infer) => { 50 | // console.log(values); 51 | 52 | try { 53 | await axios.patch(`/api/courses/${courseId}`, values); 54 | toast.success("Course updated"); 55 | toggleEdit(); 56 | router.refresh(); 57 | } catch (error) { 58 | toast.error("Something went wrong!"); 59 | } 60 | }; 61 | 62 | 63 | const selectedOption = options.find((option)=>option.value === initialData.categoryId) 64 | // console.log(selectedOption?.label); 65 | 66 | return ( 67 |
68 |
69 | Course category 70 | 81 |
82 | {!isEditing &&

{selectedOption?.label || "No Category"}

} 83 | {isEditing && ( 84 |
85 | 88 | ( 92 | 93 | 94 | {/* */} 98 | 99 | 100 | 101 | 102 | 103 | )} 104 | /> 105 |
106 | 109 |
110 | 111 | 112 | )} 113 |
114 | ); 115 | }; 116 | 117 | export default CategoryForm; 118 | -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/teacher/courses/[courseId]/_components/ChapterForm.tsx: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | "use client"; 4 | import { useState } from "react"; 5 | import { useForm } from "react-hook-form"; 6 | import { useRouter } from "next/navigation"; 7 | import * as z from "zod"; 8 | import axios from "axios"; 9 | import { zodResolver } from "@hookform/resolvers/zod"; 10 | import toast from "react-hot-toast"; 11 | import { Loader2, Pencil, PlusCircle } from "lucide-react"; 12 | 13 | import { 14 | Form, 15 | FormControl, 16 | FormField, 17 | FormItem, 18 | FormMessage, 19 | } from "@/components/ui/form"; 20 | import { Input } from "@/components/ui/input"; 21 | import { Button } from "@/components/ui/button"; 22 | import { cn } from "@/lib/utils"; 23 | import { Textarea } from "@/components/ui/textarea"; 24 | import { Chapter, Course } from "@prisma/client"; 25 | import ChaptersList from "./ChaptersList"; 26 | 27 | interface ChapterFormProps { 28 | initialData: Course & { chapters: Chapter[] }; 29 | courseId: string; 30 | } 31 | 32 | const formSchema = z.object({ 33 | title: z.string().min(1), 34 | }); 35 | const ChapterForm: React.FC = ({ initialData, courseId }) => { 36 | const [isCreating, setIsCreating] = useState(false); 37 | const [isUpdating, setIsUpdating] = useState(false); 38 | const toggleCreating = () => setIsCreating((current) => !current); 39 | 40 | const router = useRouter(); 41 | 42 | const form = useForm>({ 43 | resolver: zodResolver(formSchema), 44 | defaultValues: { title: "" }, 45 | }); 46 | 47 | const { isSubmitting, isValid } = form.formState; 48 | const onSubmit = async (values: z.infer) => { 49 | try { 50 | await axios.post(`/api/courses/${courseId}/chapters`, values); 51 | toast.success("Chapter created"); 52 | toggleCreating(); 53 | router.refresh(); 54 | } catch (error) { 55 | toast.error("Something went wrong!"); 56 | } 57 | }; 58 | const onReorder = async (updatedData: { id: string; position: number }[]) => { 59 | try { 60 | setIsUpdating(true); 61 | await axios.put(`/api/courses/${courseId}/chapters/reorder`,{ 62 | list:updatedData 63 | }); 64 | toast.success("Chapters reordered successfully!"); 65 | router.refresh() 66 | } catch (error) { 67 | toast.error("Something went wrong!"); 68 | } finally { 69 | setIsUpdating(false); 70 | } 71 | }; 72 | const onEdit = (id:string)=>{ 73 | router.push(`/teacher/courses/${courseId}/chapters/${id}`) 74 | } 75 | 76 | return ( 77 |
78 | {isUpdating && ( 79 |
80 | 81 |
82 | )} 83 |
84 | Course chapters 85 | 95 |
96 | {isCreating && ( 97 |
98 | 101 | ( 105 | 106 | 107 | 112 | 113 | 114 | 115 | )} 116 | /> 117 | 118 | 121 | 122 | 123 | )} 124 | {!isCreating && ( 125 |
130 | {!initialData.chapters.length && "No chapters"} 131 | 136 |
137 | )} 138 | {!isCreating && ( 139 |

140 | Drag and drop to reorder the chapters 141 |

142 | )} 143 |
144 | ); 145 | }; 146 | 147 | export default ChapterForm; 148 | -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/teacher/courses/[courseId]/_components/ChaptersList.tsx: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | "use client"; 4 | import { Chapter } from "@prisma/client"; 5 | import React, { useEffect, useState } from "react"; 6 | 7 | import { 8 | DragDropContext, 9 | Droppable, 10 | Draggable, 11 | DropResult, 12 | } from "@hello-pangea/dnd"; 13 | import { cn } from "@/lib/utils"; 14 | import { Grid, Grip, Pencil } from "lucide-react"; 15 | import { Badge } from "@/components/ui/badge"; 16 | 17 | interface ChaptersListProps { 18 | items: Chapter[]; 19 | onReorder: (updateData: { id: string; position: number }[]) => void; 20 | onEdit: (id: string) => void; 21 | } 22 | const ChaptersList = ({ items, onReorder, onEdit }: ChaptersListProps) => { 23 | const [isMounted, setIsMounted] = useState(false); 24 | const [chapters, setChapters] = useState(items); 25 | 26 | useEffect(() => { 27 | setIsMounted(true); 28 | }, []); 29 | 30 | useEffect(() => { 31 | setChapters(items); 32 | }, [items]); 33 | 34 | const onDragEnd = (result: DropResult) => { 35 | if (!result.destination) return; 36 | 37 | const items = Array.from(chapters); 38 | const [reorderItem] = items.splice(result.source.index, 1); 39 | items.splice(result.destination.index, 0, reorderItem); 40 | 41 | const startIndex = Math.min(result.source.index, result.destination.index); 42 | const endIndex = Math.min(result.source.index, result.destination.index); 43 | 44 | const updatedChapters = items.slice(startIndex, endIndex + 1); 45 | 46 | setChapters(items); 47 | 48 | const bulkUpdatedData = updatedChapters.map((chapter) => ({ 49 | id: chapter.id, 50 | position: items.findIndex((item) => item.id === chapter.id), 51 | })); 52 | 53 | onReorder(bulkUpdatedData); 54 | }; 55 | 56 | if (!isMounted) { 57 | return null; 58 | } 59 | return ( 60 | 61 | 62 | {(provided) => ( 63 |
64 | {chapters.map((chapter, index) => ( 65 | 69 | {(provided) => ( 70 |
78 |
85 | 86 |
87 | {chapter.title} 88 |
89 | {chapter.isFree && Free} 90 | 95 | {chapter.isPublished ? "Published" : "Draft"} 96 | 97 | onEdit(chapter.id)} 99 | className='w-4 h-4 cursor-pointer hover:opacity-75 transition' 100 | /> 101 |
102 |
103 | )} 104 |
105 | ))} 106 | {provided.placeholder} 107 |
108 | )} 109 |
110 |
111 | ); 112 | }; 113 | 114 | export default ChaptersList; 115 | -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/teacher/courses/[courseId]/_components/DescriptionForm.tsx: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | "use client"; 4 | import { useState } from "react"; 5 | import { useForm } from "react-hook-form"; 6 | import { useRouter } from "next/navigation"; 7 | import * as z from "zod"; 8 | import axios from "axios"; 9 | import { zodResolver } from "@hookform/resolvers/zod"; 10 | import toast from "react-hot-toast"; 11 | import { Pencil } from "lucide-react"; 12 | 13 | import { 14 | Form, 15 | FormControl, 16 | FormField, 17 | FormItem, 18 | FormMessage, 19 | } from "@/components/ui/form"; 20 | import { Input } from "@/components/ui/input"; 21 | import { Button } from "@/components/ui/button"; 22 | import { cn } from "@/lib/utils"; 23 | import { Textarea } from "@/components/ui/textarea"; 24 | import { Course } from "@prisma/client"; 25 | 26 | interface DescriptionFormProps { 27 | initialData: Course; 28 | courseId: string; 29 | } 30 | 31 | const formSchema = z.object({ 32 | description: z.string().min(1, { 33 | message: "Description is required", 34 | }), 35 | }); 36 | const DescriptionForm: React.FC = ({ initialData, courseId }) => { 37 | const [isEditing, setIsEditing] = useState(false); 38 | const router = useRouter(); 39 | const toggleEdit = () => setIsEditing((current) => !current); 40 | 41 | const form = useForm>({ 42 | resolver: zodResolver(formSchema), 43 | defaultValues: {description:initialData?.description || " "}, 44 | }); 45 | 46 | const { isSubmitting, isValid } = form.formState; 47 | const onSubmit = async (values: z.infer) => { 48 | try { 49 | await axios.patch(`/api/courses/${courseId}`, values); 50 | toast.success("Course updated"); 51 | toggleEdit(); 52 | router.refresh(); 53 | } catch (error) { 54 | toast.error("Something went wrong!"); 55 | } 56 | }; 57 | 58 | return ( 59 |
60 |
61 | Course Description 62 | 73 |
74 | {!isEditing &&

{initialData.description || "No description"}

} 75 | {isEditing && ( 76 |
77 | 80 | ( 84 | 85 | 86 |