├── .env.example ├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README.md ├── actions ├── get-all-users.tsx ├── get-analytic.ts ├── get-categories.ts ├── get-chapter.ts ├── get-comment.tsx ├── get-course-with-progress.ts ├── get-courses-created.ts ├── get-courses.ts ├── get-creating-course.ts ├── get-current-chapter.ts ├── get-current-course.ts ├── get-dashboard-courses.ts ├── get-enroll.ts ├── get-overview-course.ts ├── get-progress.tsx ├── get-redirect-course.ts └── get-waitlist.ts ├── app ├── (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 │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ ├── forum │ │ ├── _components │ │ │ ├── _contexts │ │ │ │ └── forum-context.tsx │ │ │ ├── comment-card.tsx │ │ │ ├── comment-form.tsx │ │ │ ├── comment-list.tsx │ │ │ ├── forum.tsx │ │ │ ├── icon-btn.tsx │ │ │ └── section.html │ │ ├── layout.tsx │ │ └── page.tsx │ │ └── page.tsx ├── (dashboard) │ ├── (routes) │ │ ├── (root) │ │ │ ├── _components │ │ │ │ └── info-card.tsx │ │ │ └── page.tsx │ │ ├── admin │ │ │ ├── layout.tsx │ │ │ └── users │ │ │ │ ├── _components │ │ │ │ ├── columns.tsx │ │ │ │ └── data-table.tsx │ │ │ │ └── page.tsx │ │ ├── search │ │ │ ├── _components │ │ │ │ ├── categories.tsx │ │ │ │ └── category-item.tsx │ │ │ ├── overview │ │ │ │ └── [courseId] │ │ │ │ │ ├── _components │ │ │ │ │ ├── coures-description.tsx │ │ │ │ │ ├── enrollment.tsx │ │ │ │ │ ├── forum-button.tsx │ │ │ │ │ ├── thumbnail.tsx │ │ │ │ │ └── view.tsx │ │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ └── teacher │ │ │ ├── analytics │ │ │ ├── _components │ │ │ │ ├── chart.tsx │ │ │ │ └── data-card.tsx │ │ │ └── page.tsx │ │ │ ├── courses │ │ │ ├── [courseId] │ │ │ │ ├── _components │ │ │ │ │ ├── actions.tsx │ │ │ │ │ ├── attachment-form.tsx │ │ │ │ │ ├── category-form.tsx │ │ │ │ │ ├── chapter-form.tsx │ │ │ │ │ ├── chapters-list.tsx │ │ │ │ │ ├── description-form.tsx │ │ │ │ │ ├── image-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 │ │ │ ├── _components │ │ │ │ ├── columns.tsx │ │ │ │ └── data-table.tsx │ │ │ └── page.tsx │ │ │ ├── create │ │ │ └── page.tsx │ │ │ ├── enrollment │ │ │ ├── _components │ │ │ │ ├── columns.tsx │ │ │ │ └── data-table.tsx │ │ │ └── page.tsx │ │ │ └── layout.tsx │ ├── _components │ │ ├── logo.tsx │ │ ├── mobile-sidebar.tsx │ │ ├── navbar.tsx │ │ ├── sidebar-item.tsx │ │ ├── sidebar-routes.tsx │ │ └── sidebar.tsx │ └── layout.tsx ├── api │ ├── auth │ │ ├── [...nextauth] │ │ │ └── route.ts │ │ └── register │ │ │ └── route.ts │ ├── courses │ │ ├── [courseId] │ │ │ ├── attachments │ │ │ │ ├── [attachmentId] │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ ├── categories │ │ │ │ └── route.ts │ │ │ ├── chapters │ │ │ │ ├── [chapterId] │ │ │ │ │ ├── progress │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── publish │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── route.ts │ │ │ │ │ └── unpublish │ │ │ │ │ │ └── route.ts │ │ │ │ ├── reorder │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ ├── comment │ │ │ │ ├── like │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ ├── enroll │ │ │ │ └── route.ts │ │ │ ├── publish │ │ │ │ └── route.ts │ │ │ ├── route.ts │ │ │ └── unpublish │ │ │ │ └── route.ts │ │ └── route.ts │ ├── enroll │ │ └── [enrollId] │ │ │ └── route.ts │ ├── uploadthing │ │ ├── core.ts │ │ └── route.ts │ └── users │ │ ├── change │ │ ├── image │ │ │ └── route.ts │ │ ├── password │ │ │ └── route.ts │ │ └── role │ │ │ └── route.ts │ │ └── route.ts ├── auth │ ├── _components │ │ ├── logo.tsx │ │ └── navbar.tsx │ ├── register │ │ ├── _components │ │ │ └── register-form.tsx │ │ └── page.tsx │ └── signin │ │ ├── _components │ │ └── form.tsx │ │ └── page.tsx ├── denied │ └── page.tsx ├── globals.css ├── layout.tsx └── profile │ ├── _components │ ├── navbar.tsx │ ├── profile.tsx │ └── profile_image.tsx │ ├── layout.tsx │ └── page.tsx ├── components.json ├── components ├── banner.tsx ├── course-card.tsx ├── course-progress.tsx ├── courses-list.tsx ├── dialog-password.tsx ├── editor.tsx ├── file-upload.tsx ├── icon-badge.tsx ├── modals │ └── confirm-modal.tsx ├── mode-toggle.tsx ├── navbar-routes.tsx ├── preview.tsx ├── providers │ ├── confetti-provider.tsx │ ├── session-provider.tsx │ ├── theme-provider.tsx │ └── toaster-provider.tsx ├── search-input.tsx ├── select.tsx ├── ui │ ├── alert-dialog.tsx │ ├── auto-complete.tsx │ ├── avatar.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 │ ├── select.tsx │ ├── separator.tsx │ ├── sheet.tsx │ ├── table.tsx │ ├── tag-input.tsx │ ├── tag-list.tsx │ ├── tag-popover.tsx │ ├── tag.tsx │ ├── textarea.tsx │ ├── toast.tsx │ ├── toaster.tsx │ └── use-toast.ts └── user-button.tsx ├── hooks ├── use-confetti-store.ts └── use-debounce.ts ├── lib ├── admin.ts ├── auth.ts ├── constant.ts ├── db.ts ├── teacher.ts ├── uploadthing.ts └── utils.ts ├── middleware.ts ├── next-auth.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── prisma ├── migrations │ └── migration_lock.toml └── schema.prisma ├── public ├── favicon.ico ├── fonts │ └── font.woff2 ├── google.svg ├── img │ ├── background.jpg │ ├── background_flip.png │ └── solid.jpg ├── logo.svg ├── next.svg └── vercel.svg ├── scripts └── seed.ts ├── tailwind.config.js └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL= 2 | 3 | UPLOADTHING_SECRET= 4 | UPLOADTHING_APP_ID= 5 | MUX_TOKEN_ID= 6 | MUX_TOKEN_SECRET= 7 | 8 | NEXTAUTH_URL="http://localhost:3000" 9 | NEXTAUTH_SECRET="" 10 | 11 | GITHUB_ID="" 12 | GITHUB_SECRET="" 13 | 14 | GOOGLE_ID="" 15 | GOOGLE_SECRET="" -------------------------------------------------------------------------------- /.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 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | .env 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | .nvmrc 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Hong Quan Tran 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CourseX: Nền tảng giáo dục của tương lai 2 | 3 | 4 | ## Cách chạy server trên platform 5 | ### Yêu cầu 6 | Bản build của server cùng NodeJs18 được tải sẵn lên platform, các tham số môi trường đã đặt sẵn trong `./CourseX/.env` 7 | ### Chạy server 8 | - Tại thư mục home trên platform của nhóm (VD:`jovyan@jupyter-fall2324w3g2:~$`), tiến hành bật tmux lên và chạy bash script `start.sh`, 9 | ```shell 10 | jovyan@jupyter-fall2324w3g2:~$ bash start.sh 11 | ``` 12 | Truy cập trang web http://fall2324w3g2.int3306.freeddns.org 13 | 14 | Link video demo: https://youtu.be/xGjlS_0WA1c 15 | 16 | - Chi tiết file `start.sh` 17 | - `cd CourseX`: Di chuyển vào folder CourseX 18 | - `/etc/jupyter/bin/expose 8000`: Mở cổng 8000 19 | - `export PATH=$HOME/node-v18.18.2-linux-x64/bin:$PATH`: Thêm PATH ENVIRONMENT của Node 18 20 | - `npm run start`: Chạy ứng dụng 21 | 22 | ## Cách cài đặt và chạy trên local 23 | ### Yêu cầu 24 | Phiên bản Node JS: `18.x.x` 25 | ### Cài đặt thư viện 26 | ```shell 27 | npm i 28 | ``` 29 | ### Cấu hình local enviroment 30 | Cấu hình local enviroment, gồm Database URL [Uploadthing API](https://uploadthing.com/), [Mux API](https://www.mux.com/), NextAuth Secret key, Github và Google OAuth API. Ví dụ file `env`: 31 | ```js 32 | DATABASE_URL= 33 | 34 | UPLOADTHING_SECRET= 35 | UPLOADTHING_APP_ID= 36 | 37 | MUX_TOKEN_ID= 38 | MUX_TOKEN_SECRET= 39 | 40 | NEXTAUTH_URL= 41 | NEXTAUTH_SECRET= 42 | 43 | GITHUB_ID= 44 | GITHUB_SECRET= 45 | 46 | GOOGLE_ID= 47 | GOOGLE_SECRET= 48 | ``` 49 | ### Cài đặt Prisma 50 | Sau khi thêm Database URL, cài đặt Prisma: 51 | ```shell 52 | npx prisma generate 53 | npx prisma db push 54 | ``` 55 | ### Chạy development server 56 | ```shell 57 | npm run dev 58 | ``` 59 | ### Build server 60 | Build server với lệnh: 61 | ```shell 62 | npm run build 63 | ``` 64 | Sau đó chạy lệnh sau để khởi động server: 65 | ```shell 66 | npm run start 67 | ``` 68 | 69 | -------------------------------------------------------------------------------- /actions/get-all-users.tsx: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | import { User } from "@prisma/client"; 3 | 4 | 5 | export const getAllUsers = async (): Promise => { 6 | try { 7 | const users = await db.user.findMany({ 8 | where: {}, 9 | distinct: ["id"], 10 | }); 11 | 12 | return users; 13 | } catch (error) { 14 | console.error("[GET_ALL_USERS]", error); 15 | return []; 16 | } 17 | } -------------------------------------------------------------------------------- /actions/get-analytic.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | import { Course, Enroll } from "@prisma/client"; 3 | 4 | type EnrollWithCourse = Enroll & { 5 | course: Course; 6 | }; 7 | 8 | const groupByCourse = (enrolls: EnrollWithCourse[]) => { 9 | const grouped: { [courseTitle: string]: number } = {}; 10 | 11 | enrolls.forEach((enroll) => { 12 | const courseTitle = enroll.course.title; 13 | if (!grouped[courseTitle]) { 14 | grouped[courseTitle] = 0; 15 | } 16 | grouped[courseTitle] += 1; // numbers of students in each course 17 | }) 18 | 19 | return grouped; 20 | } 21 | 22 | export const getAnalytics = async (userId: string) => { 23 | try { 24 | const enrolls = await db.enroll.findMany({ 25 | where: { 26 | isAccepted: true, 27 | course: { 28 | userId: userId, 29 | } 30 | }, 31 | include: { 32 | course: true, 33 | } 34 | }); 35 | 36 | const distinctEnrolls = await db.course.findMany({ 37 | where: { 38 | userId: userId, 39 | }, 40 | }) 41 | 42 | const groupedEarnings = groupByCourse(enrolls); 43 | const data = Object.entries(groupedEarnings).map(([courseTitle, total]) => ({ 44 | name: courseTitle, 45 | total: total, 46 | })); 47 | 48 | const totalCourses = distinctEnrolls.length; 49 | const totalEnrollment = enrolls.length; 50 | 51 | return { 52 | data, 53 | totalCourses, 54 | totalEnrollment, 55 | }; 56 | 57 | } catch (error) { 58 | console.log("[ANALYTIC]", error); 59 | return { 60 | data: [], 61 | totalCourses: 0, 62 | totalEnrollment: 0, 63 | } 64 | } 65 | } -------------------------------------------------------------------------------- /actions/get-categories.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | 3 | export default async function getAllCategories() { 4 | try { 5 | const categories = await db.category.findMany({ 6 | orderBy: { 7 | name: "asc", 8 | }, 9 | }); 10 | 11 | return categories; 12 | } catch (error) { 13 | console.error("[GET_CATEGORIES]", error); 14 | return []; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /actions/get-chapter.ts: -------------------------------------------------------------------------------- 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 enroll = await db.enroll.findUnique({ 17 | where: { 18 | userId_courseId: { 19 | userId, 20 | courseId, 21 | }, 22 | isAccepted: true, 23 | } 24 | }); 25 | 26 | const chapter = await db.chapter.findUnique({ 27 | where: { 28 | id: chapterId, 29 | isPublished: true, 30 | } 31 | }); 32 | 33 | if (!chapter) { 34 | throw new Error("Chapter not found"); 35 | } 36 | 37 | let muxData = null; 38 | let attachments: Attachment[] = []; 39 | let nextChapter: Chapter | null = null; 40 | 41 | if (enroll) { 42 | attachments = await db.attachment.findMany({ 43 | where: { 44 | courseId: courseId 45 | } 46 | }); 47 | } 48 | 49 | if (chapter.isFree || enroll) { 50 | muxData = await db.muxData.findUnique({ 51 | where: { 52 | chapterId: chapterId, 53 | } 54 | }); 55 | 56 | nextChapter = await db.chapter.findFirst({ 57 | where: { 58 | courseId: courseId, 59 | isPublished: true, 60 | position: { 61 | gt: chapter?.position, 62 | } 63 | }, 64 | orderBy: { 65 | position: "asc", 66 | } 67 | }); 68 | } 69 | 70 | const userProgress = await db.userProgress.findUnique({ 71 | where: { 72 | userId_chapterId: { 73 | userId, 74 | chapterId, 75 | } 76 | } 77 | }); 78 | 79 | return { 80 | chapter, 81 | muxData, 82 | attachments, 83 | nextChapter, 84 | userProgress, 85 | enroll, 86 | }; 87 | } catch (error) { 88 | console.log("[GET_CHAPTER]", error); 89 | return { 90 | chapter: null, 91 | muxData: null, 92 | attachments: [], 93 | nextChapter: null, 94 | userProgress: null, 95 | enroll: null, 96 | } 97 | } 98 | } -------------------------------------------------------------------------------- /actions/get-comment.tsx: -------------------------------------------------------------------------------- 1 | import { CommentItem } from "@/lib/constant"; 2 | import { db } from "@/lib/db"; 3 | import { Comment, Like } from "@prisma/client"; 4 | 5 | 6 | type GetComment = { 7 | courseId: string; 8 | }; 9 | 10 | export const getComment = async ({ 11 | courseId, 12 | }: GetComment): Promise => { 13 | try { 14 | const comments = await db.comment.findMany({ 15 | where: { 16 | courseId, 17 | }, 18 | include: { 19 | user: { 20 | select: { 21 | image: true, 22 | name: true, 23 | role: true, 24 | } 25 | }, 26 | course: { 27 | select: { 28 | userId: true, 29 | } 30 | }, 31 | likes: true, 32 | }, 33 | orderBy: { 34 | createdAt: "asc", 35 | }, 36 | }); 37 | 38 | const comment: CommentItem[] = comments.flatMap((comment) => 39 | ({ 40 | id: comment.id, 41 | userId: comment.userId, 42 | courseId: comment.courseId, 43 | parentId: comment.parentId || null, 44 | content: comment.content || "", 45 | createdAt: comment.createdAt, 46 | updatedAt: comment.updatedAt, 47 | isDeleted: comment.isDeleted, 48 | likes: comment.likes, 49 | userName: comment.user.name || null, 50 | userAvatar: comment.user.image || null, 51 | userRole: comment.user.role || null, 52 | courseOwner: comment.course.userId, 53 | })); 54 | 55 | return comment; 56 | } catch (error) { 57 | console.error("[GET_COMMENT]", error); 58 | return []; 59 | } 60 | }; 61 | -------------------------------------------------------------------------------- /actions/get-course-with-progress.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | 3 | export async function getCourseWithProgress(courseId: string, userId: string) { 4 | try { 5 | const course = await db.course.findUnique({ 6 | where: { 7 | id: courseId, 8 | }, 9 | include: { 10 | chapters: { 11 | where: { 12 | isPublished: true, 13 | }, 14 | include: { 15 | userProgress: { 16 | where: { 17 | userId, 18 | }, 19 | }, 20 | }, 21 | orderBy: { 22 | position: "asc", 23 | }, 24 | }, 25 | }, 26 | }); 27 | 28 | return course; 29 | 30 | } catch (error) { 31 | console.error("[GET_COURSE_WITH_PROGRESS]", error); 32 | 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /actions/get-courses-created.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | 3 | export default async function getCoursesCreated(userId: string) { 4 | try { 5 | const courses = await db.course.findMany({ 6 | where: { 7 | userId, 8 | }, 9 | orderBy: { 10 | createdAt: "desc", 11 | }, 12 | }); 13 | return courses; 14 | } catch (error) { 15 | console.error("[GET_COURSES_CREATED]", error); 16 | return []; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /actions/get-creating-course.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | 3 | export default async function getCreatingCourse( 4 | courseId: string, 5 | userId: string 6 | ) { 7 | try { 8 | const course = await db.course.findUnique({ 9 | where: { 10 | id: courseId, 11 | userId, 12 | }, 13 | include: { 14 | chapters: { 15 | orderBy: { 16 | position: "asc", 17 | }, 18 | }, 19 | attachments: { 20 | orderBy: { 21 | createdAt: "desc", 22 | }, 23 | }, 24 | categories: { 25 | include: { 26 | category: {}, 27 | }, 28 | }, 29 | }, 30 | }); 31 | 32 | return course; 33 | } catch (error) { 34 | console.error("[GET_CREATING_COURSE]", error); 35 | 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /actions/get-current-chapter.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | 3 | export async function getCurrentChapter( 4 | courseId: string, 5 | chapterId: string, 6 | ) { 7 | try { 8 | const chapter = await db.chapter.findUnique({ 9 | where: { 10 | id: chapterId, 11 | courseId: courseId, 12 | }, 13 | include: { 14 | muxData: true, 15 | } 16 | }); 17 | 18 | return chapter; 19 | } catch (error) { 20 | console.error("[GET_CURRENT_CHAPTER]", error); 21 | 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /actions/get-current-course.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | 3 | export async function getCurrentCourse(courseId: string) { 4 | try { 5 | const course = await db.course.findUnique({ 6 | where: { 7 | id: courseId, 8 | }, 9 | }); 10 | 11 | return course; 12 | 13 | } catch (error) { 14 | console.error("[GET_CURRENT_COURSE]", error); 15 | 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /actions/get-dashboard-courses.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | import { Category, Chapter, Course } from "@prisma/client"; 3 | import { getProgress } from "@/actions/get-progress"; 4 | 5 | type CourseWithProgressWithCategory = Course & { 6 | categories: Category[]; 7 | chapters: Chapter[]; 8 | progress: number | null; 9 | }; 10 | 11 | type DashboardCourses = { 12 | completedCourses: CourseWithProgressWithCategory[]; 13 | coursesInProgress: CourseWithProgressWithCategory[]; 14 | }; 15 | 16 | export const getDashboardCourses = async ( 17 | userId: string 18 | ): Promise => { 19 | try { 20 | const enrolledCourses = await db.enroll.findMany({ 21 | where: { 22 | userId: userId, 23 | isAccepted: true, 24 | }, 25 | include: { 26 | course: { 27 | include: { 28 | categories: { 29 | select: { 30 | category: true, 31 | }, 32 | }, 33 | chapters: { 34 | where: { 35 | isPublished: true, 36 | }, 37 | }, 38 | }, 39 | }, 40 | }, 41 | }); 42 | 43 | const courses: CourseWithProgressWithCategory[] = enrolledCourses.map( 44 | (enroll) => { 45 | const course = enroll.course; 46 | const categories = course.categories.map((category) => category.category); 47 | return { 48 | ...course, 49 | categories, 50 | progress: null, 51 | }; 52 | } 53 | ); 54 | 55 | for (let course of courses) { 56 | const progress = await getProgress(userId, course.id); 57 | course.progress = progress; 58 | } 59 | 60 | const completedCourses = courses.filter( 61 | (course) => course.progress === 100 62 | ); 63 | const coursesInProgress = courses.filter( 64 | (course) => (course.progress ?? 0) < 100 65 | ); 66 | 67 | return { 68 | completedCourses, 69 | coursesInProgress, 70 | }; 71 | } catch (error) { 72 | console.log("[GET_DASHBOARD_COURSES]", error); 73 | return { 74 | completedCourses: [], 75 | coursesInProgress: [], 76 | }; 77 | } 78 | }; 79 | -------------------------------------------------------------------------------- /actions/get-enroll.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | 3 | export const getEnroll = async (courseId: string, userId: string) => { 4 | try { 5 | const enroll = await db.enroll.findFirst({ 6 | where: { 7 | courseId: courseId, 8 | userId: userId, 9 | }, 10 | }); 11 | 12 | return enroll; 13 | } catch (error) { 14 | console.error("[GET_ENROLL]", error); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /actions/get-overview-course.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | import { getSession } from "next-auth/react"; 3 | import { redirect } from "next/navigation"; 4 | 5 | export default async function getOverviewCourse(courseId: string, userId: string) { 6 | try { 7 | 8 | const course = await db.course.findUnique({ 9 | where: { 10 | id: courseId, 11 | }, 12 | include: { 13 | chapters: { 14 | where: { 15 | isPublished: true, 16 | }, 17 | orderBy: { 18 | position: "asc", 19 | }, 20 | }, 21 | categories: { 22 | select: { 23 | category: true, 24 | }, 25 | }, 26 | enrolls: { 27 | where: { 28 | userId: userId, 29 | courseId: courseId, 30 | }, 31 | }, 32 | }, 33 | }); 34 | 35 | return course; 36 | } catch (error) { 37 | console.error("[GET_OVERVIEW_COURSE]", error); 38 | throw error; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /actions/get-progress.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 | } 38 | -------------------------------------------------------------------------------- /actions/get-redirect-course.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | 3 | export default async function getRedirectCourse(courseId: string) { 4 | try { 5 | const course = await db.course.findUnique({ 6 | where: { 7 | id: courseId, 8 | }, 9 | include: { 10 | chapters: { 11 | where: { 12 | isPublished: true, 13 | }, 14 | orderBy: { 15 | position: "asc", 16 | }, 17 | }, 18 | }, 19 | }); 20 | 21 | return course; 22 | } catch (error) { 23 | console.error("[GET_REDIRECT_COURSE]", error); 24 | throw error; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /actions/get-waitlist.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | 3 | type WaitlistItem = { 4 | id: string; 5 | userId: string; 6 | courseId: string; 7 | createdAt: Date; 8 | updatedAt: Date; 9 | isAccepted: boolean; 10 | userName: string; 11 | courseTitle: string; 12 | }; 13 | 14 | type GetWaitlist = { 15 | userId: string; 16 | }; 17 | 18 | export const getWaitlist = async ({ 19 | userId, 20 | }: GetWaitlist): Promise => { 21 | try { 22 | const courses = await db.course.findMany({ 23 | where: { 24 | userId, 25 | }, 26 | include: { 27 | enrolls: { 28 | include: { 29 | user: true, 30 | }, 31 | }, 32 | }, 33 | orderBy: { 34 | createdAt: "desc", 35 | }, 36 | }); 37 | 38 | const waitlist: WaitlistItem[] = courses.flatMap((course) => 39 | course.enrolls.map((enroll) => ({ 40 | id: enroll.id, 41 | userId: enroll.userId, 42 | courseId: enroll.courseId, 43 | userName: enroll.user?.name || "", 44 | courseTitle: course.title || "", 45 | createdAt: enroll.createdAt, 46 | updatedAt: enroll.updatedAt, 47 | isAccepted: enroll.isAccepted, 48 | })) 49 | ); 50 | 51 | return waitlist; 52 | } catch (error) { 53 | console.error("[GET_WAITLIST]", error); 54 | return []; 55 | } 56 | }; 57 | -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/_components/course-mobile-sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { Menu } from "lucide-react"; 2 | import { Chapter, Course, UserProgress } from "@prisma/client"; 3 | 4 | import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; 5 | 6 | import { CourseSidebar } from "./course-sidebar"; 7 | 8 | interface CourseMobileSidebarProps { 9 | course: Course & { 10 | chapters: (Chapter & { 11 | userProgress: UserProgress[] | null; 12 | })[]; 13 | }; 14 | progressCount: number; 15 | isForum: boolean; 16 | } 17 | 18 | export const CourseMobileSidebar = ({ 19 | course, 20 | progressCount, 21 | isForum, 22 | }: CourseMobileSidebarProps) => { 23 | return ( 24 | 25 | {isForum ? ( 26 | <> 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | ) : ( 35 | <> 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | )} 44 | 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/_components/course-navbar.tsx: -------------------------------------------------------------------------------- 1 | import { Chapter, Course, UserProgress } from "@prisma/client" 2 | 3 | import { NavbarRoutes } from "@/components/navbar-routes"; 4 | 5 | import { CourseMobileSidebar } from "./course-mobile-sidebar"; 6 | 7 | interface CourseNavbarProps { 8 | course: Course & { 9 | chapters: (Chapter & { 10 | userProgress: UserProgress[] | null; 11 | })[]; 12 | }; 13 | progressCount: number; 14 | isForum: boolean; 15 | }; 16 | 17 | export const CourseNavbar = ({ 18 | course, 19 | progressCount, 20 | isForum, 21 | }: CourseNavbarProps) => { 22 | return ( 23 |
24 | 29 | {isForum ? ( 30 |
31 | Discussion Forum 32 |
33 | ) : null} 34 | 35 |
36 | ) 37 | } -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/_components/course-sidebar-item.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import { CheckCircle, Lock, PlayCircle } from "lucide-react"; 5 | import { usePathname, useRouter } from "next/navigation"; 6 | 7 | interface CourseSidebarItemProps { 8 | label: string; 9 | id: string; 10 | isCompleted: boolean; 11 | courseId: string; 12 | isLocked: boolean; 13 | } 14 | 15 | export const CourseSidebarItem = ({ 16 | label, 17 | id, 18 | isCompleted, 19 | courseId, 20 | isLocked, 21 | }: CourseSidebarItemProps) => { 22 | const pathname = usePathname(); 23 | const router = useRouter(); 24 | 25 | const Icon = isLocked ? Lock : isCompleted ? CheckCircle : PlayCircle; 26 | const isActive = pathname?.includes(id); 27 | 28 | const onClick = () => { 29 | router.push(`/courses/${courseId}/chapters/${id}`); 30 | }; 31 | 32 | return ( 33 | 63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/_components/course-sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | import { Chapter, Course, UserProgress } from "@prisma/client"; 3 | import { redirect } from "next/navigation"; 4 | import { CourseSidebarItem } from "./course-sidebar-item"; 5 | import { CourseProgress } from "@/components/course-progress"; 6 | import { getSession } from "@/lib/auth"; 7 | import { getEnroll } from "@/actions/get-enroll"; 8 | 9 | interface CourseSidebarProps { 10 | course: Course & { 11 | chapters: (Chapter & { 12 | userProgress: UserProgress[] | null; 13 | })[]; 14 | }; 15 | progressCount: number; 16 | } 17 | 18 | export const CourseSidebar = async ({ 19 | course, 20 | progressCount, 21 | }: CourseSidebarProps) => { 22 | const session = await getSession(); 23 | if (!session) { 24 | return redirect("/auth/signin"); 25 | } 26 | 27 | const userId = session.user.uid; 28 | 29 | const enroll = await getEnroll(course.id, userId) 30 | 31 | return ( 32 |
33 |
34 |

{course.title}

35 | {enroll && 36 | (progressCount >= 0 ? ( 37 |
38 | 39 |
40 | ) : null)} 41 |
42 |
43 | {course.chapters.map((chapter) => ( 44 | 52 | ))} 53 |
54 |
55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/chapters/[chapterId]/_components/course-enroll-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import axios from "axios"; 4 | import { useState } from 'react'; 5 | import { Button } from "@/components/ui/button"; 6 | import toast from "react-hot-toast"; 7 | 8 | interface CourseEnrollButtonProps { 9 | courseId: string; 10 | } 11 | 12 | export const CourseEnrollButton = ({ 13 | courseId, 14 | }: CourseEnrollButtonProps) => { 15 | const [isEnrolled, setIsEnrolled] = useState(false); 16 | 17 | const onClick = async () => { 18 | try { 19 | const response = await axios.post(`/api/courses/${courseId}/enroll`); 20 | console.log("User enrolled successfully:", response.data); 21 | setIsEnrolled(true); 22 | toast.success("Course enrolled"); 23 | } catch { 24 | toast.error("Something went wrong"); 25 | } 26 | }; 27 | 28 | return ( 29 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /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-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/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-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]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | import { getProgress } from "@/actions/get-progress"; 4 | import { getSession } from "@/lib/auth"; 5 | import { CourseNavbar } from "../../_components/course-navbar"; 6 | import { CourseSidebar } from "../../_components/course-sidebar"; 7 | import { getCourseWithProgress } from "@/actions/get-course-with-progress"; 8 | 9 | const CourseLayout = async ({ 10 | children, 11 | params, 12 | }: { 13 | children: React.ReactNode; 14 | params: { courseId: string }; 15 | }) => { 16 | const session = await getSession(); 17 | 18 | if (!session) { 19 | return redirect("/auth/signin"); 20 | } 21 | 22 | const userId = session.user.uid; 23 | 24 | const course = await getCourseWithProgress(params.courseId, userId); 25 | 26 | if (!course) { 27 | return redirect("/"); 28 | } 29 | 30 | const progressCount = await getProgress(userId, course.id); 31 | 32 | return ( 33 |
34 |
35 | 40 |
41 |
42 | 43 |
44 |
{children}
45 |
46 | ); 47 | }; 48 | 49 | export default CourseLayout; 50 | -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/forum/_components/_contexts/forum-context.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { CommentItem } from "@/lib/constant"; 3 | import React, { useContext, useEffect, useMemo, useState } from "react"; 4 | 5 | export interface ForumContext { 6 | rootComments: CommentItem[]; 7 | getReplies: (parentId: string) => CommentItem[]; 8 | createLocalComment: (comment: CommentItem) => void; 9 | courseId: string; 10 | userId: string | undefined; 11 | } 12 | 13 | const initialContextValue: ForumContext = { 14 | rootComments: [], 15 | getReplies: (parentId: string) => ([]), 16 | createLocalComment: (comment: CommentItem) => ([]), 17 | courseId: '', 18 | userId: undefined, 19 | }; 20 | 21 | const Context = React.createContext(initialContextValue); 22 | 23 | 24 | export function useForum() { 25 | const context = useContext(Context); 26 | if (!context) { 27 | throw new Error("useForum must be used within a ForumProvider"); 28 | } 29 | return context; 30 | } 31 | 32 | export function ForumProvider({items, children, courseId, userId} : {items: CommentItem[],children: React.ReactNode, courseId: string, userId: string | undefined}) { 33 | const [comments, setComments] = useState([]); 34 | 35 | const commentsByParentId = useMemo<{ [key: string]: CommentItem[] }>(() => { 36 | const group: { [key: string]: CommentItem[] } = {}; 37 | items.forEach((item: CommentItem) => { 38 | const parentId = item.parentId ?? ''; // Use nullish coalescing operator 39 | group[parentId] = group[parentId] || []; 40 | group[parentId].push(item); 41 | }); 42 | 43 | return group; 44 | }, 45 | [items] 46 | ); 47 | 48 | useEffect(() => { 49 | if (items == null) return; 50 | setComments(items); 51 | }, [items]) 52 | 53 | function createLocalComment(comment: CommentItem) { 54 | setComments(prevComments => { 55 | return [comment, ...prevComments]; 56 | }) 57 | } 58 | 59 | function getReplies(parentId: string) { 60 | return commentsByParentId[parentId]; 61 | } 62 | return 69 | {children} 70 | 71 | } -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/forum/_components/comment-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRouter } from "next/navigation"; 4 | import React, { useState } from "react"; 5 | import { Input } from "@/components/ui/input"; 6 | import { useForum } from "./_contexts/forum-context"; 7 | import toast from "react-hot-toast"; 8 | interface FormData { 9 | comment: string; 10 | userId: string | undefined; 11 | parentId: string | null; 12 | } 13 | interface ParentIdProps { 14 | parentId: string | null; 15 | } 16 | const CommentForm = ({ parentId }: ParentIdProps) => { 17 | const router = useRouter(); 18 | const forumContext = useForum(); 19 | const [formData, setFormData] = useState({ 20 | comment: "", 21 | userId: forumContext.userId, 22 | parentId: parentId, 23 | }); 24 | 25 | const handleChange = (e: React.ChangeEvent): void => { 26 | const value = e.target.value; 27 | const name = e.target.name; 28 | setFormData((prevState) => ({ 29 | ...prevState, 30 | [name]: value, 31 | })); 32 | }; 33 | const headers = new Headers(); 34 | headers.append("Content-Type", "application/json"); 35 | const handleSubmit = async (e: React.FormEvent) => { 36 | e.preventDefault(); 37 | const res = await fetch(`/api/courses/${forumContext.courseId}/comment`, { 38 | method: "POST", 39 | body: JSON.stringify({ formData }), 40 | headers, 41 | }); 42 | if (!res.ok) { 43 | const response = await res.json(); 44 | toast.error(response.message); 45 | } else { 46 | toast.success("Commented"); 47 | router.refresh(); 48 | } 49 | }; 50 | 51 | return ( 52 | <> 53 |
54 |
55 | 58 |
59 | 70 | 76 |
77 |
78 |
79 | 80 | ); 81 | }; 82 | export default CommentForm; 83 | -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/forum/_components/comment-list.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { CommentCard } from "./comment-card"; 3 | import { CommentItem } from "@/lib/constant"; 4 | 5 | export interface CommentListProps { 6 | items: CommentItem[]; 7 | } 8 | 9 | export default function CommentList({ 10 | items, 11 | }: CommentListProps) { 12 | return ( 13 |
14 | {items.slice().reverse().map((item) => ( 15 |
16 | 18 | 19 |
20 | ))} 21 |
22 | ) 23 | } -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/forum/_components/forum.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { useForum } from "./_contexts/forum-context"; 3 | import CommentList from "./comment-list"; 4 | import CommentForm from "./comment-form"; 5 | 6 | export default function Forum() { 7 | const forumContext = useForum(); 8 | var rootComments = forumContext.rootComments; 9 | return ( 10 |
11 | 12 | {rootComments != null && rootComments.length > 0 && ( 13 | <> 14 | 15 | 16 | )} 17 |
18 | ) 19 | } -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/forum/_components/icon-btn.tsx: -------------------------------------------------------------------------------- 1 | import { Heart, LucideProps } from "lucide-react"; 2 | import React from "react"; 3 | 4 | export interface IconProps { 5 | Icon: React.FC; 6 | isActive: boolean; 7 | isHidden: boolean; 8 | children: React.ReactNode | null; 9 | onClick: () => void; 10 | width: number; 11 | isFill: boolean; 12 | } 13 | 14 | export function IconBtn({ 15 | Icon, 16 | isActive, 17 | children, 18 | onClick, 19 | isHidden, 20 | ...props 21 | }: IconProps) { 22 | return ( 23 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/forum/layout.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | import { getProgress } from "@/actions/get-progress"; 4 | import { getSession } from "@/lib/auth"; 5 | import { CourseNavbar } from "../_components/course-navbar"; 6 | import { CourseSidebar } from "../_components/course-sidebar"; 7 | import { getCourseWithProgress } from "@/actions/get-course-with-progress"; 8 | 9 | const CourseLayout = async ({ 10 | children, 11 | params, 12 | }: { 13 | children: React.ReactNode; 14 | params: { courseId: string }; 15 | }) => { 16 | const session = await getSession(); 17 | 18 | if (!session) { 19 | return redirect("/auth/signin"); 20 | } 21 | 22 | const userId = session.user.uid; 23 | 24 | const course = await getCourseWithProgress(params.courseId, userId); 25 | 26 | if (!course) { 27 | return redirect("/"); 28 | } 29 | 30 | const progressCount = -1; 31 | 32 | return ( 33 |
34 |
35 | 40 |
41 |
{children}
42 |
43 | ); 44 | }; 45 | 46 | export default CourseLayout; 47 | -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/forum/page.tsx: -------------------------------------------------------------------------------- 1 | import Forum from "./_components/forum"; 2 | import { headers } from 'next/headers'; 3 | import { getComment } from "@/actions/get-comment"; 4 | import { ForumProvider } from "./_components/_contexts/forum-context"; 5 | import { getSession } from "@/lib/auth"; 6 | import { Metadata } from "next"; 7 | import { db } from "@/lib/db"; 8 | import Link from "next/link"; 9 | import { ArrowLeft } from "lucide-react"; 10 | import { getCurrentCourse } from "@/actions/get-current-course"; 11 | 12 | export async function generateMetadata({ params }: { params: { courseId: string } }) { 13 | const course = await getCurrentCourse(params.courseId); 14 | return { 15 | title: `${course?.title} - Forum | CourseX` 16 | } 17 | } 18 | 19 | export default async function ForumPage() { 20 | const headersList = headers(); 21 | const header_url = headersList.get('x-url') || ""; 22 | const courseId = header_url.split('/')[4]; 23 | 24 | const commentList = await getComment({ courseId }); 25 | 26 | const session = await getSession(); 27 | const userId = session?.user.uid; 28 | 29 | return ( 30 |
31 |
32 | 37 | 38 | Back to course 39 | 40 |
41 | 42 | 43 | 44 |
45 | ) 46 | } -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/page.tsx: -------------------------------------------------------------------------------- 1 | import getRedirectCourse from "@/actions/get-redirect-course"; 2 | import { db } from "@/lib/db"; 3 | import { Metadata } from "next"; 4 | import { redirect } from "next/navigation"; 5 | 6 | export const metadata: Metadata = { 7 | title: "CourseX", 8 | }; 9 | 10 | const CourseIdPage = async ({ 11 | params 12 | }: { 13 | params: { courseId: string; } 14 | }) => { 15 | const course = await getRedirectCourse(params.courseId); 16 | 17 | if (!course) { 18 | return redirect("/"); 19 | } 20 | 21 | return redirect(`/courses/${course.id}/chapters/${course.chapters[0].id}`); 22 | } 23 | 24 | export default CourseIdPage; -------------------------------------------------------------------------------- /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 = ({ 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 { redirect } from "next/navigation"; 2 | import { CheckCircle, Clock } from "lucide-react"; 3 | 4 | import { getDashboardCourses } from "@/actions/get-dashboard-courses"; 5 | import { CoursesList } from "@/components/courses-list"; 6 | 7 | import { InfoCard } from "./_components/info-card"; 8 | import { getSession } from "@/lib/auth"; 9 | 10 | export default async function Dashboard() { 11 | const session = await getSession(); 12 | 13 | if (!session) { 14 | redirect("/auth/signin"); 15 | } 16 | const userId = session.user.uid; 17 | 18 | const { 19 | completedCourses, 20 | coursesInProgress 21 | } = await getDashboardCourses(userId); 22 | 23 | return ( 24 |
25 |
26 | 31 | 37 |
38 | 41 |
42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/admin/layout.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | import { getSession } from "@/lib/auth"; 3 | import { useSession } from "next-auth/react"; 4 | import { isAdminSession } from "@/lib/admin"; 5 | 6 | const AdminLayout = async ({ children }: { children: React.ReactNode }) => { 7 | const session = await getSession(); 8 | 9 | if (!isAdminSession(session?.user.role)) { 10 | return redirect("/"); 11 | } 12 | 13 | return <>{children}; 14 | }; 15 | 16 | export default AdminLayout; 17 | -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/admin/users/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | import { db } from "@/lib/db"; 3 | import { getSession } from "@/lib/auth"; 4 | import { DataTable } from "./_components/data-table"; 5 | import { columns } from "./_components/columns"; 6 | import { Metadata } from "next"; 7 | import { getAllUsers } from "@/actions/get-all-users"; 8 | 9 | export const metadata: Metadata = { 10 | title: "User Authorization | CourseX", 11 | }; 12 | 13 | const UserPage = async () => { 14 | const session = await getSession(); 15 | if (!session) { 16 | return redirect("/auth/signin"); 17 | } 18 | const users = await getAllUsers(); 19 | return ( 20 |
21 | 22 |
23 | ); 24 | }; 25 | 26 | export default UserPage; 27 | -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/search/_components/categories.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Category } from "@prisma/client"; 4 | import { CategoryItem } from "./category-item"; 5 | 6 | 7 | interface CategoriesProps{ 8 | items: Category[]; 9 | } 10 | 11 | export const Categories = ({ 12 | items, 13 | }: CategoriesProps) => { 14 | return ( 15 |
16 | {items.map((item) => ( 17 | 22 | ))} 23 |
24 | ) 25 | } -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/search/_components/category-item.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import { usePathname, useRouter, useSearchParams } from "next/navigation"; 5 | import { IconType } from "react-icons"; 6 | import qs from "query-string"; 7 | 8 | interface CategoryItemProps { 9 | label: string; 10 | value?: string; 11 | icon?: IconType; 12 | }; 13 | 14 | export const CategoryItem = ({ 15 | label, 16 | value, 17 | icon: Icon, 18 | }: CategoryItemProps) => { 19 | const pathname = usePathname(); 20 | const router = useRouter(); 21 | const searchParams = useSearchParams(); 22 | 23 | const currentCategoryId = searchParams.get("categoryId"); 24 | const currentTitle = searchParams.get("title"); 25 | 26 | const isSelected = currentCategoryId === value; 27 | 28 | const onClick = () => { 29 | const url = qs.stringifyUrl({ 30 | url: pathname, 31 | query: { 32 | title: currentTitle, 33 | categoryId: isSelected ? null :value, 34 | } 35 | }, { skipNull: true, skipEmptyString: true}); 36 | 37 | router.push(url); 38 | }; 39 | 40 | return ( 41 | 52 | ) 53 | } -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/search/overview/[courseId]/_components/coures-description.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { BookOpenIcon } from "lucide-react"; 5 | import { CourseProgress } from "@/components/course-progress"; 6 | import { IconBadge } from "@/components/icon-badge"; 7 | import { Category } from "@prisma/client"; 8 | import { Badge } from "@/components/ui/badge" 9 | import { cn } from "@/lib/utils" 10 | 11 | interface CourseDescriptionProps { 12 | title: string; 13 | description: string; 14 | numChapter: number; 15 | progressCount: number; 16 | categories: Category[]; 17 | } 18 | 19 | export const CourseDescription = ({ 20 | title, 21 | description, 22 | numChapter, 23 | progressCount, 24 | categories, 25 | }: CourseDescriptionProps) => { 26 | 27 | 28 | return ( 29 |
30 |
31 | Number of chapters: {numChapter} 32 |
33 |

34 | {title} 35 |

36 |
37 | {categories.map(category => ( 38 | 39 | {category.name} 40 | 41 | ))} 42 |
43 |

{description}

44 | 45 |
46 | ); 47 | } -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/search/overview/[courseId]/_components/enrollment.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { useState } from "react"; 5 | import axios from "axios"; 6 | import toast from "react-hot-toast"; 7 | 8 | interface EnrollmentProps { 9 | courseId: string; 10 | isEnrolled: boolean; 11 | } 12 | 13 | export const Enrollment = ({ 14 | courseId, 15 | isEnrolled, 16 | }: EnrollmentProps) => { 17 | 18 | const [isRequestEnrolled, setIsEnrolled] = useState(isEnrolled); 19 | 20 | const onClick = async () => { 21 | try { 22 | const response = await axios.post(`/api/courses/${courseId}/enroll`); 23 | console.log("User enrolled successfully:", response.data); 24 | setIsEnrolled(true); 25 | toast.success("Course enrolled"); 26 | } catch { 27 | toast.error("Something went wrong"); 28 | } 29 | }; 30 | 31 | return ( 32 |
33 |
34 |

Join learning now

35 |

36 | {isRequestEnrolled ? "Enroll request has been sent. Wait for being approved." : "Press the button below to send an enroll request."} 37 | 38 |

39 |
40 | 41 | 48 |
49 | ) 50 | } -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/search/overview/[courseId]/_components/forum-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import Link from "next/link"; 5 | 6 | interface ForumProps { 7 | courseId: string; 8 | isAccepted: boolean; 9 | } 10 | 11 | export const Forum = ({ 12 | courseId, 13 | }: ForumProps) => { 14 | 15 | 16 | return ( 17 |
18 |
19 |

20 | Discussion forum of this course 21 |

22 |

23 | Enter the forum to discuss 24 |

25 |
26 | 27 | 33 | 34 | 35 |
36 | ) 37 | } -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/search/overview/[courseId]/_components/thumbnail.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | 5 | interface ThumbnailProps { 6 | imgUrl: string; 7 | } 8 | 9 | export const Thumbnail = ({ 10 | imgUrl 11 | }: ThumbnailProps) => { 12 | 13 | return ( 14 |
15 | alt 20 |
21 | ) 22 | } -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/search/overview/[courseId]/_components/view.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import Link from "next/link"; 5 | 6 | interface ViewCourseProps { 7 | courseId: string; 8 | isAccepted: boolean; 9 | } 10 | 11 | export const ViewCourse = ({ 12 | courseId, 13 | isAccepted, 14 | }: ViewCourseProps) => { 15 | 16 | 17 | return ( 18 |
19 |
20 |

21 | {isAccepted 22 | ? "Continue your learning" 23 | : "Not sure if this course suits you?"} 24 |

25 |

26 | {isAccepted 27 | ? "Watch from your last completed chapters." 28 | : "Preview some contents from this course now."} 29 |

30 |
31 | 32 | 35 | 36 |
37 | ); 38 | } -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/search/overview/[courseId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | import { redirect } from "next/navigation"; 3 | import Link from "next/link"; 4 | 5 | import { Thumbnail } from "./_components/thumbnail"; 6 | import { CourseDescription } from "./_components/coures-description"; 7 | import { Enrollment } from "./_components/enrollment"; 8 | import { ArrowLeft } from "lucide-react"; 9 | import { CourseProgress } from "@/components/course-progress"; 10 | import { getProgress } from "@/actions/get-progress"; 11 | import { getSession } from "@/lib/auth"; 12 | import { ViewCourse } from "./_components/view"; 13 | import { Forum } from "./_components/forum-button"; 14 | import { getCurrentCourse } from "@/actions/get-current-course"; 15 | import getOverviewCourse from "@/actions/get-overview-course"; 16 | 17 | export async function generateMetadata({ 18 | params, 19 | }: { 20 | params: { courseId: string }; 21 | }) { 22 | const course = await getCurrentCourse(params.courseId); 23 | 24 | return { 25 | title: `${course?.title} - Overview | CourseX`, 26 | }; 27 | } 28 | 29 | const CourseIdOverview = async ({ 30 | params, 31 | }: { 32 | params: { courseId: string; courseTitle: string }; 33 | }) => { 34 | const session = await getSession(); 35 | 36 | if (!session) { 37 | return redirect("/"); 38 | } 39 | const userId = session.user.uid; 40 | 41 | const course = await getOverviewCourse(params.courseId, userId); 42 | 43 | if (!course) { 44 | return redirect("/"); 45 | } 46 | 47 | const isEnrolled = course.enrolls.length > 0; 48 | let isAccepted = false; 49 | if (isEnrolled) { 50 | // Nếu có thông tin enroll, lấy giá trị của trường isAccepted 51 | isAccepted = course.enrolls[0].isAccepted; 52 | } 53 | 54 | const progressCount = await getProgress(userId, course.id); 55 | 56 | return ( 57 |
58 | 63 | 64 | Back to course browsing 65 | 66 |
67 |
68 | 69 | category.category)} 75 | /> 76 |
77 | 78 |
79 | {!isAccepted ? ( 80 | 81 | ) : null} 82 | 83 | 84 | {isAccepted ? ( 85 | 86 | ) : null} 87 |
88 |
89 |
90 | ); 91 | }; 92 | 93 | export default CourseIdOverview; 94 | -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/search/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | import { db } from "@/lib/db"; 4 | import { SearchInput } from "@/components/search-input"; 5 | import { getCourses } from "@/actions/get-courses"; 6 | import { CoursesList } from "@/components/courses-list"; 7 | 8 | import { Categories } from "./_components/categories"; 9 | import { getServerSession } from "next-auth"; 10 | import { options } from "@/lib/auth"; 11 | import { Metadata } from "next"; 12 | import getAllCategories from "@/actions/get-categories"; 13 | // import getAllCategories from "@/actions/get-categories"; 14 | 15 | export const metadata: Metadata = { 16 | title: "Browse | CourseX", 17 | }; 18 | 19 | interface SearchPageProps { 20 | searchParams: { 21 | title: string; 22 | categoryId: string; 23 | }; 24 | } 25 | 26 | const SearchPage = async ({ searchParams }: SearchPageProps) => { 27 | const session = await getServerSession(options); 28 | 29 | if (!session) { 30 | redirect("/"); 31 | } 32 | const userId = session.user.uid; 33 | 34 | const categories = await getAllCategories(); 35 | 36 | const courses = await getCourses({ 37 | userId, 38 | ...searchParams, 39 | }); 40 | 41 | return ( 42 | <> 43 |
44 | 45 |
46 |
47 | 48 | 49 |
50 | 51 | ); 52 | }; 53 | 54 | export default SearchPage; 55 | -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/teacher/analytics/_components/chart.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Card } from "@/components/ui/card"; 4 | 5 | import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis } from "recharts"; 6 | 7 | interface ChartProps { 8 | data: { 9 | name: string; 10 | total: number; 11 | }[]; 12 | } 13 | 14 | export const Chart = ({ data }: ChartProps) => { 15 | return ( 16 | 17 | 18 | 19 | 26 | `${value}`} 32 | /> 33 | 34 | 35 | 36 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/teacher/analytics/_components/data-card.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 2 | import { format } from "path"; 3 | 4 | interface DataCardProps { 5 | value: number; 6 | label: string; 7 | shouldFormat?: boolean; 8 | } 9 | 10 | export const DataCard = ({ 11 | value, 12 | label, 13 | shouldFormat, 14 | }: DataCardProps) => { 15 | return ( 16 | 17 | 18 | 19 | {label} 20 | 21 | 22 | 23 |
24 | {value} 25 |
26 |
27 |
28 | ) 29 | } -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/teacher/analytics/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | import { getAnalytics } from "@/actions/get-analytic"; 3 | import { DataCard } from "./_components/data-card"; 4 | import { Chart } from "./_components/chart"; 5 | import { getSession } from "@/lib/auth"; 6 | import { Metadata } from "next"; 7 | 8 | export const metadata: Metadata = { 9 | title: "Teacher Analytics | CourseX", 10 | }; 11 | 12 | const AnalyticPage = async () => { 13 | const session = await getSession(); 14 | 15 | if (!session) { 16 | return redirect("/auth/signin"); 17 | } 18 | 19 | const userId = session.user.uid; 20 | 21 | const { data, totalCourses, totalEnrollment } = await getAnalytics(userId); 22 | 23 | return ( 24 |
25 |
26 | 31 | 35 |
36 | 39 |
40 | ); 41 | }; 42 | 43 | export default AnalyticPage; 44 | -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/teacher/courses/[courseId]/_components/actions.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ConfirmModal } from "@/components/modals/confirm-modal"; 4 | import { Button } from "@/components/ui/button"; 5 | import { useConfettiStore } from "@/hooks/use-confetti-store"; 6 | import axios from "axios"; 7 | import { Trash } from "lucide-react"; 8 | import { useRouter } from "next/navigation"; 9 | import { useState } from "react"; 10 | import toast from "react-hot-toast"; 11 | 12 | interface ActionsProps { 13 | disabled: boolean; 14 | courseId: string; 15 | isPublished: boolean; 16 | } 17 | 18 | export const Actions = ({ 19 | disabled, 20 | courseId, 21 | isPublished, 22 | }: ActionsProps) => { 23 | const router = useRouter(); 24 | const [isLoading, setIsLoading] = useState(false); 25 | 26 | const confetti = useConfettiStore(); 27 | 28 | const onClick = async () => { 29 | try { 30 | setIsLoading(true); 31 | 32 | if (isPublished) { 33 | await axios.patch(`/api/courses/${courseId}/unpublish`); 34 | toast.success("Course unpublished"); 35 | } else { 36 | await axios.patch(`/api/courses/${courseId}/publish`); 37 | toast.success("Course published"); 38 | confetti.onOpen(); 39 | } 40 | 41 | router.refresh(); 42 | } catch { 43 | toast.error("Something went wrong"); 44 | } finally { 45 | setIsLoading(false); 46 | } 47 | } 48 | 49 | const onDelete = async () => { 50 | try { 51 | setIsLoading(true); 52 | 53 | await axios.delete(`/api/courses/${courseId}`); 54 | 55 | toast.success("Course deleted"); 56 | router.refresh(); 57 | router.push(`/teacher/courses`); 58 | } catch { 59 | toast.error("Something went wrong"); 60 | } finally { 61 | setIsLoading(false); 62 | } 63 | }; 64 | 65 | return ( 66 |
67 | 75 | 76 | 79 | 80 |
81 | ); 82 | }; 83 | -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/teacher/courses/[courseId]/_components/image-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as z from "zod"; 4 | import axios from "axios"; 5 | import { ImageIcon, Pencil, PlusCircle } from "lucide-react"; 6 | import { useState } from "react"; 7 | import toast from "react-hot-toast"; 8 | import { useRouter } from "next/navigation"; 9 | import { Course } from "@prisma/client"; 10 | import Image from "next/image"; 11 | 12 | import { Button } from "@/components/ui/button"; 13 | import { FileUpload } from "@/components/file-upload"; 14 | 15 | interface ImageFormProps { 16 | initialData: Course; 17 | courseId: string; 18 | } 19 | 20 | const formSchema = z.object({ 21 | imageUrl: z.string().min(1, { 22 | message: "An image is mandatory", 23 | }), 24 | }); 25 | 26 | export const ImageForm = ({ initialData, courseId }: ImageFormProps) => { 27 | const [isEditing, setIsEditing] = useState(false); 28 | const toggleEdit = () => setIsEditing((current) => !current); 29 | 30 | const router = useRouter(); 31 | 32 | const onSubmit = async (values: z.infer) => { 33 | try { 34 | await axios.patch(`/api/courses/${courseId}`, values); 35 | toast.success("Course updated"); 36 | toggleEdit(); 37 | router.refresh(); 38 | } catch { 39 | toast.error("Something went wrong"); 40 | } 41 | }; 42 | 43 | return ( 44 |
45 |
46 | Course image 47 | 62 |
63 | {!isEditing && 64 | (!initialData.imageUrl ? ( 65 |
66 | 67 |
68 | ) : ( 69 |
70 | Upload 76 |
77 | ))} 78 | {isEditing && ( 79 |
80 | { 83 | if (url) { 84 | onSubmit({ imageUrl: url }); 85 | } 86 | }} 87 | /> 88 |
89 | Recommended ratio: 16:9 90 |
91 |
92 | )} 93 |
94 | ); 95 | }; 96 | -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/teacher/courses/[courseId]/_components/title-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 | 12 | import { 13 | Form, 14 | FormControl, 15 | FormField, 16 | FormItem, 17 | FormMessage, 18 | } from "@/components/ui/form"; 19 | import { Input } from "@/components/ui/input"; 20 | import { Button } from "@/components/ui/button"; 21 | 22 | interface TitleFormProps { 23 | initialData: { 24 | title: string; 25 | }; 26 | courseId: string; 27 | } 28 | 29 | const formSchema = z.object({ 30 | title: z.string().min(1, { 31 | message: "Tiêu đề là bắt buộc", 32 | }), 33 | }); 34 | 35 | export const TitleForm = ({ initialData, courseId }: TitleFormProps) => { 36 | const [isEditing, setIsEditing] = useState(false); 37 | const toggleEdit = () => setIsEditing((current) => !current); 38 | 39 | const form = useForm>({ 40 | resolver: zodResolver(formSchema), 41 | defaultValues: initialData, 42 | }); 43 | 44 | const router = useRouter(); 45 | 46 | const { isSubmitting, isValid } = form.formState; 47 | 48 | const onSubmit = async (values: z.infer) => { 49 | try { 50 | await axios.patch(`/api/courses/${courseId}`, values); 51 | toast.success("Course updated"); 52 | toggleEdit(); 53 | router.refresh(); 54 | } catch { 55 | toast.error("Something went wrong"); 56 | } 57 | }; 58 | 59 | return ( 60 |
61 |
62 | Course title 63 | 73 |
74 | {!isEditing &&

{initialData.title}

} 75 | {isEditing && ( 76 |
77 | 81 | ( 85 | 86 | 87 | 92 | 93 | 94 | 95 | )} 96 | /> 97 |
98 | 101 |
102 | 103 | 104 | )} 105 |
106 | ); 107 | }; 108 | -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/teacher/courses/[courseId]/chapters/[chapterId]/_components/chapter-actions.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ConfirmModal } from "@/components/modals/confirm-modal"; 4 | import { Button } from "@/components/ui/button"; 5 | import axios from "axios"; 6 | import { Trash } from "lucide-react"; 7 | import { useRouter } from "next/navigation"; 8 | import { useState } from "react"; 9 | import toast from "react-hot-toast"; 10 | 11 | interface ChapterActionsProps { 12 | disabled: boolean; 13 | courseId: string; 14 | chapterId: string; 15 | isPublished: boolean; 16 | } 17 | 18 | export const ChapterActions = ({ 19 | disabled, 20 | courseId, 21 | chapterId, 22 | isPublished, 23 | }: ChapterActionsProps) => { 24 | const router = useRouter(); 25 | const [isLoading, setIsLoading] = useState(false); 26 | 27 | const onClick = async () => { 28 | try { 29 | setIsLoading(true); 30 | 31 | if (isPublished) { 32 | await axios.patch(`/api/courses/${courseId}/chapters/${chapterId}/unpublish`); 33 | toast.success("Chapter unpublished"); 34 | } else { 35 | await axios.patch(`/api/courses/${courseId}/chapters/${chapterId}/publish`); 36 | toast.success("Chapter published"); 37 | } 38 | 39 | router.refresh(); 40 | } catch { 41 | toast.error("Something went wrong"); 42 | } finally { 43 | setIsLoading(false); 44 | } 45 | } 46 | 47 | const onDelete = async () => { 48 | try { 49 | setIsLoading(true); 50 | 51 | await axios.delete(`/api/courses/${courseId}/chapters/${chapterId}`); 52 | 53 | toast.success("Chapter deleted"); 54 | router.refresh(); 55 | router.push(`/teacher/courses/${courseId}`); 56 | } catch { 57 | toast.error("Something went wrong"); 58 | } finally { 59 | setIsLoading(false); 60 | } 61 | }; 62 | 63 | return ( 64 |
65 | 73 | 74 | 77 | 78 |
79 | ); 80 | }; 81 | -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/teacher/courses/[courseId]/chapters/[chapterId]/_components/chapter-title-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 | 12 | import { 13 | Form, 14 | FormControl, 15 | FormField, 16 | FormItem, 17 | FormMessage, 18 | } from "@/components/ui/form"; 19 | import { Input } from "@/components/ui/input"; 20 | import { Button } from "@/components/ui/button"; 21 | 22 | interface ChapterTitleFormProps { 23 | initialData: { 24 | title: string; 25 | }; 26 | courseId: string; 27 | chapterId: string; 28 | } 29 | 30 | const formSchema = z.object({ 31 | title: z.string().min(1), 32 | }); 33 | 34 | export const ChapterTitleForm = ({ 35 | initialData, 36 | courseId, 37 | chapterId, 38 | }: ChapterTitleFormProps) => { 39 | const [isEditing, setIsEditing] = useState(false); 40 | const toggleEdit = () => setIsEditing((current) => !current); 41 | 42 | const form = useForm>({ 43 | resolver: zodResolver(formSchema), 44 | defaultValues: initialData, 45 | }); 46 | 47 | const router = useRouter(); 48 | 49 | const { isSubmitting, isValid } = form.formState; 50 | 51 | const onSubmit = async (values: z.infer) => { 52 | try { 53 | await axios.patch( 54 | `/api/courses/${courseId}/chapters/${chapterId}`, 55 | values 56 | ); 57 | toast.success("Chapter updated"); 58 | toggleEdit(); 59 | router.refresh(); 60 | } catch { 61 | toast.error("Something went wrong"); 62 | } 63 | }; 64 | 65 | return ( 66 |
67 |
68 | Chapter title 69 | 79 |
80 | {!isEditing &&

{initialData.title}

} 81 | {isEditing && ( 82 |
83 | 87 | ( 91 | 92 | 93 | 98 | 99 | 100 | 101 | )} 102 | /> 103 |
104 | 107 |
108 | 109 | 110 | )} 111 |
112 | ); 113 | }; 114 | -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/teacher/courses/[courseId]/chapters/[chapterId]/_components/chapter-video-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as z from "zod"; 4 | import axios from "axios"; 5 | import { Pencil, PlusCircle, Video } from "lucide-react"; 6 | import { useState } from "react"; 7 | import toast from "react-hot-toast"; 8 | import { useRouter } from "next/navigation"; 9 | import { Chapter, MuxData } from "@prisma/client"; 10 | import MuxPlayer from "@mux/mux-player-react"; 11 | 12 | import { Button } from "@/components/ui/button"; 13 | import { FileUpload } from "@/components/file-upload"; 14 | 15 | interface ChapterVideoFormProps { 16 | initialData: Chapter & { muxData?: MuxData | null }; 17 | courseId: string; 18 | chapterId: string; 19 | } 20 | 21 | const formSchema = z.object({ 22 | videoUrl: z.string().min(1), 23 | }); 24 | 25 | export const ChapterVideoForm = ({ 26 | initialData, 27 | courseId, 28 | chapterId, 29 | }: ChapterVideoFormProps) => { 30 | const [isEditing, setIsEditing] = useState(false); 31 | const toggleEdit = () => setIsEditing((current) => !current); 32 | 33 | const router = useRouter(); 34 | 35 | const onSubmit = async (values: z.infer) => { 36 | try { 37 | await axios.patch( 38 | `/api/courses/${courseId}/chapters/${chapterId}`, 39 | values 40 | ); 41 | toast.success("Chapter updated"); 42 | toggleEdit(); 43 | router.refresh(); 44 | } catch { 45 | toast.error("Something went wrong"); 46 | } 47 | }; 48 | 49 | return ( 50 |
51 |
52 | Chapter video 53 | 68 |
69 | {!isEditing && 70 | (!initialData.videoUrl ? ( 71 |
72 |
74 | ) : ( 75 |
76 | Video uploaded 77 | 81 |
82 | ))} 83 | {isEditing && ( 84 |
85 | { 88 | if (url) { 89 | onSubmit({ videoUrl: url }); 90 | } 91 | }} 92 | /> 93 |
94 | Upload a video for this chapter 95 |
96 |
97 | )} 98 | {initialData.videoUrl && !isEditing && ( 99 |
100 | It might take a few minutes for the video to be processed. Reload the tab to view changes 101 |
102 | )} 103 |
104 | ); 105 | }; 106 | -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/teacher/courses/_components/columns.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Course } from "@prisma/client" 4 | import { ColumnDef } from "@tanstack/react-table" 5 | import { ArrowUpDown, MoreHorizontal, Pencil } from "lucide-react" 6 | import { Button } from "@/components/ui/button"; 7 | 8 | import { 9 | DropdownMenu, 10 | DropdownMenuContent, 11 | DropdownMenuItem, 12 | DropdownMenuTrigger, 13 | } from "@/components/ui/dropdown-menu"; 14 | import Link from "next/link"; 15 | import { cn } from "@/lib/utils"; 16 | import { Badge } from "@/components/ui/badge"; 17 | 18 | export const columns: ColumnDef[] = [ 19 | { 20 | accessorKey: "title", 21 | header: ({ column }) => { 22 | return ( 23 | 30 | ); 31 | }, 32 | cell: ({ row }) => { 33 | const title = String(row.getValue("title") || ""); 34 | const { id } = row.original; 35 | return ( 36 | 37 | {title} 38 | 39 | ); 40 | } 41 | }, 42 | { 43 | accessorKey: "isPublished", 44 | header: ({ column }) => { 45 | return ( 46 | 53 | ); 54 | }, 55 | cell: ({ row }) => { 56 | const isPublished = row.getValue("isPublished") || false; 57 | 58 | return ( 59 | 60 | {isPublished ? "Published" : "Draft"} 61 | 62 | ); 63 | }, 64 | }, 65 | { 66 | id: "actions", 67 | cell: ({ row }) => { 68 | const { id } = row.original; 69 | return ( 70 | 71 | 72 | 76 | 77 | 78 | 79 | 80 | 81 | Edit 82 | 83 | 84 | 85 | 86 | ); 87 | }, 88 | }, 89 | ]; 90 | -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/teacher/courses/page.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import Link from "next/link"; 3 | import { DataTable } from "./_components/data-table"; 4 | import { columns } from "./_components/columns"; 5 | import { redirect } from "next/navigation"; 6 | import { db } from "@/lib/db"; 7 | import { getSession } from "@/lib/auth"; 8 | import { Metadata } from "next"; 9 | import getCoursesCreated from "@/actions/get-courses-created"; 10 | 11 | export const metadata: Metadata = { 12 | title: "Teacher Dashboard | CourseX", 13 | }; 14 | 15 | const CoursesPage = async () => { 16 | const session = await getSession(); 17 | 18 | if (!session) { 19 | return redirect("/auth/signin"); 20 | } 21 | 22 | const userId = session.user.uid; 23 | 24 | const courses = await getCoursesCreated(userId); 25 | 26 | return ( 27 |
28 | 29 |
30 | ); 31 | }; 32 | 33 | export default CoursesPage; 34 | -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/teacher/enrollment/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | import { db } from "@/lib/db"; 3 | import { DataTable } from "./_components/data-table"; 4 | import { columns } from "./_components/columns"; 5 | import { Button } from "@/components/ui/button"; 6 | 7 | import { getSession } from "@/lib/auth"; 8 | import { getWaitlist } from "@/actions/get-waitlist"; 9 | import { Metadata } from "next"; 10 | 11 | export const metadata: Metadata = { 12 | title: "Course Approval | CourseX", 13 | }; 14 | 15 | const UserPage = async () => { 16 | const session = await getSession(); 17 | 18 | if (!session) { 19 | return redirect("/auth/signin"); 20 | } 21 | 22 | const userId = session.user.uid; 23 | 24 | try { 25 | const waitlist = await getWaitlist({ userId }); 26 | 27 | return ( 28 |
29 | 30 |
31 | ); 32 | } catch (error) { 33 | console.error("[ENROLLMENT_PAGE]", error); 34 | return
Error loading waitlist data.
; 35 | } 36 | }; 37 | 38 | export default UserPage; 39 | -------------------------------------------------------------------------------- /app/(dashboard)/(routes)/teacher/layout.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | import { getSession } from "@/lib/auth"; 3 | import { isTeacherSession } from "@/lib/teacher"; 4 | 5 | const TeacherLayout = async ({ 6 | children 7 | }: { 8 | children: React.ReactNode; 9 | }) => { 10 | const session = await getSession(); 11 | 12 | if (!isTeacherSession(session?.user.role)) { 13 | return redirect("/"); 14 | } 15 | return <>{children} 16 | } 17 | 18 | export default TeacherLayout; -------------------------------------------------------------------------------- /app/(dashboard)/_components/logo.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | 4 | export const Logo = () => { 5 | const logoSrc = "/logo.svg"; 6 | 7 | return ( 8 | 9 | logo 17 | 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/mobile-sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { Menu } from "lucide-react"; 2 | 3 | import { 4 | Sheet, 5 | SheetContent, 6 | SheetTrigger 7 | } from "@/components/ui/sheet"; 8 | import { Sidebar } from "./sidebar"; 9 | 10 | export const MobileSidebar = () => { 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ) 21 | } -------------------------------------------------------------------------------- /app/(dashboard)/_components/navbar.tsx: -------------------------------------------------------------------------------- 1 | import { NavbarRoutes } from "@/components/navbar-routes" 2 | import { MobileSidebar } from "./mobile-sidebar" 3 | 4 | export const Navbar = () => { 5 | return ( 6 |
7 | 8 | 9 |
10 | ) 11 | } -------------------------------------------------------------------------------- /app/(dashboard)/_components/sidebar-item.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import { LucideIcon } from "lucide-react"; 5 | import { usePathname, useRouter } from "next/navigation"; 6 | 7 | interface SidebarItemProps { 8 | icon: LucideIcon; 9 | label: string; 10 | href: string; 11 | }; 12 | 13 | export const SidebarItem = ({ 14 | icon: Icon, 15 | label, 16 | href, 17 | }: SidebarItemProps 18 | ) => { 19 | const pathname = usePathname(); 20 | const router = useRouter(); 21 | 22 | const isActive = 23 | (pathname === "/" && href === "/") || 24 | pathname === href || 25 | pathname?.startsWith(`${href}/`); 26 | 27 | const onClick = () => { 28 | router.push(href) 29 | } 30 | 31 | return ( 32 | 58 | ) 59 | } -------------------------------------------------------------------------------- /app/(dashboard)/_components/sidebar-routes.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { BarChart, Compass, Layout, List, Shapes } from "lucide-react"; 4 | import { SidebarItem } from "./sidebar-item"; 5 | import { usePathname } from "next/navigation"; 6 | 7 | const guestRoutes = [ 8 | { 9 | icon: Layout, 10 | label: "Dashboard", 11 | href: "/", 12 | }, 13 | { 14 | icon: Compass, 15 | label: "Browse", 16 | href: "/search", 17 | }, 18 | ] 19 | 20 | const teacherRoutes = [ 21 | { 22 | icon: List, 23 | label: "Courses", 24 | href: "/teacher/courses", 25 | }, 26 | { 27 | icon: BarChart, 28 | label: "Analytics", 29 | href: "/teacher/analytics", 30 | }, 31 | { 32 | icon: Shapes, 33 | label: "Enrollment", 34 | href: "/teacher/enrollment", 35 | }, 36 | ] 37 | 38 | const adminRoutes = [ 39 | { 40 | icon: List, 41 | label: "Users", 42 | href:"/admin/users", 43 | }, 44 | ] 45 | 46 | export const SidebarRoutes = () => { 47 | const pathname = usePathname(); 48 | 49 | const isTeacherPage = pathname?.includes("/teacher"); 50 | 51 | const isAdminPage = pathname?.includes("/admin"); 52 | 53 | const routes = isAdminPage ? adminRoutes : isTeacherPage ? teacherRoutes : guestRoutes; 54 | 55 | return ( 56 |
57 | {routes.map((route) => ( 58 | 64 | ))} 65 |
66 | ) 67 | } -------------------------------------------------------------------------------- /app/(dashboard)/_components/sidebar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Logo } from "./logo" 4 | import { SidebarRoutes } from "./sidebar-routes" 5 | 6 | export const Sidebar = () => { 7 | return ( 8 |
9 |
10 | 11 |
12 |
13 | 14 |
15 |
16 | ) 17 | } -------------------------------------------------------------------------------- /app/(dashboard)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Navbar } from "./_components/navbar"; 2 | import { Sidebar } from "./_components/sidebar"; 3 | 4 | const DashboardLayout = ({ children }: { children: React.ReactNode }) => { 5 | return ( 6 |
7 |
8 | 9 |
10 | 11 |
12 | 13 |
14 |
{children}
15 |
16 | ); 17 | }; 18 | 19 | export default DashboardLayout; 20 | -------------------------------------------------------------------------------- /app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from "next-auth"; 2 | import { options } from "@/lib/auth"; 3 | 4 | const handler = NextAuth(options); 5 | export { handler as GET, handler as POST }; -------------------------------------------------------------------------------- /app/api/auth/register/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import bcrypt from "bcrypt"; 3 | import { db } from "@/lib/db"; 4 | 5 | export async function POST(req: Request) { 6 | try { 7 | const body = await req.json(); 8 | const userData = body.formData; 9 | if (!userData?.email || !userData?.password) { 10 | return NextResponse.json({ message: "All field are required." }, { status: 400 }); 11 | } 12 | 13 | const duplicate = await db.user.findUnique({ 14 | where:{ 15 | email: userData.email, 16 | }, 17 | }) 18 | 19 | if(duplicate) { 20 | return NextResponse.json({message: "Duplicate Email"}, {status: 409}); 21 | } 22 | const hashPassword = await bcrypt.hash(userData.password, 10); 23 | userData.password = hashPassword; 24 | 25 | await db.user.create({ 26 | data: userData, 27 | }); 28 | return NextResponse.json({message: "User created."}, {status: 201}); 29 | 30 | } catch (error) { 31 | console.log(error); 32 | return NextResponse.json({ message: "Error", error }, { status: 500 }); 33 | } 34 | } -------------------------------------------------------------------------------- /app/api/courses/[courseId]/attachments/[attachmentId]/route.ts: -------------------------------------------------------------------------------- 1 | import { isAdminDB, isAdminSession } from "@/lib/admin"; 2 | import { getSession } from "@/lib/auth"; 3 | import { db } from "@/lib/db"; 4 | import { isTeacherDB, isTeacherSession } from "@/lib/teacher"; 5 | import { NextResponse } from "next/server"; 6 | 7 | export async function DELETE( 8 | req: Request, 9 | { params }: { params: { courseId: string, attachmentId: string } } 10 | ) { 11 | try { 12 | const session = await getSession(); 13 | const userId = session?.user.uid; 14 | const role = session?.user.role; 15 | var isAuthorized = isAdminSession(role) || isTeacherSession(role); 16 | if (!isAuthorized) { 17 | isAuthorized = await isAdminDB(userId) || await isTeacherDB(userId); 18 | } 19 | 20 | if (!userId || !isAuthorized) { 21 | return new NextResponse("Unauthorized", { status: 401 }); 22 | } 23 | 24 | const courseOwner = await db.course.findUnique({ 25 | where: { 26 | id: params.courseId, 27 | userId: userId, 28 | } 29 | }); 30 | 31 | if (!courseOwner) { 32 | return new NextResponse("Unauthorized", { status: 401 }); 33 | } 34 | 35 | const attachment = await db.attachment.delete({ 36 | where: { 37 | courseId: params.courseId, 38 | id: params.attachmentId 39 | } 40 | }); 41 | 42 | return NextResponse.json(attachment); 43 | } catch(error) { 44 | console.log("ATTACHMENT ID", error); 45 | return new NextResponse("Internal Error", {status: 500}); 46 | } 47 | } -------------------------------------------------------------------------------- /app/api/courses/[courseId]/attachments/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | import { db } from "@/lib/db"; 4 | import { getSession } from "@/lib/auth"; 5 | import { isTeacherDB, isTeacherSession } from "@/lib/teacher"; 6 | import { isAdminDB, isAdminSession } from "@/lib/admin"; 7 | 8 | export async function POST( 9 | req: Request, 10 | { params }: { params: { courseId: string } } 11 | ) { 12 | try { 13 | const session = await getSession(); 14 | const userId = session?.user.uid; 15 | const role = session?.user.role; 16 | const { url, name } = await req.json(); 17 | var isAuthorized = isAdminSession(role) || isTeacherSession(role); 18 | if (!isAuthorized) { 19 | isAuthorized = await isAdminDB(userId) || await isTeacherDB(userId); 20 | } 21 | 22 | if (!userId || !isAuthorized) { 23 | return new NextResponse("Unauthorized", { status: 401 }); 24 | } 25 | 26 | const courseOwner = await db.course.findUnique({ 27 | where: { 28 | id: params.courseId, 29 | userId: userId, 30 | } 31 | }); 32 | 33 | if (!courseOwner) { 34 | return new NextResponse("Unauthorized", { status: 401 }); 35 | } 36 | 37 | const attachment = await db.attachment.create({ 38 | data: { 39 | url, 40 | name: name, 41 | courseId: params.courseId, 42 | } 43 | }) 44 | 45 | return NextResponse.json(attachment); 46 | } catch (error) { 47 | console.log("COURSE_ID_ATTACHMENT", error); 48 | return new NextResponse("Internal Error", { status: 500 }); 49 | } 50 | 51 | } -------------------------------------------------------------------------------- /app/api/courses/[courseId]/categories/route.ts: -------------------------------------------------------------------------------- 1 | import { getSession } from "@/lib/auth"; 2 | import { db } from "@/lib/db"; 3 | import { NextResponse } from "next/server"; 4 | 5 | import { isAdminDB, isAdminSession } from '@/lib/admin'; 6 | import { isTeacherDB, isTeacherSession } from '@/lib/teacher'; 7 | import { Tag } from '@/components/ui/tag-input'; 8 | 9 | export async function PATCH( 10 | req: Request, 11 | { params }: { params: { courseId: string } } 12 | ) { 13 | try { 14 | const session = await getSession(); 15 | const userId = session?.user.uid; 16 | const role = session?.user.role; 17 | const values = await req.json(); 18 | var isAuthorized = isAdminSession(role) || isTeacherSession(role); 19 | if (!isAuthorized) { 20 | isAuthorized = await isAdminDB(userId) || await isTeacherDB(userId); 21 | } 22 | 23 | if (!userId || !isAuthorized) { 24 | return new NextResponse("Unauthorized", { status: 401 }); 25 | } 26 | 27 | const { categories, ...otherValues } = values; 28 | 29 | console.log(values) 30 | 31 | await db.courseCategory.deleteMany({ 32 | where: { 33 | courseId: params.courseId, 34 | }, 35 | }); 36 | 37 | const updatedCourseCategories = await Promise.all( 38 | categories.map(async (category: Tag) => { 39 | const existingCategory = await db.category.findFirst({ 40 | where: { id: category.id }, 41 | }); 42 | 43 | if (!existingCategory) { 44 | await db.category.create({ 45 | data: { 46 | id: category.id, 47 | name: category.text, 48 | }, 49 | }); 50 | } 51 | 52 | return db.courseCategory.create({ 53 | data: { 54 | categoryId: category.id, 55 | courseId: params.courseId, 56 | }, 57 | }); 58 | 59 | }) 60 | ); 61 | 62 | const updatedCourse = await db.course.update({ 63 | where: { 64 | id: params.courseId, 65 | userId, 66 | }, 67 | data: { 68 | ...otherValues, 69 | categories: { 70 | connect: updatedCourseCategories.map((category) => ({ id: category.id })), 71 | }, 72 | }, 73 | }); 74 | 75 | return NextResponse.json(updatedCourse); 76 | } catch (error) { 77 | console.log("[COURSE_ID]", error); 78 | return new NextResponse("Internal Error", { status: 500 }); 79 | } 80 | } -------------------------------------------------------------------------------- /app/api/courses/[courseId]/chapters/[chapterId]/progress/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | import { db } from "@/lib/db"; 4 | import { getSession } from "@/lib/auth"; 5 | 6 | export async function PUT( 7 | req: Request, 8 | { params }: { params: { courseId: string; chapterId: string } } 9 | ) { 10 | try { 11 | const session = await getSession(); 12 | const userId = session?.user.uid; 13 | const { isCompleted } = await req.json(); 14 | 15 | if (!userId) { 16 | return new NextResponse("Unauthorized", { status: 401 }); 17 | } 18 | 19 | const userProgress = await db.userProgress.upsert({ 20 | where: { 21 | userId_chapterId: { 22 | userId, 23 | chapterId: params.chapterId, 24 | } 25 | }, 26 | update: { 27 | isCompleted 28 | }, 29 | create: { 30 | userId, 31 | chapterId: params.chapterId, 32 | isCompleted, 33 | } 34 | }) 35 | 36 | return NextResponse.json(userProgress); 37 | } catch (error) { 38 | console.log("[CHAPTER_ID_PROGRESS]", error); 39 | return new NextResponse("Internal Error", { status: 500 }); 40 | } 41 | } -------------------------------------------------------------------------------- /app/api/courses/[courseId]/chapters/[chapterId]/publish/route.ts: -------------------------------------------------------------------------------- 1 | import { getSession } from "@/lib/auth"; 2 | import { db } from "@/lib/db"; 3 | import { NextResponse } from "next/server"; 4 | 5 | export async function PATCH( 6 | req: Request, 7 | { params }: { params: { courseId: string; chapterId: string } } 8 | ) { 9 | try { 10 | const session = await getSession(); 11 | const userId = session?.user.uid; 12 | 13 | if (!userId) { 14 | return new NextResponse("Unauthorized", { status: 401 }); 15 | } 16 | 17 | const ownCourse = await db.course.findUnique({ 18 | where: { 19 | id: params.courseId, 20 | userId 21 | } 22 | }); 23 | 24 | if (!ownCourse) { 25 | return new NextResponse("Unauthorized", { status: 401 }); 26 | } 27 | 28 | const chapter = await db.chapter.findUnique({ 29 | where: { 30 | id: params.chapterId, 31 | courseId: params.courseId, 32 | } 33 | }); 34 | 35 | const muxData = await db.muxData.findUnique({ 36 | where: { 37 | chapterId: params.chapterId, 38 | } 39 | }); 40 | 41 | if (!chapter || !muxData || !chapter.title || !chapter.description || !chapter.videoUrl) { 42 | return new NextResponse("Missing required fields", { status: 400 }); 43 | } 44 | 45 | const publishedChapter = await db.chapter.update({ 46 | where: { 47 | id: params.chapterId, 48 | courseId: params.courseId, 49 | }, 50 | data: { 51 | isPublished: true, 52 | } 53 | }); 54 | 55 | return NextResponse.json(publishedChapter); 56 | } catch (error) { 57 | console.log("[CHAPTER_PUBLISH]", error); 58 | return new NextResponse("Internal Error", { status: 500 }); 59 | } 60 | } -------------------------------------------------------------------------------- /app/api/courses/[courseId]/chapters/[chapterId]/unpublish/route.ts: -------------------------------------------------------------------------------- 1 | import { getSession } from "@/lib/auth"; 2 | import { db } from "@/lib/db"; 3 | import { NextResponse } from "next/server"; 4 | 5 | export async function PATCH( 6 | req: Request, 7 | { params }: { params: { courseId: string; chapterId: string } } 8 | ) { 9 | try { 10 | const session = await getSession(); 11 | const userId = session?.user.uid; 12 | 13 | if (!userId) { 14 | return new NextResponse("Unauthorized", { status: 401 }); 15 | } 16 | 17 | const ownCourse = await db.course.findUnique({ 18 | where: { 19 | id: params.courseId, 20 | userId, 21 | }, 22 | }); 23 | 24 | if (!ownCourse) { 25 | return new NextResponse("Unauthorized", { status: 401 }); 26 | } 27 | 28 | const unPublishedChapter = await db.chapter.update({ 29 | where: { 30 | id: params.chapterId, 31 | courseId: params.courseId, 32 | }, 33 | data: { 34 | isPublished: false, 35 | }, 36 | }); 37 | 38 | const publishedChapterInCourse = await db.chapter.findMany({ 39 | where: { 40 | courseId: params.courseId, 41 | isPublished: true, 42 | }, 43 | }); 44 | 45 | if (!publishedChapterInCourse.length) 46 | [ 47 | await db.course.update({ 48 | where: { 49 | id: params.courseId, 50 | }, 51 | data: { 52 | isPublished: false, 53 | }, 54 | }), 55 | ]; 56 | 57 | return NextResponse.json(unPublishedChapter); 58 | } catch (error) { 59 | console.log("[CHAPTER_UNPUBLISH]", error); 60 | return new NextResponse("Internal Error", { status: 500 }); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /app/api/courses/[courseId]/chapters/reorder/route.ts: -------------------------------------------------------------------------------- 1 | import { getSession } from "@/lib/auth"; 2 | import { db } from "@/lib/db"; 3 | import { NextResponse } from "next/server"; 4 | 5 | export async function PUT( 6 | req: Request, 7 | { params }: { params: { courseId: string } } 8 | ) { 9 | try { 10 | const session = await getSession(); 11 | const userId = session?.user.uid; 12 | 13 | if (!userId) { 14 | return new NextResponse("Unauthorized", { status: 401 }); 15 | } 16 | 17 | const courseOwner = await db.course.findUnique({ 18 | where: { 19 | id: params.courseId, 20 | userId: userId, 21 | } 22 | }); 23 | 24 | const { list } = await req.json(); 25 | 26 | if (!courseOwner) { 27 | return new NextResponse("Unauthorized", { status: 401 }); 28 | } 29 | 30 | 31 | for (let item of list) { 32 | await db.chapter.update({ 33 | where: { id: item.id }, 34 | data: { position: item.position }, 35 | }) 36 | } 37 | 38 | return new NextResponse("Success", { status: 200 }); 39 | 40 | } catch (error) { 41 | console.log("[REORDER]", error); 42 | return new NextResponse("Internal Error", { status: 500 }); 43 | } 44 | 45 | } -------------------------------------------------------------------------------- /app/api/courses/[courseId]/chapters/route.ts: -------------------------------------------------------------------------------- 1 | import { isAdminDB, isAdminSession } from "@/lib/admin"; 2 | import { getSession } from "@/lib/auth"; 3 | import { db } from "@/lib/db"; 4 | import { isTeacherDB, isTeacherSession } from "@/lib/teacher"; 5 | import { NextResponse } from "next/server"; 6 | 7 | export async function POST( 8 | req: Request, 9 | { params }: { params: { courseId: string } } 10 | ) { 11 | try { 12 | const session = await getSession(); 13 | const userId = session?.user.uid; 14 | const role = session?.user.role; 15 | const { title } = await req.json(); 16 | var isAuthorized = isAdminSession(role) || isTeacherSession(role); 17 | if (!isAuthorized) { 18 | isAuthorized = await isAdminDB(userId) || await isTeacherDB(userId); 19 | } 20 | 21 | if (!userId || !isAuthorized) { 22 | return new NextResponse("Unauthorized", { status: 401 }); 23 | } 24 | 25 | const courseOwner = await db.course.findUnique({ 26 | where: { 27 | id: params.courseId, 28 | userId: userId, 29 | } 30 | }); 31 | 32 | if (!courseOwner) { 33 | return new NextResponse("Unauthorized", { status: 401 }); 34 | } 35 | 36 | const lastChapter = await db.chapter.findFirst({ 37 | where: { 38 | courseId: params.courseId, 39 | }, 40 | orderBy: { 41 | position: "desc", 42 | }, 43 | }); 44 | 45 | const newPosition = lastChapter ? lastChapter.position + 1 : 1; 46 | 47 | const chapter = await db.chapter.create({ 48 | data: { 49 | title, 50 | courseId: params.courseId, 51 | position: newPosition, 52 | }, 53 | }); 54 | 55 | return NextResponse.json(chapter); 56 | } catch (error) { 57 | console.log("[CHAPTER]", error); 58 | return new NextResponse("Inernal Error", { status: 500 }); 59 | } 60 | 61 | } -------------------------------------------------------------------------------- /app/api/courses/[courseId]/comment/like/route.ts: -------------------------------------------------------------------------------- 1 | import { getSession } from "@/lib/auth"; 2 | import { NextResponse } from "next/server"; 3 | import { db } from "@/lib/db"; 4 | 5 | export async function POST(req: Request) { 6 | try { 7 | const session = await getSession(); 8 | const userId = session?.user.uid; 9 | 10 | if (!userId) { 11 | return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); 12 | } 13 | 14 | const body = await req.json(); 15 | const userIdLike = body.userId; 16 | const commentIdLike = body.commentId; 17 | const existedLike = await db.like.findFirst({ 18 | where: { 19 | userId: userIdLike, 20 | commentId: commentIdLike, 21 | }, 22 | }); 23 | if (existedLike) { 24 | const deletedLike = await db.like.delete({ 25 | where: { 26 | userId_commentId: { 27 | userId: userIdLike, 28 | commentId: commentIdLike, 29 | }, 30 | }, 31 | }); 32 | if (deletedLike) { 33 | return NextResponse.json({ message: "Unliked" }, { status: 200 }); 34 | } 35 | return NextResponse.json({ message: "Cannot unlike" }, { status: 500 }); 36 | } 37 | const newLiked = await db.like.create({ 38 | data: { 39 | userId: userIdLike, 40 | commentId: commentIdLike, 41 | }, 42 | }); 43 | if (newLiked) 44 | return NextResponse.json({ message: "Liked" }, { status: 200 }); 45 | } catch (error) { 46 | console.log("LIKE", error); 47 | return NextResponse.json({ message: "Internal Error" }, { status: 500 }); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/api/courses/[courseId]/comment/route.ts: -------------------------------------------------------------------------------- 1 | import { getSession } from "@/lib/auth"; 2 | import { NextResponse } from "next/server"; 3 | import { db } from "@/lib/db"; 4 | 5 | export async function POST( 6 | req: Request, 7 | { params }: { params: { courseId: string } } 8 | ) { 9 | try { 10 | const session = await getSession(); 11 | const userId = session?.user.uid; 12 | 13 | if (!userId) { 14 | return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); 15 | } 16 | 17 | const body = await req.json(); 18 | const commentData = body.formData; 19 | const newComment = await db.comment.create({ 20 | data: { 21 | userId: userId, 22 | content: commentData.comment, 23 | courseId: params.courseId, 24 | parentId: commentData.parentId, 25 | }, 26 | }); 27 | if (newComment) 28 | return NextResponse.json({ message: "Commented" }, { status: 200 }); 29 | } catch (error) { 30 | console.log("FORUM_COMMENT", error); 31 | return NextResponse.json({ message: "Internal Error" }, { status: 500 }); 32 | } 33 | } 34 | 35 | export async function PATCH( 36 | req: Request, 37 | { params }: { params: { courseId: string } } 38 | ) { 39 | try { 40 | const session = await getSession(); 41 | const userId = session?.user.uid; 42 | 43 | if (!userId) { 44 | return NextResponse.json({ message: "Unauthorized" }, { status: 403 }); 45 | } 46 | const body = await req.json(); 47 | const commentData = body; 48 | if (userId != commentData.userId) { 49 | return NextResponse.json( 50 | { message: "You do not have permission to edit this comment" }, 51 | { status: 500 } 52 | ); 53 | } 54 | const existedComment = await db.comment.findFirst({ 55 | where: { 56 | id: commentData.commentId, 57 | }, 58 | }); 59 | if (!existedComment) { 60 | return NextResponse.json( 61 | { message: "Comment doesn't existed in DB" }, 62 | { status: 500 } 63 | ); 64 | } 65 | const newComment = await db.comment.update({ 66 | where: { 67 | id: commentData.commentId, 68 | }, 69 | data: { 70 | content: commentData.content, 71 | }, 72 | }); 73 | if (newComment) 74 | return NextResponse.json({ message: "Comment edited" }, { status: 200 }); 75 | return NextResponse.json( 76 | { message: "Can not edit comment" }, 77 | { status: 500 } 78 | ); 79 | } catch (error) { 80 | console.log("FORUM_COMMENT", error); 81 | return NextResponse.json({ error: "Internal Error" }, { status: 500 }); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /app/api/courses/[courseId]/enroll/route.ts: -------------------------------------------------------------------------------- 1 | import { getSession } from "@/lib/auth"; 2 | import { NextResponse } from "next/server"; 3 | import { db } from "@/lib/db"; 4 | 5 | export async function POST( 6 | req: Request, 7 | { params }: { params: { courseId: string } } 8 | ) { 9 | try{ 10 | const session = await getSession(); 11 | const userId = session?.user.uid; 12 | 13 | if (!userId) { 14 | return new NextResponse("Unauthorized", { status: 401 }); 15 | } 16 | 17 | const enrollResult = await db.enroll.create({ 18 | data: { 19 | userId: userId, 20 | courseId: params.courseId 21 | } 22 | }); 23 | 24 | return NextResponse.json(enrollResult); 25 | 26 | } catch (error) { 27 | console.log("COURSE_ENROLL", error); 28 | return new NextResponse("Internal Error", { status: 500 }); 29 | } 30 | } -------------------------------------------------------------------------------- /app/api/courses/[courseId]/publish/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { db } from "@/lib/db"; 3 | import { getSession } from "@/lib/auth"; 4 | 5 | export async function PATCH( 6 | req: Request, 7 | { params }: { params: { courseId: string } } 8 | ) { 9 | try { 10 | const session = await getSession(); 11 | const userId = session?.user.uid; 12 | 13 | if (!userId) { 14 | return new NextResponse("Unauthorized", { status: 401 }); 15 | } 16 | 17 | const course = await db.course.findUnique({ 18 | where: { 19 | id: params.courseId, 20 | userId, 21 | }, 22 | include: { 23 | chapters: { 24 | include: { 25 | muxData: true, 26 | } 27 | }, 28 | categories: { 29 | include: { 30 | category: true, 31 | } 32 | }} 33 | }); 34 | 35 | if (!course) { 36 | return new NextResponse("Not found", { status: 404 }); 37 | } 38 | 39 | const hasPublishedChapters = course.chapters.some((chapter) => chapter.isPublished); 40 | const hasCategories = (course.categories.length > 0); 41 | 42 | if (!course.title || !course.description || !course.imageUrl || !hasPublishedChapters || !hasCategories) { 43 | return new NextResponse("Missing required fields", { status: 401 }); 44 | } 45 | 46 | const publishedCourse = await db.course.update({ 47 | where: { 48 | id: params.courseId, 49 | userId, 50 | }, 51 | data: { 52 | isPublished: true, 53 | } 54 | }); 55 | 56 | return NextResponse.json(publishedCourse); 57 | 58 | } catch (error) { 59 | console.log("[QLKHGV_ADD]", error); 60 | return new NextResponse("Internal error", { status: 500 }); 61 | } 62 | } -------------------------------------------------------------------------------- /app/api/courses/[courseId]/route.ts: -------------------------------------------------------------------------------- 1 | import { MuxData } from '@prisma/client'; 2 | import { NextResponse } from "next/server"; 3 | import Mux from "@mux/mux-node"; 4 | import { db } from "@/lib/db"; 5 | import { getSession } from '@/lib/auth'; 6 | import { isAdminDB, isAdminSession } from '@/lib/admin'; 7 | import { isTeacherDB, isTeacherSession } from '@/lib/teacher'; 8 | 9 | const { Video } = new Mux( 10 | process.env.MUX_TOKEN_ID!, 11 | process.env.MUX_TOKEN_SECRET! 12 | ); 13 | 14 | export async function DELETE( 15 | req: Request, 16 | { params }: { params: { courseId: string } } 17 | ) { 18 | try { 19 | const session = await getSession(); 20 | const userId = session?.user.uid; 21 | const role = session?.user.role; 22 | var isAuthorized = isAdminSession(role) || isTeacherSession(role); 23 | if (!isAuthorized) { 24 | isAuthorized = await isAdminDB(userId) || await isTeacherDB(userId); 25 | } 26 | 27 | if (!userId || !isAuthorized) { 28 | return new NextResponse("Unauthorized", { status: 401 }); 29 | } 30 | 31 | const course = await db.course.findUnique({ 32 | where: { 33 | id: params.courseId, 34 | userId: userId, 35 | }, 36 | include: { 37 | chapters: { 38 | include: { 39 | muxData: true, 40 | } 41 | } 42 | } 43 | }); 44 | 45 | if (!course) { 46 | return new NextResponse("Not found", { status: 404 }); 47 | } 48 | 49 | for (const chapter of course.chapters) { 50 | if (chapter.muxData?.assetId) { 51 | await Video.Assets.del(chapter.muxData.assetId); 52 | } 53 | } 54 | 55 | const deletedCourse = await db.course.delete({ 56 | where: { 57 | id: params.courseId, 58 | }, 59 | }); 60 | 61 | return NextResponse.json(deletedCourse); 62 | 63 | } catch (error) { 64 | console.log("[QLCHGV_DEL]", error); 65 | return new NextResponse("Internal error", { status: 500 }); 66 | } 67 | } 68 | 69 | export async function PATCH( 70 | req: Request, 71 | { params }: { params: { courseId: string } } 72 | ) { 73 | try { 74 | const session = await getSession(); 75 | const userId = session?.user.uid; 76 | const role = session?.user.role; 77 | const values = await req.json(); 78 | var isAuthorized = isAdminSession(role) || isTeacherSession(role); 79 | if (!isAuthorized) { 80 | isAuthorized = await isAdminDB(userId) || await isTeacherDB(userId); 81 | } 82 | 83 | if (!userId || !isAuthorized) { 84 | return new NextResponse("Unauthorized", { status: 401 }); 85 | } 86 | 87 | const course = await db.course.update({ 88 | where: { 89 | id: params.courseId, 90 | userId, 91 | }, 92 | data: { 93 | ...values, 94 | }, 95 | }); 96 | 97 | return NextResponse.json(course); 98 | } catch (error) { 99 | console.log("[COURSE_ID]", error); 100 | return new NextResponse("Internal Error", { status: 500 }); 101 | } 102 | } -------------------------------------------------------------------------------- /app/api/courses/[courseId]/unpublish/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { db } from "@/lib/db"; 3 | import { getSession } from "@/lib/auth"; 4 | 5 | export async function PATCH( 6 | req: Request, 7 | { params }: { params: { courseId: string } } 8 | ) { 9 | try { 10 | const session = await getSession(); 11 | const userId = session?.user.uid; 12 | 13 | if (!userId) { 14 | return new NextResponse("Unauthorized", { status: 401 }); 15 | } 16 | 17 | const course = await db.course.findUnique({ 18 | where: { 19 | id: params.courseId, 20 | userId, 21 | } 22 | }); 23 | 24 | if (!course) { 25 | return new NextResponse("Not found", { status: 404 }); 26 | } 27 | 28 | const unPublishedCourse = await db.course.update({ 29 | where: { 30 | id: params.courseId, 31 | userId, 32 | }, 33 | data: { 34 | isPublished: false, 35 | } 36 | }); 37 | 38 | return NextResponse.json(unPublishedCourse); 39 | 40 | } catch (error) { 41 | console.log("[QLKHGV_ADD]", error); 42 | return new NextResponse("Internal error", { status: 500 }); 43 | } 44 | } -------------------------------------------------------------------------------- /app/api/courses/route.ts: -------------------------------------------------------------------------------- 1 | import { isAdminSession, isAdminDB } from "@/lib/admin"; 2 | import { getSession } from "@/lib/auth"; 3 | import { db } from "@/lib/db"; 4 | import { isTeacherSession, isTeacherDB } from "@/lib/teacher"; 5 | import { NextResponse } from "next/server"; 6 | 7 | export async function POST(req: Request) { 8 | try { 9 | const session = await getSession(); 10 | const userId = session?.user.uid; 11 | const role = session?.user.role; 12 | 13 | const { title } = await req.json(); 14 | var isAuthorized = isAdminSession(role) || isTeacherSession(role); 15 | if (!isAuthorized) { 16 | isAuthorized = await isAdminDB(userId) || await isTeacherDB(userId); 17 | } 18 | 19 | if (!userId || !isAuthorized) { 20 | return new NextResponse("Unauthorized", { status: 401 }); 21 | } 22 | 23 | const course = await db.course.create({ 24 | data: { 25 | userId, 26 | title 27 | } 28 | }); 29 | 30 | return NextResponse.json(course); 31 | 32 | } catch (error) { 33 | console.log("[COURSE]", error); 34 | return new NextResponse("Internal Error", { status: 500 }) 35 | } 36 | } -------------------------------------------------------------------------------- /app/api/enroll/[enrollId]/route.ts: -------------------------------------------------------------------------------- 1 | import { isAdminDB, isAdminSession } from "@/lib/admin"; 2 | import { getSession } from "@/lib/auth"; 3 | import { NextResponse } from "next/server"; 4 | import { db } from "@/lib/db"; 5 | import { isTeacherDB, isTeacherSession } from "@/lib/teacher"; 6 | 7 | export async function POST( 8 | req: Request, 9 | { params }: { params: { enrollId: string } } 10 | ) { 11 | try { 12 | const session = await getSession(); 13 | const userId = session?.user.uid; 14 | const role = session?.user.role; 15 | 16 | var isAuthorized = isAdminSession(role) || isTeacherSession(role); 17 | if (!isAuthorized) { 18 | isAuthorized = await isAdminDB(userId) || await isTeacherDB(userId); 19 | } 20 | 21 | if (!userId || !isAuthorized) { 22 | return new NextResponse("Unauthorized", { status: 401 }); 23 | } 24 | 25 | const updatedEnroll = await db.enroll.update({ 26 | where: { 27 | id: params.enrollId, 28 | }, 29 | data: { 30 | isAccepted: true, 31 | }, 32 | }); 33 | 34 | return NextResponse.json(updatedEnroll); 35 | 36 | } catch (error) { 37 | console.log("ENROLL_APPROVE", error); 38 | return new NextResponse("Internal Error", { status: 500 }); 39 | } 40 | } -------------------------------------------------------------------------------- /app/api/uploadthing/core.ts: -------------------------------------------------------------------------------- 1 | import { getSession } from "@/lib/auth"; 2 | import { createUploadthing, type FileRouter } from "uploadthing/next"; 3 | 4 | const f = createUploadthing(); 5 | 6 | const handleAuth = async () => { 7 | const session = await getSession(); 8 | const userId = session?.user.uid; 9 | 10 | if (!userId) throw new Error("Unauthorized"); 11 | return { userId }; 12 | } 13 | 14 | // FileRouter for your app, can contain multiple FileRoutes 15 | export const ourFileRouter = { 16 | courseImage: f({ image: { maxFileSize: "16MB", maxFileCount: 1 } }) 17 | .middleware(() => handleAuth()) 18 | .onUploadComplete(() => {}), 19 | courseAttachment: f(["text", "image", "video", "audio", "pdf"]) 20 | .middleware(() => handleAuth()) 21 | .onUploadComplete(() => {}), 22 | chapterVideo: f({ video: { maxFileCount: 1, maxFileSize: "512GB" } }) 23 | .middleware(() => handleAuth()) 24 | .onUploadComplete(() => {}), 25 | userImage: f({ image: { maxFileSize: "2MB", maxFileCount: 1 } }) 26 | .middleware(() => handleAuth()) 27 | .onUploadComplete(() => {}), 28 | } satisfies FileRouter; 29 | 30 | export type OurFileRouter = typeof ourFileRouter; 31 | -------------------------------------------------------------------------------- /app/api/uploadthing/route.ts: -------------------------------------------------------------------------------- 1 | import { createNextRouteHandler } from "uploadthing/next"; 2 | 3 | import { ourFileRouter } from "./core"; 4 | 5 | // Export routes for Next App Router 6 | export const { GET, POST } = createNextRouteHandler({ 7 | router: ourFileRouter, 8 | }); 9 | -------------------------------------------------------------------------------- /app/api/users/change/image/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | import { db } from "@/lib/db"; 4 | import { getSession } from "@/lib/auth"; 5 | import { utapi } from "@/lib/utils"; 6 | export async function PATCH(req: Request) { 7 | try { 8 | const session = await getSession(); 9 | const userId = session?.user.uid; 10 | 11 | const url = await req.json(); 12 | // Have to modify database to store fileKey 13 | // if (url.preKey != "") { 14 | // utapi.deleteFiles(url.preKey); 15 | // console.log("Old image deleted!"); 16 | // } 17 | if (!userId) { 18 | return NextResponse.json({ message: "Unauthorize" }, { status: 401 }); 19 | } 20 | const newImage = await db.user.update({ 21 | where: { 22 | id: userId, 23 | }, 24 | data: { 25 | image: url.imageUrl, 26 | }, 27 | }); 28 | if (newImage) 29 | return NextResponse.json({ message: "Changed image" }, { status: 200 }); 30 | return NextResponse.json( 31 | { message: "Can not change user image" }, 32 | { status: 500 } 33 | ); 34 | } catch (error) { 35 | console.log("[COURSE_CHAPTER_ID]", error); 36 | return NextResponse.json({ message: "Internal Error" }, { status: 500 }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/api/users/change/password/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import bcrypt from "bcrypt"; 3 | import { db } from "@/lib/db"; 4 | 5 | export async function POST(req: Request) { 6 | try { 7 | const body = await req.json(); 8 | const userData = body.formData; 9 | if (!userData?.email || !userData?.current || !userData?.now) { 10 | return NextResponse.json({ message: "All field are required." }, { status: 400 }); 11 | } 12 | 13 | const existUser = await db.user.findUnique({ 14 | where:{ 15 | email: userData.email, 16 | }, 17 | }) 18 | 19 | if(!existUser) { 20 | return NextResponse.json({message: "User not exist"}, {status: 409}); 21 | } 22 | if (existUser.password != null) { 23 | const match = await bcrypt.compare(userData?.current!, existUser.password); 24 | if (match && userData.now != '') { 25 | const newPass = await bcrypt.hash(userData.now, 10); 26 | console.log(newPass); 27 | const setNewPass = await db.user.update({ 28 | where:{ 29 | email: userData.email, 30 | }, 31 | data: { 32 | password: newPass, 33 | }, 34 | }); 35 | if (setNewPass) return NextResponse.json({message: "Password changed."}, {status: 201}); 36 | return NextResponse.json({message: "Can not change password"}, {status: 500}); 37 | } 38 | return NextResponse.json({message: "Current password don't match or new password is empty"}, {status: 400}); 39 | } 40 | return NextResponse.json({message: "User does not have password because you sign in with Github or Google"}, {status: 400}); 41 | 42 | } catch (error) { 43 | console.log(error); 44 | return NextResponse.json({ message: "Error", error }, { status: 500 }); 45 | } 46 | } -------------------------------------------------------------------------------- /app/api/users/change/role/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import bcrypt from "bcrypt"; 3 | import { db } from "@/lib/db"; 4 | import { getSession } from "@/lib/auth"; 5 | import { isAdminSession } from "@/lib/admin"; 6 | import { PRIVILEGES, ROLES, TYPE_CHANGE } from "@/lib/constant"; 7 | import { setCharAt } from "@/lib/utils"; 8 | 9 | 10 | export async function POST(req: Request) { 11 | try { 12 | const body = await req.json(); 13 | const session = await getSession(); 14 | if (!body?.ids || body?.ids.length === 0) { 15 | return NextResponse.json({ message: "Select an user to change." }, { status: 400 }); 16 | } 17 | if (!isAdminSession(session?.user.role)) { 18 | return NextResponse.json({message: "YOU ARE NOT ADMINISTRATOR"}, {status: 403}); 19 | } 20 | if (body?.type == TYPE_CHANGE["DELETE"]) { 21 | const deleteUsers = await db.user.deleteMany({ 22 | where: { 23 | id: { 24 | in: body?.ids, 25 | }, 26 | } 27 | }); 28 | if (deleteUsers) return NextResponse.json({message: "User(s) deleted"}, {status: 200}); 29 | else return NextResponse.json({message: "Delete operation failed"}, {status: 500}); 30 | } 31 | const selectedUsers = await db.user.findMany({ 32 | where: { 33 | id: { 34 | in: body?.ids, 35 | } 36 | }, 37 | select: { 38 | id: true, 39 | role: true, 40 | } 41 | }) 42 | 43 | if (body?.type == TYPE_CHANGE["ADMIN"]) { 44 | if (body?.ids.length > 1) { 45 | return NextResponse.json({message: "Please choose one user per admin privileges change."}, {status: 400}) 46 | } 47 | selectedUsers.forEach(async user => { 48 | var newRole = "0,2"; 49 | if (user.role) newRole = setCharAt(user.role, PRIVILEGES["ADMIN"], user.role[PRIVILEGES["ADMIN"]] == ROLES["ADMIN"] ? ROLES["NOT_ADMIN"] : ROLES["ADMIN"]) ?? ""; 50 | const updateUsers = await db.user.update({ 51 | where: { 52 | id: user.id, 53 | }, 54 | data: { 55 | role: newRole, 56 | }, 57 | }) 58 | if (!updateUsers) return NextResponse.json({message: `Cannot change user ${user.id} role. Operation aborted`}, {status: 500}); 59 | }); 60 | return NextResponse.json({message: "Role changed"}, {status: 200}); 61 | } 62 | 63 | selectedUsers.forEach(async user => { 64 | var newRole = "0,2"; 65 | if (user.role) newRole = setCharAt(user.role, PRIVILEGES["OTHERS"], user.role[PRIVILEGES["OTHERS"]] == ROLES["TEACHER"] ? ROLES["USER"] : ROLES["TEACHER"]) ?? ""; 66 | const updateUsers = await db.user.update({ 67 | where: { 68 | id: user.id, 69 | }, 70 | data: { 71 | role: newRole, 72 | }, 73 | }) 74 | if (!updateUsers) return NextResponse.json({message: `Cannot change user ${user.id} role. Operation aborted`}, {status: 500}); 75 | }); 76 | return NextResponse.json({message: "Role changed"}, {status: 200}); 77 | 78 | } catch (error) { 79 | return NextResponse.json({ message: "Error", error }, { status: 500 }); 80 | } 81 | } -------------------------------------------------------------------------------- /app/auth/_components/logo.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | 4 | export const Logo = () => { 5 | return ( 6 | logo 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /app/auth/_components/navbar.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import localFont from "next/font/local"; 3 | import { cn } from "@/lib/utils"; 4 | import { Button } from "@/components/ui/button"; 5 | 6 | const headingFont = localFont({ 7 | src: "../../../public/fonts/font.woff2", 8 | }); 9 | 10 | export const Navbar = () => { 11 | return ( 12 |
18 |
19 |
20 |
21 | 22 | 28 | 29 | 30 | 36 | 37 | 38 | 44 | 45 |
46 |
47 |
48 |
49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /app/auth/register/page.tsx: -------------------------------------------------------------------------------- 1 | import UserForm from "./_components/register-form"; 2 | import { Navbar } from "../_components/navbar"; 3 | import localFont from "next/font/local"; 4 | import { cn } from "@/lib/utils"; 5 | import backgroundImage from "@/public/img/background.jpg"; 6 | import { Metadata } from "next"; 7 | 8 | const headingFont = localFont({ 9 | src: "../../../public/fonts/font.woff2", 10 | }); 11 | 12 | export const metadata: Metadata = { 13 | title: "Register | CourseX", 14 | }; 15 | 16 | export default async function LandingPage() { 17 | return ( 18 |
24 | 25 | 26 |
27 |
33 |
34 | The Education Platform 35 |
36 |
For The Future
37 |
38 | Join Right Now 39 |
40 |
41 |
42 | 43 | {/* Login Form */} 44 |
45 | 46 |
47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /app/auth/signin/page.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import LoginForm from "./_components/form"; 3 | import { Navbar } from "../_components/navbar"; 4 | import localFont from "next/font/local"; 5 | import backgroundImage from "@/public/img/solid.jpg"; 6 | import { Metadata } from "next"; 7 | 8 | const headingFont = localFont({ 9 | src: "../../../public/fonts/font.woff2", 10 | }); 11 | 12 | export const metadata: Metadata = { 13 | title: "Sign in - CourseX", 14 | }; 15 | 16 | export default async function LandingPage() { 17 | return ( 18 |
24 | 25 | 26 |
27 |
33 |
34 | The Education Platform 35 |
36 |
37 | For The Future 38 |
39 |
40 | Join Right Now 41 |
42 |
43 |
44 | 45 | {/* Login Form */} 46 |
47 | 48 |
49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /app/denied/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { Button } from '@/components/ui/button'; 3 | import Link from 'next/link'; 4 | import React from 'react' 5 | 6 | const Denied = () => { 7 | return ( 8 |
9 |

403

10 |
11 |

You dont have permission to access this page

12 |
13 | 16 |
17 | ) 18 | } 19 | 20 | export default Denied; -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, 6 | body, 7 | :root { 8 | height: 100%; 9 | } 10 | 11 | body { 12 | /* ... */ 13 | font-family: 'Inter'; 14 | } 15 | 16 | @keyframes fadeIn { 17 | from { 18 | opacity: 0; 19 | } 20 | to { 21 | opacity: 1; 22 | } 23 | } 24 | 25 | .animate-fade-in { 26 | animation: fadeIn 0.75s ease-in-out; 27 | } 28 | 29 | @media (max-height: 400px) { 30 | .round-3xl { 31 | transform: scale(0.8); 32 | } 33 | } 34 | 35 | @media (min-height: 401px) and (max-height: 600px) { 36 | .round-3xl { 37 | transform: scale(0.9); 38 | } 39 | } 40 | 41 | @layer base { 42 | :root { 43 | --background: 0 0% 100%; 44 | --foreground: 222.2 84% 4.9%; 45 | 46 | --card: 0 0% 100%; 47 | --card-foreground: 222.2 84% 4.9%; 48 | 49 | --popover: 0 0% 100%; 50 | --popover-foreground: 222.2 84% 4.9%; 51 | 52 | --primary: 222.2 47.4% 11.2%; 53 | --primary-foreground: 210 40% 98%; 54 | 55 | --secondary: 210 40% 96.1%; 56 | --secondary-foreground: 222.2 47.4% 11.2%; 57 | 58 | --muted: 210 40% 96.1%; 59 | --muted-foreground: 215.4 16.3% 46.9%; 60 | 61 | --accent: 210 40% 96.1%; 62 | --accent-foreground: 222.2 47.4% 11.2%; 63 | 64 | --destructive: 0 84.2% 60.2%; 65 | --destructive-foreground: 210 40% 98%; 66 | 67 | --border: 214.3 31.8% 91.4%; 68 | --input: 214.3 31.8% 91.4%; 69 | --ring: 222.2 84% 4.9%; 70 | 71 | --radius: 0.5rem; 72 | } 73 | 74 | .dark { 75 | --background: 222.2 84% 4.9%; 76 | --foreground: 210 40% 98%; 77 | 78 | --card: 222.2 84% 4.9%; 79 | --card-foreground: 210 40% 98%; 80 | 81 | --popover: 222.2 84% 4.9%; 82 | --popover-foreground: 210 40% 98%; 83 | 84 | --primary: 210 40% 98%; 85 | --primary-foreground: 222.2 47.4% 11.2%; 86 | 87 | --secondary: 217.2 32.6% 17.5%; 88 | --secondary-foreground: 210 40% 98%; 89 | 90 | --muted: 217.2 32.6% 17.5%; 91 | --muted-foreground: 215 20.2% 65.1%; 92 | 93 | --accent: 217.2 32.6% 17.5%; 94 | --accent-foreground: 210 40% 98%; 95 | 96 | --destructive: 0 62.8% 30.6%; 97 | --destructive-foreground: 210 40% 98%; 98 | 99 | --border: 217.2 32.6% 17.5%; 100 | --input: 217.2 32.6% 17.5%; 101 | --ring: 212.7 26.8% 83.9%; 102 | } 103 | } 104 | 105 | @layer base { 106 | * { 107 | @apply border-border; 108 | } 109 | body { 110 | @apply bg-background text-foreground; 111 | } 112 | } 113 | 114 | .ql-container { 115 | font-size: 16px !important; 116 | } 117 | 118 | @import "~@uploadthing/react/styles.css" -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import './globals.css' 2 | import type { Metadata } from 'next' 3 | import { Inter } from 'next/font/google' 4 | import { ToasterProvider } from '@/components/providers/toaster-provider' 5 | import { ConfettiProvider } from '@/components/providers/confetti-provider' 6 | import { ThemeProvider } from '@/components/providers/theme-provider' 7 | import SessionProvider from '@/components/providers/session-provider'; 8 | import { getSession } from '@/lib/auth'; 9 | 10 | const inter = Inter({ 11 | subsets: ['vietnamese'] 12 | }) 13 | 14 | export const metadata: Metadata = { 15 | title: 'CourseX', 16 | } 17 | 18 | export default async function RootLayout({ 19 | children, 20 | }: { 21 | children: React.ReactNode 22 | }) { 23 | const session = await getSession() 24 | return ( 25 | 26 | 27 | 28 | 29 | 30 | 31 | {children} 32 | 33 | 34 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /app/profile/_components/navbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Logo } from "../../(dashboard)/_components/logo"; 3 | import { ModeToggle } from "@/components/mode-toggle"; 4 | import { UserButton } from "@/components/user-button"; 5 | 6 | export const Navbar = () => { 7 | return ( 8 |
9 | 10 |
11 |
12 | 13 |
14 |
15 | 16 |
17 |
18 |
19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /app/profile/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Navbar } from "./_components/navbar"; 2 | 3 | const ProfileLayout = ({ children }: { children: React.ReactNode }) => { 4 | return ( 5 |
6 |
7 | 8 |
9 |
{children}
10 |
11 | ); 12 | }; 13 | 14 | export default ProfileLayout; 15 | -------------------------------------------------------------------------------- /app/profile/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | import { getSession } from "@/lib/auth"; 3 | import { Profile } from "./_components/profile"; 4 | import toast from "react-hot-toast"; 5 | import { db } from "@/lib/db"; 6 | 7 | export default async function ProfilePage() { 8 | const session = await getSession(); 9 | 10 | if (!session) { 11 | redirect("/"); 12 | } 13 | 14 | return ( 15 |
16 | 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } -------------------------------------------------------------------------------- /components/banner.tsx: -------------------------------------------------------------------------------- 1 | import { AlertTriangle, CheckCircleIcon } from "lucide-react"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | import { cn } from "@/lib/utils"; 4 | 5 | const bannerVariants = cva( 6 | "border text-center p-4 text-sm flex items-center w-full", 7 | { 8 | variants: { 9 | variant: { 10 | warning: "bg-yellow-200/80 border-yellow-30 text-primary", 11 | success: "bg-emerald-700 border-emerald-800 text-secondary", 12 | }, 13 | }, 14 | defaultVariants: { 15 | variant: "warning", 16 | }, 17 | } 18 | ); 19 | 20 | interface BannerProps extends VariantProps { 21 | label: string; 22 | } 23 | 24 | const iconMap = { 25 | warning: AlertTriangle, 26 | success: CheckCircleIcon, 27 | } 28 | 29 | export const Banner = ({ 30 | label, 31 | variant, 32 | }: BannerProps) => { 33 | const Icon = iconMap[variant || "warning"] 34 | 35 | return ( 36 |
37 | 38 | {label} 39 |
40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /components/course-card.tsx: -------------------------------------------------------------------------------- 1 | import { Category } from "@prisma/client"; 2 | import Image from "next/image"; 3 | import Link from "next/link"; 4 | import { BookOpen } from "lucide-react"; 5 | import { Badge } from "@/components/ui/badge"; 6 | import { cn } from "@/lib/utils"; 7 | 8 | import { IconBadge } from "@/components/icon-badge"; 9 | import { CourseProgress } from "@/components/course-progress"; 10 | 11 | interface CourseCardProps { 12 | id: string; 13 | title: string; 14 | imageUrl: string; 15 | chaptersLength: number; 16 | progress: number | null; 17 | categories: Category[]; 18 | } 19 | 20 | export const CourseCard = ({ 21 | id, 22 | title, 23 | imageUrl, 24 | chaptersLength, 25 | progress, 26 | categories, 27 | }: CourseCardProps) => { 28 | return ( 29 |
30 |
31 | 32 | {title} 33 | 34 |
35 |
36 |
37 | {title} 38 |
39 |
40 | {categories.map((category) => ( 41 | 42 | 43 | {category.name} 44 | 45 | 46 | ))} 47 |
48 |
49 |
50 | 51 | 52 | {chaptersLength} {chaptersLength === 1 ? "Chapter" : "Chapters"} 53 | 54 |
55 |
56 | {progress !== null ? ( 57 | 62 | ) : ( 63 |

64 | )} 65 |
66 |
67 | ); 68 | }; 69 | -------------------------------------------------------------------------------- /components/course-progress.tsx: -------------------------------------------------------------------------------- 1 | import { Progress } from "@/components/ui/progress"; 2 | import { cn } from "@/lib/utils"; 3 | 4 | interface CourseProgressProps { 5 | value: number; 6 | variant?: "default" | "success", 7 | size?: "default" | "sm"; 8 | }; 9 | 10 | const colorByVariant = { 11 | default: "text-sky-700", 12 | success: "text-emerald-700", 13 | } 14 | 15 | const sizeByVariant = { 16 | default: "text-sm", 17 | sm: "text-xs", 18 | } 19 | 20 | export const CourseProgress = ({ 21 | value, 22 | variant, 23 | size, 24 | }: CourseProgressProps) => { 25 | return ( 26 |
27 | 32 |

37 | {Math.round(value)}% Complete 38 |

39 |
40 | ) 41 | } -------------------------------------------------------------------------------- /components/courses-list.tsx: -------------------------------------------------------------------------------- 1 | import { Category, Course } from "@prisma/client"; 2 | 3 | import { CourseCard } from "@/components/course-card"; 4 | 5 | type CourseWithProgressWithCategory = Course & { 6 | categories: Category[]; 7 | chapters: { id: string }[]; 8 | progress: number | null; 9 | }; 10 | 11 | interface CoursesListProps { 12 | items: CourseWithProgressWithCategory[]; 13 | } 14 | 15 | export const CoursesList = ({ 16 | items 17 | }: CoursesListProps) => { 18 | return ( 19 |
20 |
21 | {items.map((item) => ( 22 | 31 | ))} 32 |
33 | {items.length === 0 && ( 34 |
35 | No courses found 36 |
37 | )} 38 |
39 | ) 40 | } -------------------------------------------------------------------------------- /components/editor.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import dynamic from "next/dynamic"; 4 | import { useMemo } from "react"; 5 | import "react-quill/dist/quill.snow.css"; 6 | 7 | interface EditorProps { 8 | onChange: (value: string) => void; 9 | value: string; 10 | } 11 | 12 | export const Editor = ({ 13 | onChange, 14 | value, 15 | }: EditorProps) => { 16 | const ReactQuill = useMemo(() => dynamic(() => import("react-quill"), { ssr: false }), []) 17 | 18 | return ( 19 |
20 | 25 |
26 | ); 27 | }; -------------------------------------------------------------------------------- /components/file-upload.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import toast from "react-hot-toast"; 3 | 4 | import { UploadDropzone } from "@/lib/uploadthing" 5 | import { ourFileRouter } from "@/app/api/uploadthing/core" 6 | 7 | interface FileUploadProps { 8 | onChange: (url?: string, filename?:string) => void; 9 | endpoint: keyof typeof ourFileRouter; 10 | }; 11 | 12 | export const FileUpload = ({ 13 | onChange, 14 | endpoint 15 | }: FileUploadProps) => { 16 | return ( 17 | { 20 | onChange(res?.[0].url, res?.[0].fileName); 21 | }} 22 | onUploadError={(error: Error) => { 23 | toast.error(`${error?.message}`); 24 | }} 25 | /> 26 | ) 27 | } -------------------------------------------------------------------------------- /components/icon-badge.tsx: -------------------------------------------------------------------------------- 1 | import { LucideIcon } from "lucide-react"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const backgroundVariants = cva( 7 | "rounded-full flex items-center justify-center", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-sky-100", 12 | success: "bg-emerald-100", 13 | }, 14 | size: { 15 | default: "p-2", 16 | sm: "p-1", 17 | } 18 | }, 19 | defaultVariants: { 20 | variant: "default", 21 | size: "default", 22 | } 23 | } 24 | ); 25 | 26 | const iconVariants = cva( 27 | "", 28 | { 29 | variants: { 30 | variant: { 31 | default: "text-sky-700", 32 | success: "text-emerald-700", 33 | }, 34 | size: { 35 | default: "h-8 w-8", 36 | sm: "h-4 w-4" 37 | }, 38 | }, 39 | defaultVariants: { 40 | variant: "default", 41 | size: "default", 42 | } 43 | } 44 | ); 45 | 46 | type BackgroundVariantsProps = VariantProps; 47 | type IconVariantsProps = VariantProps; 48 | 49 | interface IconBadgeProps extends BackgroundVariantsProps, IconVariantsProps { 50 | icon: LucideIcon; 51 | }; 52 | 53 | export const IconBadge = ({ 54 | icon: Icon, 55 | variant, 56 | size, 57 | }: IconBadgeProps) => { 58 | return ( 59 |
60 | 61 |
62 | ) 63 | }; -------------------------------------------------------------------------------- /components/modals/confirm-modal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | AlertDialog, 5 | AlertDialogAction, 6 | AlertDialogCancel, 7 | AlertDialogContent, 8 | AlertDialogDescription, 9 | AlertDialogFooter, 10 | AlertDialogHeader, 11 | AlertDialogTitle, 12 | AlertDialogTrigger, 13 | } from "@/components/ui/alert-dialog"; 14 | 15 | interface ConfirmModalProps { 16 | children: React.ReactNode; 17 | onConfirm: () => void; 18 | } 19 | 20 | export const ConfirmModal = ({ children, onConfirm }: ConfirmModalProps) => { 21 | return ( 22 | 23 | {children} 24 | 25 | 26 | Confirm deleting? 27 | 28 | This action cannot be undone. 29 | 30 | 31 | 32 | Cancel 33 | Continue 34 | 35 | 36 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /components/mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { Moon, Sun } from "lucide-react"; 5 | import { useTheme } from "next-themes"; 6 | 7 | import { Button } from "@/components/ui/button"; 8 | import { 9 | DropdownMenu, 10 | DropdownMenuContent, 11 | DropdownMenuItem, 12 | DropdownMenuTrigger, 13 | } from "@/components/ui/dropdown-menu"; 14 | 15 | export function ModeToggle() { 16 | const { setTheme } = useTheme(); 17 | 18 | return ( 19 | 20 | 21 | 26 | 27 | 28 | setTheme("light")}> 29 | Light 30 | 31 | setTheme("dark")}> 32 | Dark 33 | 34 | setTheme("system")}> 35 | System 36 | 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /components/navbar-routes.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useSession, signOut } from "next-auth/react"; 4 | import { usePathname } from "next/navigation"; 5 | import { Button } from "@/components/ui/button"; 6 | import { LogOut } from "lucide-react"; 7 | import Link from "next/link"; 8 | import { SearchInput } from "./search-input"; 9 | import { ModeToggle } from "./mode-toggle"; 10 | import { UserButton } from "@/components/user-button"; 11 | import { PRIVILEGES, ROLES } from "@/lib/constant"; 12 | import { isAdminSession } from "@/lib/admin"; 13 | import { isTeacherSession } from "@/lib/teacher"; 14 | 15 | export const NavbarRoutes = () => { 16 | const pathname = usePathname(); 17 | const { data: session } = useSession(); 18 | 19 | const role = session?.user.role; 20 | var isAdmin = isAdminSession(role); 21 | var isTeacher = isTeacherSession(role); 22 | 23 | const isTeacherPage = pathname?.startsWith("/teacher"); 24 | const isCoursePage = pathname?.startsWith("/courses"); 25 | const isSearchPage = pathname === "/search"; 26 | const isAdminPage = pathname?.startsWith("/admin"); 27 | return ( 28 | <> 29 | {isSearchPage && ( 30 |
31 | 32 |
33 | )} 34 |
35 | {isTeacherPage || isAdminPage || isCoursePage ? ( 36 | 37 | 40 | 41 | ) : null} 42 | {isTeacher && !isTeacherPage && !isCoursePage ? ( 43 | 44 | 47 | 48 | ) : null} 49 | {isAdmin && !isAdminPage && !isCoursePage ? ( 50 | 51 | 54 | 55 | ) : null} 56 |
57 | 58 |
59 |
60 | 61 |
62 |
63 | 64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /components/preview.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import dynamic from "next/dynamic"; 4 | import { useMemo } from "react"; 5 | import "react-quill/dist/quill.bubble.css"; 6 | 7 | interface PreviewProps { 8 | value: string; 9 | } 10 | 11 | export const Preview = ({ 12 | value, 13 | }: PreviewProps) => { 14 | const ReactQuill = useMemo(() => dynamic(() => import("react-quill"), { ssr: false }), []) 15 | 16 | return ( 17 | 22 | ); 23 | }; -------------------------------------------------------------------------------- /components/providers/confetti-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import ReactConfetti from "react-confetti"; 4 | 5 | import { useConfettiStore } from "@/hooks/use-confetti-store"; 6 | 7 | export const ConfettiProvider = () => { 8 | const confetti = useConfettiStore(); 9 | 10 | if (!confetti.isOpen) return null; 11 | 12 | return ( 13 | { 19 | confetti.onClose(); 20 | }} 21 | /> 22 | ) 23 | } -------------------------------------------------------------------------------- /components/providers/session-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import {SessionProvider} from "next-auth/react"; 3 | export default SessionProvider; -------------------------------------------------------------------------------- /components/providers/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 5 | import { type ThemeProviderProps } from "next-themes/dist/types"; 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children}; 9 | } 10 | -------------------------------------------------------------------------------- /components/providers/toaster-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Toaster } from "react-hot-toast"; 4 | 5 | export const ToasterProvider = () => { 6 | return ; 7 | }; -------------------------------------------------------------------------------- /components/search-input.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Search } from "lucide-react"; 4 | import { Input } from "./ui/input"; 5 | import { useEffect, useState } from "react"; 6 | import { useDebounce } from "@/hooks/use-debounce"; 7 | import { usePathname, useRouter, useSearchParams } from "next/navigation"; 8 | import qs from "query-string"; 9 | 10 | export const SearchInput = () => { 11 | const [value, setValue] = useState(""); 12 | const debouncedValue = useDebounce(value); 13 | 14 | const searchParams = useSearchParams(); 15 | const router = useRouter(); 16 | const pathname = usePathname(); 17 | 18 | const currentCategoryId = searchParams.get("categoryId"); 19 | 20 | useEffect(() => { 21 | const url = qs.stringifyUrl({ 22 | url: pathname, 23 | query: { 24 | categoryId: currentCategoryId, 25 | title: debouncedValue, 26 | } 27 | }, {skipEmptyString:true, skipNull:true}); 28 | router.push(url); 29 | }, [debouncedValue, currentCategoryId, router, pathname]); 30 | 31 | return ( 32 |
33 | 34 | setValue(e.target.value)} 36 | value={value} 37 | className="w-full md:w-[300px] pl-9 rounded-full bg-slate-100 focus-visible:ring-slate-200" 38 | placeholder="Search a course" /> 39 | 40 |
41 | ) 42 | } -------------------------------------------------------------------------------- /components/select.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { 4 | Select, 5 | SelectContent, 6 | SelectGroup, 7 | SelectItem, 8 | SelectLabel, 9 | SelectTrigger, 10 | SelectValue, 11 | } from "@/components/ui/select" 12 | 13 | export function SelectComponent() { 14 | return ( 15 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /components/ui/auto-complete.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Command, 4 | CommandList, 5 | CommandItem, 6 | CommandGroup, 7 | CommandEmpty, 8 | } from "@/components/ui/command"; 9 | import { type Tag as TagType } from "./tag-input"; 10 | 11 | type AutocompleteProps = { 12 | tags: TagType[]; 13 | setTags: React.Dispatch>; 14 | autocompleteOptions: TagType[]; 15 | maxTags?: number; 16 | onTagAdd?: (tag: string) => void; 17 | allowDuplicates: boolean; 18 | children: React.ReactNode; 19 | }; 20 | 21 | export const Autocomplete: React.FC = ({ 22 | tags, 23 | setTags, 24 | autocompleteOptions, 25 | maxTags, 26 | onTagAdd, 27 | allowDuplicates, 28 | children, 29 | }) => { 30 | return ( 31 | 32 | {children} 33 | 34 | No results found. 35 | 36 | {autocompleteOptions.map((option) => ( 37 | 38 |
{ 40 | if (maxTags && tags.length >= maxTags) return; 41 | if ( 42 | allowDuplicates && 43 | tags.some((tag) => tag.text === option.text) 44 | ) 45 | return; 46 | setTags([...tags, option]); 47 | onTagAdd?.(option.text); 48 | }} 49 | > 50 | {option.text} 51 |
52 |
53 | ))} 54 |
55 |
56 |
57 | ); 58 | }; 59 | -------------------------------------------------------------------------------- /components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | success: "bg-emerald-600 text-white hover:bg-emerald-600/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-10 px-4 py-2", 25 | sm: "h-9 rounded-md px-3", 26 | lg: "h-11 rounded-md px-8", 27 | icon: "h-10 w-10", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )); 18 | Card.displayName = "Card"; 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )); 30 | CardHeader.displayName = "CardHeader"; 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

41 | )); 42 | CardTitle.displayName = "CardTitle"; 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLParagraphElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |

53 | )); 54 | CardDescription.displayName = "CardDescription"; 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |

