├── .env.example ├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── README.md ├── actions ├── get-analytics.ts ├── get-chapter.ts ├── get-courses.ts ├── get-dashboard-courses.ts └── get-progress.ts ├── app ├── (auth) │ └── (routes) │ │ ├── layout.tsx │ │ ├── sign-in │ │ └── [[...sign-in]] │ │ │ └── page.tsx │ │ └── sign-up │ │ └── [[...sign-up]] │ │ └── page.tsx ├── (course) │ └── courses │ │ └── [courseId] │ │ ├── _components │ │ ├── course-mobile-sidebar.tsx │ │ ├── course-navbar.tsx │ │ ├── course-sidebar-item.tsx │ │ └── course-sidebar.tsx │ │ ├── chapters │ │ └── [chapterId] │ │ │ ├── _components │ │ │ ├── course-enroll-button.tsx │ │ │ ├── course-progress-button.tsx │ │ │ └── video-player.tsx │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ └── page.tsx ├── (dashboard) │ ├── (routes) │ │ ├── (root) │ │ │ ├── _components │ │ │ │ └── info-card.tsx │ │ │ └── page.tsx │ │ ├── search │ │ │ ├── _component │ │ │ │ ├── category-item.tsx │ │ │ │ └── category.tsx │ │ │ └── page.tsx │ │ └── teacher │ │ │ ├── analytics │ │ │ ├── _components │ │ │ │ ├── chart.tsx │ │ │ │ └── data-card.tsx │ │ │ └── page.tsx │ │ │ ├── courses │ │ │ ├── [courseId] │ │ │ │ ├── _components │ │ │ │ │ ├── actions.tsx │ │ │ │ │ ├── attachment-form.tsx │ │ │ │ │ ├── category-form.tsx │ │ │ │ │ ├── chapters-form.tsx │ │ │ │ │ ├── chapters-list.tsx │ │ │ │ │ ├── description-form.tsx │ │ │ │ │ ├── image-form.tsx │ │ │ │ │ ├── price-form.tsx │ │ │ │ │ └── title-form.tsx │ │ │ │ ├── chapters │ │ │ │ │ └── [chapterId] │ │ │ │ │ │ ├── _components │ │ │ │ │ │ ├── chapter-access-form.tsx │ │ │ │ │ │ ├── chapter-actions.tsx │ │ │ │ │ │ ├── chapter-description-form.tsx │ │ │ │ │ │ ├── chapter-title-form.tsx │ │ │ │ │ │ └── chapter-video-form.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ ├── _component │ │ │ │ ├── columns.tsx │ │ │ │ └── data-table.tsx │ │ │ └── page.tsx │ │ │ ├── create │ │ │ └── page.tsx │ │ │ └── layout.tsx │ ├── _components │ │ ├── logo.tsx │ │ ├── mobile-sidebar.tsx │ │ ├── navbar.tsx │ │ ├── sidebar-item.tsx │ │ ├── sidebar-routes.tsx │ │ └── sidebar.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 ├── course-card.tsx ├── course-list.tsx ├── course-progress.tsx ├── editor.tsx ├── file-upload.tsx ├── icon-badge.tsx ├── modals │ ├── confirm-modal.tsx │ └── index.ts ├── navbar-routes.tsx ├── preview.tsx ├── providers │ ├── confetti-provider.tsx │ ├── index.ts │ └── toaster-provider.tsx ├── search-input.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 ├── docker-compose.yml ├── hooks ├── use-confetti.ts └── use-debounce.ts ├── lib ├── db.ts ├── format.ts ├── stripe.ts ├── teacher.ts ├── uploadthing.ts └── utils.ts ├── middleware.ts ├── next.config.js ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── prisma ├── migrations │ ├── 20231211064950_create_models_for_courses │ │ └── migration.sql │ ├── 20231212093250_rename_attachement_to_attachment │ │ └── migration.sql │ ├── 20231212101804_add_model_for_chapter_mux_data_user_progress_purchase_and_stripe_customer │ │ └── migration.sql │ ├── 20231212121922_fix_typos │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── public ├── logo.svg ├── next.svg └── vercel.svg ├── scripts └── seed.ts ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # Clerk Configuration 2 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=your_clerk_publishable_key_here 3 | CLERK_SECRET_KEY=your_clerk_secret_key_here 4 | NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in 5 | NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up 6 | NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/ 7 | NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/ 8 | 9 | # Prisma & Database Configuration 10 | # Update the DATABASE_URL with your database credentials 11 | DATABASE_URL=postgresql://username:password@localhost:5432/your_database_name 12 | 13 | # Uploadthing Configuration 14 | UPLOADTHING_TOKEN='your_uploadthing_token_here' 15 | 16 | # Mux Configuration 17 | MUX_TOKEN_ID=your_mux_token_id 18 | MUX_TOKEN_SECRET=your_mux_token_secret 19 | 20 | # Stripe Configuration 21 | STRIPE_API_KEY=your_stripe_api_key 22 | STRIPE_WEBHOOK_SECRET=your_stripe_webhook_secret 23 | 24 | NEXT_PUBLIC_APP_URL=http://localhost:3000 25 | NEXT_PUBLIC_TEACHER_ID=your_teacher_id -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript"], 3 | "rules": { 4 | "react/self-closing-comp": [ 5 | "error", 6 | { 7 | "component": true, 8 | "html": true 9 | } 10 | ], 11 | "no-console": "error", 12 | "object-shorthand": "error" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.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 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | 39 | # docker compose 40 | db-data -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "printWidth": 120, 6 | "plugins": ["prettier-plugin-tailwindcss"] 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /actions/get-analytics.ts: -------------------------------------------------------------------------------- 1 | import { Prisma } from '@prisma/client' 2 | import { db } from '@/lib/db' 3 | 4 | type PurchaseWithCourse = Prisma.PurchaseGetPayload<{ include: { course: true } }> 5 | 6 | function groupByCourse(purchases: PurchaseWithCourse[]) { 7 | const grouped: { [courseTitle: string]: number } = {} 8 | 9 | purchases.forEach((purchase) => { 10 | const courseTitle = purchase.course.title 11 | if (!grouped[courseTitle]) { 12 | grouped[courseTitle] = 0 13 | } 14 | 15 | grouped[courseTitle] += purchase.course.price! 16 | }) 17 | 18 | return grouped 19 | } 20 | 21 | export async function getAnalytics(userId: string) { 22 | try { 23 | const purchases = await db.purchase.findMany({ 24 | where: { course: { createdById: userId } }, 25 | include: { course: true }, 26 | }) 27 | 28 | const groupedEarnings = groupByCourse(purchases) 29 | const data = Object.entries(groupedEarnings).map(([courseTitle, total]) => ({ 30 | name: courseTitle, 31 | total, 32 | })) 33 | 34 | const totalRevenue = data.reduce((acc, curr) => acc + curr.total, 0) 35 | const totalSales = purchases.length 36 | 37 | return { 38 | data, 39 | totalRevenue, 40 | totalSales, 41 | } 42 | } catch { 43 | return { 44 | data: [], 45 | totalRevenue: 0, 46 | totalSales: 0, 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /actions/get-chapter.ts: -------------------------------------------------------------------------------- 1 | import { Attachment, Chapter } from '@prisma/client' 2 | import { db } from '@/lib/db' 3 | 4 | type GetChapterArgs = { 5 | userId: string 6 | courseId: string 7 | chapterId: string 8 | } 9 | 10 | export async function getChapter({ userId, courseId, chapterId }: GetChapterArgs) { 11 | try { 12 | const purchase = await db.purchase.findUnique({ where: { userId_courseId: { userId, courseId } } }) 13 | const course = await db.course.findUnique({ where: { id: courseId, isPublished: true }, select: { price: true } }) 14 | const chapter = await db.chapter.findUnique({ where: { id: chapterId, isPublished: true } }) 15 | 16 | if (!chapter || !course) { 17 | throw new Error('Chapter or course not found!') 18 | } 19 | 20 | let muxData = null 21 | let attachments: Attachment[] = [] 22 | let nextChapter: Chapter | null = null 23 | 24 | if (purchase) { 25 | attachments = await db.attachment.findMany({ where: { courseId } }) 26 | } 27 | 28 | if (chapter.isFree || purchase) { 29 | muxData = await db.muxData.findUnique({ where: { chapterId } }) 30 | 31 | nextChapter = await db.chapter.findFirst({ 32 | where: { courseId, isPublished: true, position: { gt: chapter.position } }, 33 | orderBy: { position: 'asc' }, 34 | }) 35 | } 36 | 37 | const userProgress = await db.userProgress.findUnique({ where: { userId_chapterId: { userId, chapterId } } }) 38 | 39 | return { 40 | chapter, 41 | course, 42 | muxData, 43 | attachments, 44 | nextChapter, 45 | userProgress, 46 | purchase, 47 | } 48 | } catch { 49 | return { 50 | chapter: null, 51 | course: null, 52 | muxData: null, 53 | attachments: null, 54 | nextChapter: null, 55 | userProgress: null, 56 | purchase: null, 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /actions/get-courses.ts: -------------------------------------------------------------------------------- 1 | import { Category, Course } from '@prisma/client' 2 | import { db } from '@/lib/db' 3 | import { getProgress } from './get-progress' 4 | 5 | export type CourseWithProgressAndCategory = Course & { 6 | category: Category | null 7 | chapters: { id: string }[] 8 | progress: number | null 9 | } 10 | 11 | type GetCoursesArgs = { 12 | userId: string 13 | title?: string 14 | categoryId?: string 15 | } 16 | 17 | export async function getCourses({ 18 | userId, 19 | title, 20 | categoryId, 21 | }: GetCoursesArgs): Promise { 22 | try { 23 | const courses = await db.course.findMany({ 24 | where: { isPublished: true, title: { contains: title }, categoryId }, 25 | include: { 26 | category: true, 27 | chapters: { where: { isPublished: true }, select: { id: true } }, 28 | purchases: { where: { userId } }, 29 | }, 30 | orderBy: { 31 | createdAt: 'desc', 32 | }, 33 | }) 34 | 35 | const coursesWithProgress: CourseWithProgressAndCategory[] = await Promise.all( 36 | courses.map(async (course) => { 37 | if (course.purchases.length === 0) { 38 | return { ...course, progress: null } 39 | } 40 | 41 | const progressPercentage = await getProgress(userId, course.id) 42 | return { 43 | ...course, 44 | progress: progressPercentage, 45 | } 46 | }), 47 | ) 48 | 49 | return coursesWithProgress 50 | } catch { 51 | return [] 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /actions/get-dashboard-courses.ts: -------------------------------------------------------------------------------- 1 | import { Prisma } from '@prisma/client' 2 | import { db } from '@/lib/db' 3 | import { getProgress } from './get-progress' 4 | 5 | type CourseWithProgressAndCategory = Prisma.CourseGetPayload<{ include: { category: true; chapters: true } }> & { 6 | progress: number 7 | } 8 | 9 | type DashboardCourses = { 10 | completedCourses: CourseWithProgressAndCategory[] 11 | coursesInProgress: CourseWithProgressAndCategory[] 12 | } 13 | 14 | export async function getDashboardCourses(userId: string): Promise { 15 | try { 16 | const purchasedCourses = await db.purchase.findMany({ 17 | where: { userId }, 18 | select: { course: { include: { category: true, chapters: { where: { isPublished: true } } } } }, 19 | }) 20 | 21 | const courses = purchasedCourses.map((purchase) => purchase.course) as CourseWithProgressAndCategory[] 22 | 23 | for (const course of courses) { 24 | const progress = await getProgress(userId, course.id) 25 | course.progress = progress 26 | } 27 | 28 | const completedCourses = courses.filter((course) => course.progress === 100) 29 | const coursesInProgress = courses.filter((course) => (course?.progress ?? 0) < 100) 30 | 31 | return { 32 | completedCourses, 33 | coursesInProgress, 34 | } 35 | } catch { 36 | return { 37 | completedCourses: [], 38 | coursesInProgress: [], 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /actions/get-progress.ts: -------------------------------------------------------------------------------- 1 | import { db } from '@/lib/db' 2 | 3 | export async function getProgress(userId: string, courseId: string): Promise { 4 | try { 5 | const publishedChapters = await db.chapter.findMany({ 6 | where: { courseId, isPublished: true }, 7 | select: { id: true }, 8 | }) 9 | const publishedChapterIds = publishedChapters.map((chapter) => chapter.id) 10 | 11 | const validCompletedChapters = await db.userProgress.count({ 12 | where: { userId, chapterId: { in: publishedChapterIds }, isCompleted: true }, 13 | }) 14 | 15 | const progressPercentage = (validCompletedChapters / publishedChapterIds.length) * 100 16 | 17 | return progressPercentage 18 | } catch { 19 | return 0 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/(auth)/(routes)/layout.tsx: -------------------------------------------------------------------------------- 1 | const AuthLayout = ({ children }: { children: React.ReactNode }) => { 2 | return
{children}
3 | } 4 | export default AuthLayout 5 | -------------------------------------------------------------------------------- /app/(auth)/(routes)/sign-in/[[...sign-in]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignIn } from '@clerk/nextjs' 2 | 3 | export default function Page() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /app/(auth)/(routes)/sign-up/[[...sign-up]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignUp } from '@clerk/nextjs' 2 | 3 | export default function Page() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/_components/course-mobile-sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { Prisma } from '@prisma/client' 2 | import { MenuIcon } from 'lucide-react' 3 | import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet' 4 | import CourseSidebar from './course-sidebar' 5 | 6 | type CourseMobileSidebarProps = { 7 | course: Prisma.CourseGetPayload<{ include: { chapters: { include: { userProgress: true } } } }> 8 | progressCount: number 9 | } 10 | 11 | export default function CourseMobileSidebar({ course, progressCount }: CourseMobileSidebarProps) { 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/_components/course-navbar.tsx: -------------------------------------------------------------------------------- 1 | import { Prisma } from '@prisma/client' 2 | import CourseMobileSidebar from './course-mobile-sidebar' 3 | import { NavbarRoutes } from '@/components/navbar-routes' 4 | 5 | type CourseNavbarProps = { 6 | course: Prisma.CourseGetPayload<{ include: { chapters: { include: { userProgress: true } } } }> 7 | progressCount: number 8 | } 9 | 10 | export default function CourseNavbar({ course, progressCount }: CourseNavbarProps) { 11 | return ( 12 |
13 | 14 | 15 |
16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/_components/course-sidebar-item.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { CheckCircleIcon, LockIcon, PlayCircleIcon } from 'lucide-react' 4 | import { usePathname, useRouter } from 'next/navigation' 5 | import { cn } from '@/lib/utils' 6 | 7 | type CourseSidebarItemProps = { 8 | id: string 9 | label: string 10 | isCompleted: boolean 11 | courseId: string 12 | isLocked: boolean 13 | } 14 | 15 | export default function CourseSidebarItem({ id, label, isCompleted, courseId, isLocked }: CourseSidebarItemProps) { 16 | const pathname = usePathname() 17 | const router = useRouter() 18 | 19 | const Icon = isLocked ? LockIcon : isCompleted ? CheckCircleIcon : PlayCircleIcon 20 | 21 | const isActive = pathname?.includes(id) 22 | 23 | const onClick = () => { 24 | router.push(`/courses/${courseId}/chapters/${id}`) 25 | } 26 | 27 | return ( 28 | 55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/_components/course-sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { Prisma } from '@prisma/client' 2 | import { redirect } from 'next/navigation' 3 | import { auth } from '@clerk/nextjs/server' 4 | import { db } from '@/lib/db' 5 | import CourseSidebarItem from './course-sidebar-item' 6 | import { CourseProgress } from '@/components/course-progress' 7 | 8 | type CourseSidebarProps = { 9 | course: Prisma.CourseGetPayload<{ include: { chapters: { include: { userProgress: true } } } }> 10 | progressCount: number 11 | } 12 | 13 | export default async function CourseSidebar({ course, progressCount }: CourseSidebarProps) { 14 | const { userId } = await auth() 15 | 16 | if (!userId) { 17 | return redirect('/') 18 | } 19 | 20 | const purchase = await db.purchase.findUnique({ 21 | where: { 22 | userId_courseId: { 23 | userId, 24 | courseId: course.id, 25 | }, 26 | }, 27 | }) 28 | 29 | return ( 30 |
31 |
32 |

{course.title}

33 | {purchase ? ( 34 |
35 | 36 |
37 | ) : null} 38 |
39 |
40 | {course.chapters.map((chapter) => ( 41 | 49 | ))} 50 |
51 |
52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/chapters/[chapterId]/_components/course-enroll-button.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState } from 'react' 4 | import toast from 'react-hot-toast' 5 | import axios from 'axios' 6 | import { Button } from '@/components/ui/button' 7 | import { formatPrice } from '@/lib/format' 8 | 9 | type CourseEnrollButtonProps = { 10 | price: number 11 | courseId: string 12 | } 13 | 14 | export default function CourseEnrollButton({ price, courseId }: CourseEnrollButtonProps) { 15 | const [isLoading, setIsLoading] = useState(false) 16 | 17 | const onClick = async () => { 18 | try { 19 | setIsLoading(true) 20 | const response = await axios.post(`/api/courses/${courseId}/checkout`) 21 | window.location.assign(response.data.url) 22 | } catch { 23 | toast.error('Something went wrong!') 24 | } finally { 25 | setIsLoading(false) 26 | } 27 | } 28 | 29 | return ( 30 | 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/chapters/[chapterId]/_components/course-progress-button.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' 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 | } 69 | -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/chapters/[chapterId]/_components/video-player.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' 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 |

This chapter is locked

70 |
71 | )} 72 | {!isLocked && ( 73 | setIsReady(true)} 77 | onEnded={onEnd} 78 | autoPlay 79 | playbackId={playbackId} 80 | /> 81 | )} 82 |
83 | ) 84 | } 85 | -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/chapters/[chapterId]/page.tsx: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { redirect } from 'next/navigation' 4 | import { auth } from '@clerk/nextjs/server' 5 | import { Banner } from '@/components/banner' 6 | import { Preview } from '@/components/preview' 7 | import { VideoPlayer } from './_components/video-player' 8 | import { getChapter } from '@/actions/get-chapter' 9 | import CourseEnrollButton from './_components/course-enroll-button' 10 | import { Separator } from '@/components/ui/separator' 11 | import { CourseProgressButton } from './_components/course-progress-button' 12 | 13 | type Params = Promise<{ 14 | courseId: string 15 | chapterId: string 16 | }> 17 | 18 | type ChapterDetailsProps = { 19 | params: Params 20 | } 21 | 22 | export default async function ChapterDetails({ params }: ChapterDetailsProps) { 23 | const resolvedParams = await params 24 | const { userId } = await auth() 25 | if (!userId) { 26 | return redirect('/') 27 | } 28 | 29 | const { chapter, course, muxData, attachments, nextChapter, userProgress, purchase } = await getChapter({ 30 | userId, 31 | ...resolvedParams, 32 | }) 33 | 34 | if (!chapter || !course) { 35 | return redirect('/') 36 | } 37 | 38 | const isLocked = !chapter.isFree && !purchase 39 | const completedOnEnd = !!purchase && !userProgress?.isCompleted 40 | 41 | return ( 42 |
43 | {userProgress?.isCompleted ? : null} 44 | {isLocked ? : null} 45 | 46 |
47 |
48 | 57 |
58 | 59 |
60 |
61 |

{chapter.title}

62 | {purchase ? ( 63 | 69 | ) : ( 70 | 71 | )} 72 |
73 | 74 | 75 | 76 |
77 | 78 |
79 | 80 | {attachments.length ? ( 81 | <> 82 | 83 |
84 | {attachments.map((attachment) => ( 85 | 92 | {attachment.name} 93 | 94 | ))} 95 |
96 | 97 | ) : null} 98 |
99 |
100 |
101 | ) 102 | } 103 | -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation' 2 | import { auth } from '@clerk/nextjs/server' 3 | import { db } from '@/lib/db' 4 | import CourseNavbar from './_components/course-navbar' 5 | import CourseSidebar from './_components/course-sidebar' 6 | import { getProgress } from '@/actions/get-progress' 7 | 8 | type CourseLayoutProps = { 9 | children: React.ReactNode 10 | params: Promise<{ courseId: string }> 11 | } 12 | 13 | export default async function CourseLayout({ children, params }: CourseLayoutProps) { 14 | const { userId } = await auth() 15 | if (!userId) { 16 | return redirect('/') 17 | } 18 | const resolvedParams = await params 19 | 20 | const course = await db.course.findUnique({ 21 | where: { id: await resolvedParams.courseId }, 22 | include: { 23 | chapters: { 24 | where: { isPublished: true }, 25 | include: { userProgress: { where: { userId } } }, 26 | orderBy: { position: 'asc' }, 27 | }, 28 | }, 29 | }) 30 | 31 | if (!course) { 32 | return redirect('/') 33 | } 34 | 35 | const progressCount = await getProgress(userId, course.id) 36 | 37 | return ( 38 |
39 |
40 | 41 |
42 | 43 |
44 | 45 |
46 | 47 |
{children}
48 |
49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation' 2 | import { db } from '@/lib/db' 3 | 4 | const CourseIdPage = async ({ params }: { params: Promise<{ courseId: string }> }) => { 5 | const { courseId } = await params 6 | const course = await db.course.findUnique({ 7 | where: { 8 | id: courseId, 9 | }, 10 | include: { 11 | chapters: { 12 | where: { 13 | isPublished: true, 14 | }, 15 | orderBy: { 16 | position: 'asc', 17 | }, 18 | }, 19 | }, 20 | }) 21 | 22 | if (!course) { 23 | return redirect('/') 24 | } 25 | 26 | return redirect(`/courses/${course.id}/chapters/${course.chapters[0].id}`) 27 | } 28 | 29 | export default CourseIdPage 30 | -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/(root)/_components/info-card.tsx: -------------------------------------------------------------------------------- 1 | import { LucideIcon } from 'lucide-react' 2 | 3 | import { IconBadge } from '@/components/icon-badge' 4 | 5 | interface InfoCardProps { 6 | numberOfItems: number 7 | variant?: 'default' | 'success' 8 | label: string 9 | icon: LucideIcon 10 | } 11 | 12 | export const InfoCard = ({ variant, icon: Icon, numberOfItems, label }: InfoCardProps) => { 13 | return ( 14 |
15 | 16 |
17 |

{label}

18 |

19 | {numberOfItems} {numberOfItems === 1 ? 'Course' : 'Courses'} 20 |

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

No attachments yet

74 | )} 75 | {initialData.attachments.length > 0 && ( 76 |
77 | {initialData.attachments.map((attachment) => ( 78 |
82 | 83 |

{attachment.name}

84 | {deletingId === attachment.id && ( 85 |
86 | 87 |
88 | )} 89 | {deletingId !== attachment.id && ( 90 | 93 | )} 94 |
95 | ))} 96 |
97 | )} 98 | 99 | )} 100 | {isEditing && ( 101 |
102 | { 105 | if (url) { 106 | onSubmit({ url }) 107 | } 108 | }} 109 | /> 110 |
111 | Add anything your students might need to complete the course. 112 |
113 |
114 | )} 115 |
116 | ) 117 | } 118 | -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/teacher/courses/[courseId]/_components/category-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { zodResolver } from '@hookform/resolvers/zod' 4 | import { Course } from '@prisma/client' 5 | import { PencilIcon } from 'lucide-react' 6 | import { useForm } from 'react-hook-form' 7 | import { z } from 'zod' 8 | import { useState } from 'react' 9 | import toast from 'react-hot-toast' 10 | import axios from 'axios' 11 | import { useRouter } from 'next/navigation' 12 | import { Button } from '@/components/ui/button' 13 | import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form' 14 | import { cn } from '@/lib/utils' 15 | import { Combobox } from '@/components/ui/combobox' 16 | 17 | type CategoryFormProps = { 18 | initialData: Course 19 | courseId: string 20 | options: Array<{ label: string; value: string }> 21 | } 22 | 23 | const formSchema = z.object({ 24 | categoryId: z.string().min(1), 25 | }) 26 | 27 | type FormSchema = z.infer 28 | 29 | export default function CategoryForm({ initialData, courseId, options }: CategoryFormProps) { 30 | const [isEditing, setIsEditing] = useState(false) 31 | const router = useRouter() 32 | 33 | const toggleEdit = () => setIsEditing((current) => !current) 34 | 35 | const form = useForm({ 36 | resolver: zodResolver(formSchema), 37 | defaultValues: { categoryId: initialData.categoryId ?? '' }, 38 | }) 39 | 40 | const { isSubmitting, isValid } = form.formState 41 | 42 | const onSubmit = async (values: FormSchema) => { 43 | try { 44 | await axios.patch(`/api/courses/${courseId}`, values) 45 | toast.success('Course updated!') 46 | toggleEdit() 47 | router.refresh() 48 | } catch { 49 | toast.error('Something went wrong!') 50 | } 51 | } 52 | 53 | const selectedOption = options.find((option) => option.value === initialData?.categoryId) 54 | 55 | return ( 56 |
57 |
58 | Course Category 59 | 69 |
70 | 71 | {!isEditing ? ( 72 |

73 | {selectedOption?.label ?? 'No Category'} 74 |

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

Drag and drop to reorder the chapters

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

72 | {initialData.description || 'No description'} 73 |

74 | )} 75 | {isEditing && ( 76 |
77 | 78 | ( 82 | 83 | 84 |