61 | )); 62 | CardContent.displayName = "CardContent"; 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )); 74 | CardFooter.displayName = "CardFooter"; 75 | 76 | export { 77 | Card, 78 | CardHeader, 79 | CardFooter, 80 | CardTitle, 81 | CardDescription, 82 | CardContent, 83 | }; 84 | -------------------------------------------------------------------------------- /components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; 5 | import { Check } from "lucide-react"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const Checkbox = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | 24 | 25 | 26 | 27 | )); 28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName; 29 | 30 | export { Checkbox }; 31 | -------------------------------------------------------------------------------- /components/ui/combobox.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { Check, ChevronsUpDown } from "lucide-react"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | import { Button } from "@/components/ui/button"; 8 | import { 9 | Command, 10 | CommandEmpty, 11 | CommandGroup, 12 | CommandInput, 13 | CommandItem, 14 | } from "@/components/ui/command"; 15 | import { 16 | Popover, 17 | PopoverContent, 18 | PopoverTrigger, 19 | } from "@/components/ui/popover"; 20 | 21 | interface ComboboxProps { 22 | option: { label: string; value: string }[]; 23 | value?: string; 24 | onChange: (value: string) => void; 25 | } 26 | 27 | export const Combobox = ({ option, value, onChange }: ComboboxProps) => { 28 | const [open, setOpen] = React.useState(false); 29 | 30 | return ( 31 | 32 | 33 | 44 | 45 | 46 | 47 | 48 | No option found. 49 | 50 | {option.map((option) => ( 51 | { 55 | onChange(option.value === value ? "" : option.value); 56 | setOpen(false); 57 | }} 58 | > 59 | 65 | {option.label} 66 | 67 | ))} 68 | 69 | 70 | 71 | 72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ) 21 | } 22 | ) 23 | Input.displayName = "Input" 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as PopoverPrimitive from "@radix-ui/react-popover" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Popover = PopoverPrimitive.Root 9 | 10 | const PopoverTrigger = PopoverPrimitive.Trigger 11 | 12 | const PopoverContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | 17 | 27 | 28 | )) 29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 30 | 31 | export { Popover, PopoverTrigger, PopoverContent } 32 | -------------------------------------------------------------------------------- /components/ui/progress.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as ProgressPrimitive from "@radix-ui/react-progress" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const progressVariants = cva( 10 | "h-full w-full flex-1 bg-primary transition-all", 11 | { 12 | variants: { 13 | variant: { 14 | default: "bg-sky-600", 15 | success: "bg-emerald-700", 16 | }, 17 | }, 18 | defaultVariants: { 19 | variant: "default", 20 | } 21 | } 22 | ) 23 | 24 | export interface ProgressProps 25 | extends React.HTMLAttributes, 26 | VariantProps {} 27 | 28 | type CombinedProgressProps = ProgressProps & React.ComponentPropsWithoutRef 29 | 30 | const Progress = React.forwardRef< 31 | React.ElementRef, 32 | CombinedProgressProps 33 | >(({ className, value, variant, ...props }, ref) => ( 34 | 42 | 46 | 47 | )) 48 | Progress.displayName = ProgressPrimitive.Root.displayName 49 | 50 | export { Progress } -------------------------------------------------------------------------------- /components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Separator = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >( 12 | ( 13 | { className, orientation = "horizontal", decorative = true, ...props }, 14 | ref 15 | ) => ( 16 | 27 | ) 28 | ) 29 | Separator.displayName = SeparatorPrimitive.Root.displayName 30 | 31 | export { Separator } 32 | -------------------------------------------------------------------------------- /components/ui/table.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Table = React.forwardRef< 6 | HTMLTableElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
10 | 15 | 16 | )) 17 | Table.displayName = "Table" 18 | 19 | const TableHeader = React.forwardRef< 20 | HTMLTableSectionElement, 21 | React.HTMLAttributes 22 | >(({ className, ...props }, ref) => ( 23 | 24 | )) 25 | TableHeader.displayName = "TableHeader" 26 | 27 | const TableBody = React.forwardRef< 28 | HTMLTableSectionElement, 29 | React.HTMLAttributes 30 | >(({ className, ...props }, ref) => ( 31 | 36 | )) 37 | TableBody.displayName = "TableBody" 38 | 39 | const TableFooter = React.forwardRef< 40 | HTMLTableSectionElement, 41 | React.HTMLAttributes 42 | >(({ className, ...props }, ref) => ( 43 | 48 | )) 49 | TableFooter.displayName = "TableFooter" 50 | 51 | const TableRow = React.forwardRef< 52 | HTMLTableRowElement, 53 | React.HTMLAttributes 54 | >(({ className, ...props }, ref) => ( 55 | 63 | )) 64 | TableRow.displayName = "TableRow" 65 | 66 | const TableHead = React.forwardRef< 67 | HTMLTableCellElement, 68 | React.ThHTMLAttributes 69 | >(({ className, ...props }, ref) => ( 70 |
78 | )) 79 | TableHead.displayName = "TableHead" 80 | 81 | const TableCell = React.forwardRef< 82 | HTMLTableCellElement, 83 | React.TdHTMLAttributes 84 | >(({ className, ...props }, ref) => ( 85 | 90 | )) 91 | TableCell.displayName = "TableCell" 92 | 93 | const TableCaption = React.forwardRef< 94 | HTMLTableCaptionElement, 95 | React.HTMLAttributes 96 | >(({ className, ...props }, ref) => ( 97 |
102 | )) 103 | TableCaption.displayName = "TableCaption" 104 | 105 | export { 106 | Table, 107 | TableHeader, 108 | TableBody, 109 | TableFooter, 110 | TableHead, 111 | TableRow, 112 | TableCell, 113 | TableCaption, 114 | } 115 | -------------------------------------------------------------------------------- /components/ui/tag-list.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { type Tag as TagType } from "./tag-input"; 3 | import { Tag, TagProps } from "./tag"; 4 | import { cn } from "@/lib/utils"; 5 | 6 | export type TagListProps = { 7 | tags: TagType[]; 8 | customTagRenderer?: (tag: TagType) => React.ReactNode; 9 | direction?: TagProps["direction"]; 10 | } & Omit; 11 | 12 | export const TagList: React.FC = ({ 13 | tags, 14 | customTagRenderer, 15 | direction, 16 | ...tagProps 17 | }) => { 18 | return ( 19 |
25 | {tags.map((tagObj) => 26 | customTagRenderer ? ( 27 | customTagRenderer(tagObj) 28 | ) : ( 29 | 30 | ) 31 | )} 32 |
33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /components/ui/tag-popover.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Popover, 4 | PopoverContent, 5 | PopoverTrigger, 6 | } from "@/components/ui/popover"; 7 | import { type Tag as TagType } from "./tag-input"; 8 | import { TagList, TagListProps } from "./tag-list"; 9 | 10 | type TagPopoverProps = { 11 | children: React.ReactNode; 12 | tags: TagType[]; 13 | customTagRenderer?: (tag: TagType) => React.ReactNode; 14 | } & TagListProps; 15 | 16 | export const TagPopover: React.FC = ({ 17 | children, 18 | tags, 19 | customTagRenderer, 20 | ...tagProps 21 | }) => { 22 | return ( 23 | 24 | {children} 25 | 26 |
27 |

Entered Tags

28 |

29 | These are the tags you've entered. 30 |

31 |
32 | 37 |
38 |
39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /components/ui/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface TextareaProps 6 | extends React.TextareaHTMLAttributes {} 7 | 8 | const Textarea = React.forwardRef( 9 | ({ className, ...props }, ref) => { 10 | return ( 11 |