├── .eslintrc.json ├── .gitignore ├── README.md ├── components.json ├── images ├── lj-3.jpg ├── lj-4.jpg ├── lj-5.jpg ├── lj-8.png ├── lj1.jpg └── lj2.jpg ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── prisma └── schema.prisma ├── public ├── next.svg └── vercel.svg ├── src ├── app │ ├── api │ │ ├── auth │ │ │ └── [...nextauth] │ │ │ │ └── route.ts │ │ ├── chapter │ │ │ └── getInfo │ │ │ │ └── route.ts │ │ └── course │ │ │ └── createChapters │ │ │ └── route.ts │ ├── course │ │ └── [...slug] │ │ │ └── page.tsx │ ├── create │ │ ├── [courseId] │ │ │ └── page.tsx │ │ ├── loading.tsx │ │ └── page.tsx │ ├── favicon.ico │ ├── gallery │ │ └── page.tsx │ ├── globals.css │ ├── layout.tsx │ └── page.tsx ├── components │ ├── ChapterCard.tsx │ ├── ConfirmChapters.tsx │ ├── CourseSideBar.tsx │ ├── CreateCourseForm.tsx │ ├── GalleryCourseCard.tsx │ ├── MainVideoSummary.tsx │ ├── Navbar.tsx │ ├── Providers.tsx │ ├── QuizCards.tsx │ ├── SignInButton.tsx │ ├── ThemeToggle.tsx │ ├── UserAccountNav.tsx │ ├── UserAvatar.tsx │ └── ui │ │ ├── avatar.tsx │ │ ├── button.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── radio-group.tsx │ │ ├── separator.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ └── use-toast.ts ├── lib │ ├── auth.ts │ ├── db.ts │ ├── gpt.ts │ ├── unsplash.ts │ ├── utils.ts │ └── youtube.ts └── validators │ └── course.ts ├── tailwind.config.js ├── tailwind.config.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | .env 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Learning Journey- Full-Stack-AI-Course-Generator 2 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). It is a fully-functioning AI web application that generates courses based on the title and the units you provide it. It uses OpenAI's API using prompt-engineering to utilize user provided data to generate the courses. Any topic can be provided, the ideas are ENDLESS! It has a login feature to login through your Google account (used Google Cloud API) and also generates relevant images using Unsplash API. The relevant YouTube videos are generated through YouTube's API and the Summary section summarizes the video transcript. It uses Prisma, an open-source ORM which generates all the configuartions for the MySQL-compatible serverless database - PlanetScale. 3 | 4 | ## Getting Started 5 | 6 | First, run the development server on your terminal - no need to use an IDE. Make sure that you are in the current directory of learning-journey-yt before beginning. 7 | 8 | ```bash 9 | npm run dev 10 | ``` 11 | 12 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 13 | 14 | The page should be like the one shown below once you sign in: 15 | 16 | 17 | 18 | ![lj1](https://github.com/maggike/Full-Stack-AI-Course-Creator-Learning-Journey/assets/140755916/aaf6104f-a818-458d-8e1a-9e658bf16bd6) 19 | 20 | 21 | 22 | 23 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 24 | 25 | 26 | 27 | The Gallery page shows the courses you have generated using Learning Journey. Here are some I generated below! You can generate courses from the simplest to the most complicated topics.... 28 | 29 | 30 | 31 | ![lj2](https://github.com/maggike/Full-Stack-AI-Course-Creator-Learning-Journey/assets/140755916/9beb8222-9928-4983-911c-e48a30ecd7d1) 32 | 33 | 34 | 35 | Click on the create course button to create your first course!! Input the titles and units you want. You can input as many units as you want by clicking on "Add Unit". The default value is 3 units. 36 | 37 | 38 | 39 | ![lj-3](https://github.com/maggike/Full-Stack-AI-Course-Creator-Learning-Journey/assets/140755916/026b31ae-c396-4f61-a4a4-98fdc10dd373) 40 | 41 | 42 | 43 | All the pages can be viewed in both light and dark themes ! My favourite is the dark theme. 44 | 45 | 46 | 47 | ![lj-4](https://github.com/maggike/Full-Stack-AI-Course-Creator-Learning-Journey/assets/140755916/bc8c7ddf-fd4c-409f-8fdc-d1109955f20d) 48 | 49 | 50 | 51 | Once the course has been generated the page should look like the one shown below. 52 | 53 | 54 | 55 | ![lj-5](https://github.com/maggike/Full-Stack-AI-Course-Creator-Learning-Journey/assets/140755916/270352e9-2ed7-4045-94db-b0c3c2c7b7b6) 56 | 57 | 58 | 59 | Each chapter within the course will have a relevant video generated from YouTube along with a short summary summarising the video's transcript. 60 | 61 | 62 | 63 | ![lj-11](https://github.com/maggike/Full-Stack-AI-Course-Creator-Learning-Journey/assets/140755916/5f995580-b0fd-4cb9-967f-81557fb42a2b) 64 | 65 | 66 | 67 | You can return to the course generated anytime from the course gallery. 68 | Have fun creating courses! 69 | 70 | 71 | 72 | ## Learn More 73 | 74 | To learn more about Next.js, take a look at the following resources: 75 | 76 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 77 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 78 | 79 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 80 | 81 | ## Deploy on Vercel 82 | 83 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 84 | 85 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 86 | -------------------------------------------------------------------------------- /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": "src/app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } -------------------------------------------------------------------------------- /images/lj-3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maggike/Full-Stack-AI-Course-Creator-Learning-Journey/15ea7bf160f72fb4edc3127dcc6df49921dd9cf9/images/lj-3.jpg -------------------------------------------------------------------------------- /images/lj-4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maggike/Full-Stack-AI-Course-Creator-Learning-Journey/15ea7bf160f72fb4edc3127dcc6df49921dd9cf9/images/lj-4.jpg -------------------------------------------------------------------------------- /images/lj-5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maggike/Full-Stack-AI-Course-Creator-Learning-Journey/15ea7bf160f72fb4edc3127dcc6df49921dd9cf9/images/lj-5.jpg -------------------------------------------------------------------------------- /images/lj-8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maggike/Full-Stack-AI-Course-Creator-Learning-Journey/15ea7bf160f72fb4edc3127dcc6df49921dd9cf9/images/lj-8.png -------------------------------------------------------------------------------- /images/lj1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maggike/Full-Stack-AI-Course-Creator-Learning-Journey/15ea7bf160f72fb4edc3127dcc6df49921dd9cf9/images/lj1.jpg -------------------------------------------------------------------------------- /images/lj2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maggike/Full-Stack-AI-Course-Creator-Learning-Journey/15ea7bf160f72fb4edc3127dcc6df49921dd9cf9/images/lj2.jpg -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images:{ 4 | domains:["lh3.googleusercontent.com","s3.us-west-2.amazonaws.com"] 5 | } 6 | } 7 | 8 | module.exports = nextConfig 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "learning-journey-yt", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@hookform/resolvers": "^3.3.0", 13 | "@next-auth/prisma-adapter": "^1.0.7", 14 | "@prisma/client": "^5.2.0", 15 | "@radix-ui/react-avatar": "^1.0.3", 16 | "@radix-ui/react-dropdown-menu": "^2.0.5", 17 | "@radix-ui/react-label": "^2.0.2", 18 | "@radix-ui/react-radio-group": "^1.1.3", 19 | "@radix-ui/react-separator": "^1.0.3", 20 | "@radix-ui/react-slot": "^1.0.2", 21 | "@radix-ui/react-toast": "^1.1.4", 22 | "@tanstack/react-query": "^4.33.0", 23 | "@types/node": "20.5.3", 24 | "@types/react": "18.2.21", 25 | "@types/react-dom": "18.2.7", 26 | "autoprefixer": "10.4.15", 27 | "axios": "^1.4.0", 28 | "class-variance-authority": "^0.7.0", 29 | "clsx": "^2.0.0", 30 | "create-next-app": "^13.4.19", 31 | "eslint": "8.47.0", 32 | "eslint-config-next": "13.4.19", 33 | "framer-motion": "^10.16.1", 34 | "init": "^0.1.2", 35 | "lucide-react": "^0.268.0", 36 | "next": "13.4.19", 37 | "next-auth": "^4.23.1", 38 | "next-themes": "^0.2.1", 39 | "openai": "^3.3.0", 40 | "postcss": "8.4.28", 41 | "react": "18.2.0", 42 | "react-dom": "18.2.0", 43 | "react-hook-form": "^7.45.4", 44 | "shadcn-ui": "^0.3.0", 45 | "tailwind-merge": "^1.14.0", 46 | "tailwindcss": "3.3.3", 47 | "tailwindcss-animate": "^1.0.6", 48 | "typescript": "5.1.6", 49 | "youtube-transcript": "^1.0.6", 50 | "zod": "^3.22.2" 51 | }, 52 | "description": "This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).", 53 | "main": "next.config.js", 54 | "author": "", 55 | "license": "ISC", 56 | "devDependencies": { 57 | "concurrently": "^8.2.1", 58 | "http-server": "^14.1.1", 59 | "prisma": "^5.2.0" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | 5 | generator client { 6 | provider = "prisma-client-js" 7 | } 8 | 9 | datasource db { 10 | provider = "mysql" 11 | url = env("DATABASE_URL") 12 | relationMode = "prisma" 13 | } 14 | 15 | model Account { 16 | id String @id @default(cuid()) 17 | userId String 18 | type String 19 | provider String 20 | providerAccountId String 21 | refresh_token String? @db.Text 22 | access_token String? @db.Text 23 | expires_at Int? 24 | token_type String? 25 | scope String? 26 | id_token String? @db.Text 27 | session_state String? 28 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 29 | @@index([userId], name:"userId") 30 | @@unique([provider, providerAccountId]) 31 | } 32 | 33 | model Session { 34 | id String @id @default(cuid()) 35 | sessionToken String @unique 36 | userId String 37 | expires DateTime 38 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 39 | @@index([userId], name:"userId") 40 | } 41 | 42 | model User { 43 | id String @id @default(cuid()) 44 | name String? 45 | email String? @unique 46 | emailVerified DateTime? 47 | image String? 48 | credits Int @default(10) 49 | accounts Account[] 50 | sessions Session[] 51 | } 52 | 53 | model Course{ 54 | id String @id @default(cuid()) 55 | name String 56 | image String 57 | units Unit[] 58 | } 59 | 60 | model Unit{ 61 | id String @id @default(cuid()) 62 | courseId String 63 | name String 64 | course Course @relation(fields: [courseId], references:[id]) 65 | chapters Chapter[] 66 | @@index([courseId], name:"courseId") 67 | } 68 | 69 | model Chapter{ 70 | id String @id @default(cuid()) 71 | unitId String 72 | name String 73 | youtubeSearchQuery String 74 | videoId String? 75 | summary String? @db.VarChar(3000) 76 | unit Unit @relation(fields:[unitId], references:[id]) 77 | questions Question[] 78 | 79 | @@index([unitId], name:"unitId") 80 | } 81 | 82 | model Question{ 83 | id String @id @default(cuid()) 84 | chapterId String 85 | question String @db.VarChar(3000) 86 | answer String @db.VarChar(3000) 87 | options String @db.VarChar(3000) 88 | chapter Chapter @relation(fields:[chapterId], references:[id]) 89 | 90 | @@index([chapterId], name:"chapterId") 91 | 92 | 93 | } 94 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import {authOptions} from "@/lib/auth"; 2 | import NextAuth from "next-auth/next"; 3 | 4 | const handler=NextAuth(authOptions) 5 | 6 | export {handler as GET, handler as POST}; -------------------------------------------------------------------------------- /src/app/api/chapter/getInfo/route.ts: -------------------------------------------------------------------------------- 1 | // /api/chapter/getInfo 2 | import { prisma } from "@/lib/db" 3 | import { strict_output } from "@/lib/gpt" 4 | import { getQuestionsFromTranscript, getTranscript, searchYoutube } from "@/lib/youtube" 5 | import { NextResponse } from "next/server" 6 | import { z } from "zod" 7 | 8 | const bodyParser = z.object({ 9 | chapterId: z.string() 10 | }) 11 | 12 | export async function POST(req: Request, res: Response) { 13 | try { 14 | const body = await req.json() 15 | const { chapterId } = bodyParser.parse(body) 16 | const chapter = await prisma.chapter.findUnique({ 17 | where: { 18 | id: chapterId 19 | } 20 | }) 21 | if (!chapter) { 22 | return NextResponse.json({ 23 | success: false, 24 | error: "Chapter not found" 25 | }, { status: 404 }) 26 | } 27 | const videoId = await searchYoutube(chapter.youtubeSearchQuery) 28 | let transcript = await getTranscript(videoId) 29 | let maxLength = 500 30 | transcript = transcript.split(" ").slice(0, maxLength).join(" ") 31 | const {summary }:{ summary:string } = await strict_output("You are an AI capable of summarizing a youtube transcript", 32 | "summarize in 250 words or less and do not talk of the sponsors or anything unrelated to the main topic, also do not introduce what the summary is about.\n" + transcript, { summary: "summary of the transcript" }) 33 | const questions = await getQuestionsFromTranscript(transcript, chapter.name) 34 | await prisma.question.createMany({ 35 | data: questions.map((question) => { 36 | let options = [question.answer, question.option1, question.option2, question.option3,] 37 | options = options.sort(() => Math.random() - 0.5) 38 | return { 39 | question: question.question, 40 | answer: question.answer, 41 | options: JSON.stringify(options), 42 | chapterId: chapterId, 43 | } 44 | }) 45 | }) 46 | await prisma.chapter.update({ 47 | where: { 48 | id: chapterId 49 | }, 50 | data: { 51 | videoId: videoId, 52 | summary: summary, 53 | }, 54 | }) 55 | return NextResponse.json({success:true}) 56 | } catch (error) { 57 | if (error instanceof z.ZodError) { 58 | return NextResponse.json({ 59 | success: false, error: 'Invalid body' 60 | }, { status: 400 }) 61 | } 62 | else { 63 | return NextResponse.json({ 64 | success: false, 65 | error: "unknown" 66 | }, { status: 500 }) 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /src/app/api/course/createChapters/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { createChaptersSchema } from "@/validators/course"; 3 | import { ZodError } from "zod"; 4 | import { getUnsplashImage } from "@/lib/unsplash"; 5 | import { prisma } from "@/lib/db"; 6 | import { strict_output } from "@/lib/gpt"; 7 | 8 | 9 | export async function POST(req: Request, res: Response) { 10 | try { 11 | const body = await req.json(); 12 | const { title, units } = createChaptersSchema.parse(body) 13 | type outputUnits = { 14 | title: String; 15 | chapters: { 16 | youtube_search_query: string; 17 | chapter_title: string; 18 | }[]; 19 | }[]; 20 | let output_units: outputUnits = await strict_output( 21 | "You are an AI capable of curating course content, coming up with relevant chapter titles, and finding relevant youtube videos for each chapter", 22 | new Array(units.length).fill( 23 | `It is your job to create a course about ${title}. The user has requested to create chapters for each of the units. Then, for each chapter, provide a detailed youtube search query that can be used to find an informative educational vidoe for each chapter. Each query should give an educational informative course in youtube.` 24 | ), 25 | { 26 | title: "title of the unit", 27 | chapters: 28 | "an array of chapters, each chapter should have a youtube_search_query and a chapter_title key in the JSON object", 29 | } 30 | ); 31 | const imageSearchTerm = await strict_output( 32 | "you are an AI capable of finding the most relevant image for a course", 33 | `please provide a good image search term for the title of a course about ${title}. This search term will be fed into the unsplash api so make sure it is a good search term that will return good results`, 34 | { 35 | image_search_term: "a good search term for the title of the course" 36 | } 37 | ) 38 | const course_image = await getUnsplashImage(imageSearchTerm.image_search_term); 39 | const course = await prisma.course.create({ 40 | data: { 41 | name: title, 42 | image: course_image, 43 | }, 44 | }) 45 | 46 | for (const unit of output_units) { 47 | const title = unit.title; 48 | const prismaUnit = await prisma.unit.create({ 49 | data: { 50 | name: title, 51 | courseId: course.id 52 | } 53 | }) 54 | await prisma.chapter.createMany({ 55 | data: unit.chapters.map((chapter) => { 56 | return { 57 | name: chapter.chapter_title, 58 | youtubeSearchQuery: chapter.youtube_search_query, 59 | unitId: prismaUnit.id 60 | 61 | } 62 | }) 63 | }) 64 | } 65 | console.log(output_units); 66 | return NextResponse.json({ course_id: course.id }); 67 | 68 | } catch (error) { 69 | if (error instanceof ZodError) { 70 | return new NextResponse("invalid body", { status: 400 }) 71 | } 72 | console.error(error) 73 | } 74 | } -------------------------------------------------------------------------------- /src/app/course/[...slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import CourseSideBar from '@/components/CourseSideBar' 2 | import MainVideoSummary from '@/components/MainVideoSummary' 3 | import QuizCards from '@/components/QuizCards' 4 | import { prisma } from "@/lib/db" 5 | import { ChevronLeft, ChevronRight } from 'lucide-react' 6 | import Link from 'next/link' 7 | import { redirect } from 'next/navigation' 8 | import React from 'react' 9 | 10 | type Props = { 11 | params:{ 12 | slug: string[] 13 | } 14 | } 15 | 16 | const CoursePage = async ({params:{slug}}: Props) => { 17 | const[courseId,unitIndexParam,chapterIndexParam]=slug 18 | const course=await prisma.course.findUnique({ 19 | where:{id:courseId}, 20 | include:{ 21 | units:{ 22 | include:{chapters:{ 23 | include:{questions:true} 24 | }} 25 | } 26 | } 27 | }) 28 | if(!course){ 29 | return redirect('/gallery') 30 | 31 | } 32 | let unitIndex=parseInt(unitIndexParam) 33 | let chapterIndex=parseInt(chapterIndexParam) 34 | 35 | const unit = course.units[unitIndex] 36 | if (!unit){ 37 | return redirect('/gallery') 38 | } 39 | const chapter=unit.chapters[chapterIndex] 40 | if(!chapter){ 41 | return redirect('/gallery') 42 | } 43 | 44 | const nextChapter=unit.chapters[chapterIndex+1] 45 | const prevChapter=unit.chapters[chapterIndex-1] 46 | return ( 47 |
48 | 49 |
50 |
51 |
52 | 58 | 59 | 60 |
61 |
62 |
63 | {prevChapter&&( 64 | 66 |
67 | 68 |
69 | 70 | Previous 71 | 72 | 73 | {prevChapter.name} 74 | 75 |
76 |
77 | 78 | )} 79 | 80 | {nextChapter&&( 81 | 83 |
84 | 85 |
86 | 87 | Next 88 | 89 | 90 | {nextChapter.name} 91 | 92 |
93 | 94 |
95 | 96 | )} 97 |
98 |
99 |
100 |
101 | ) 102 | 103 | 104 | } 105 | 106 | export default CoursePage -------------------------------------------------------------------------------- /src/app/create/[courseId]/page.tsx: -------------------------------------------------------------------------------- 1 | import ConfirmChapters from '@/components/ConfirmChapters' 2 | import { getAuthSession } from '@/lib/auth' 3 | import { prisma } from '@/lib/db' 4 | import { Info } from 'lucide-react' 5 | import { redirect } from 'next/navigation' 6 | import React from 'react' 7 | 8 | type Props = { 9 | params :{ 10 | courseId: string 11 | } 12 | } 13 | 14 | const CreateChapters = async ({params:{ courseId}}: Props) => { 15 | const session = await getAuthSession() 16 | if (!session?.user){ 17 | return redirect('/gallery') 18 | } 19 | const course = await prisma.course.findUnique({ 20 | where:{ 21 | id:courseId, 22 | 23 | }, 24 | include:{ 25 | units:{ 26 | include:{ 27 | chapters:true 28 | }, 29 | }, 30 | }, 31 | 32 | }) 33 | if (!course){ 34 | return redirect('/create') 35 | } 36 | return ( 37 |
38 |
39 | Course Name 40 |
41 |

42 | {course.name} 43 | 44 |

45 |
46 | 47 |
48 | We generated chapters for each of your units. Look over them and then click the button to confirm and continue 49 |
50 |
51 | 52 |
53 | ) 54 | 55 | } 56 | 57 | export default CreateChapters -------------------------------------------------------------------------------- /src/app/create/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Loader2 } from 'lucide-react' 2 | import React from 'react' 3 | 4 | type Props = {} 5 | 6 | const LoadingComponent = (props: Props) => { 7 | return ( 8 |
9 | 10 |
11 | ) 12 | } 13 | 14 | export default LoadingComponent -------------------------------------------------------------------------------- /src/app/create/page.tsx: -------------------------------------------------------------------------------- 1 | import { getAuthSession } from '@/lib/auth' 2 | import React from 'react' 3 | import {redirect} from 'next/navigation' 4 | import { InfoIcon } from 'lucide-react' 5 | import CreateCourseForm from '@/components/CreateCourseForm' 6 | 7 | type Props = {} 8 | 9 | const CreatePage = async (props: Props) => { 10 | const session= await getAuthSession() 11 | if(!session?.user){ 12 | return redirect('/gallery') 13 | } 14 | return ( 15 |
16 | 17 | 18 |

19 | Learning Journey 20 |

21 |
22 | 23 |
24 | Enter in a course title, or what you want to learn about. Then enter a list of units which are the specifics you want to learn. And our AI will generate a course for you! 25 |
26 |
27 | 28 |
29 | ) 30 | } 31 | 32 | export default CreatePage -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maggike/Full-Stack-AI-Course-Creator-Learning-Journey/15ea7bf160f72fb4edc3127dcc6df49921dd9cf9/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/gallery/page.tsx: -------------------------------------------------------------------------------- 1 | import GalleryCourseCard from '@/components/GalleryCourseCard' 2 | import { prisma } from '@/lib/db' 3 | import React from 'react' 4 | 5 | type Props = {} 6 | 7 | const GalleryPage = async (props: Props) => { 8 | const courses=await prisma.course.findMany({ 9 | include:{ 10 | units:{ 11 | include:{chapters:true} 12 | } 13 | } 14 | }) 15 | return ( 16 |
17 |
18 | {courses.map((course)=>{ 19 | return 20 | })} 21 |
22 |
23 | ) 24 | } 25 | 26 | export default GalleryPage -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --border: 214.3 31.8% 91.4%; 32 | --input: 214.3 31.8% 91.4%; 33 | --ring: 222.2 84% 4.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 222.2 84% 4.9%; 40 | --foreground: 210 40% 98%; 41 | 42 | --card: 222.2 84% 4.9%; 43 | --card-foreground: 210 40% 98%; 44 | 45 | --popover: 222.2 84% 4.9%; 46 | --popover-foreground: 210 40% 98%; 47 | 48 | --primary: 210 40% 98%; 49 | --primary-foreground: 222.2 47.4% 11.2%; 50 | 51 | --secondary: 217.2 32.6% 17.5%; 52 | --secondary-foreground: 210 40% 98%; 53 | 54 | --muted: 217.2 32.6% 17.5%; 55 | --muted-foreground: 215 20.2% 65.1%; 56 | 57 | --accent: 217.2 32.6% 17.5%; 58 | --accent-foreground: 210 40% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 210 40% 98%; 62 | 63 | --border: 217.2 32.6% 17.5%; 64 | --input: 217.2 32.6% 17.5%; 65 | --ring: 212.7 26.8% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/lib/utils'; 2 | import './globals.css' 3 | import type { Metadata } from 'next' 4 | import { Lexend } from 'next/font/google' 5 | import Navbar from '@/components/Navbar'; 6 | import { Provider } from '@/components/Providers'; 7 | import { Toaster } from '@/components/ui/toaster'; 8 | 9 | const lexend = Lexend({ subsets: ['latin'] }) 10 | 11 | export const metadata: Metadata = { 12 | title: 'Learning Journey', 13 | 14 | }; 15 | 16 | export default function RootLayout({ 17 | children, 18 | }: { 19 | children: React.ReactNode 20 | }) { 21 | return ( 22 | 23 | 26 | 27 | 28 | 29 | 30 | {children} 31 | 32 | 33 | 34 | 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | 3 | 4 | export default function Home() { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /src/components/ChapterCard.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import {Chapter} from '@prisma/client' 3 | 4 | import { cn } from '@/lib/utils' 5 | import React from 'react' 6 | import { useMutation } from '@tanstack/react-query' 7 | import axios from 'axios' 8 | import { useToast } from './ui/use-toast' 9 | import { Loader2 } from 'lucide-react' 10 | 11 | 12 | type Props = { 13 | chapter: Chapter 14 | chapterIndex: number 15 | completedChapters:Set; 16 | setCompletedChapters:React.Dispatch>>; 17 | } 18 | 19 | export type ChapterCardHandler = { 20 | triggerLoad: () => void; 21 | }; 22 | 23 | const ChapterCard = React.forwardRef(({ chapter, chapterIndex, setCompletedChapters, completedChapters }, ref) => { 24 | const {toast}=useToast() 25 | const [ success, setSuccess ] = React.useState(null); 26 | const { mutate: getchapterInfo, isLoading } = useMutation({ 27 | mutationFn: async () => { 28 | const response = await axios.post('/api/chapter/getInfo', { chapterId: chapter.id }) 29 | return response.data 30 | } 31 | }) 32 | 33 | const addChapterIdToSet=React.useCallback(( 34 | )=>{ 35 | // const newSet=new Set(completedChapters); 36 | // newSet.add(chapter.id); 37 | // setCompletedChapters(newSet) 38 | setCompletedChapters((prev)=>{ 39 | const newSet=new Set(prev) 40 | newSet.add(chapter.id) 41 | return newSet 42 | } 43 | ) 44 | 45 | },[ chapter.id, setCompletedChapters]) 46 | 47 | React.useEffect(()=>{ 48 | if(chapter.videoId){ 49 | setSuccess(true); 50 | addChapterIdToSet 51 | } 52 | }, [chapter,addChapterIdToSet]) 53 | React.useImperativeHandle(ref, () => ({ 54 | async triggerLoad() { 55 | if (chapter.videoId){ 56 | addChapterIdToSet() 57 | return 58 | } 59 | getchapterInfo(undefined, { 60 | onSuccess: ({success}) => { 61 | setSuccess(true) 62 | addChapterIdToSet() 63 | }, 64 | onError:(error)=>{ 65 | console.error(error) 66 | setSuccess(false) 67 | toast({ 68 | title:"Error", 69 | description:"There was an error loading your chapter", 70 | variant:"destructive", 71 | }) 72 | addChapterIdToSet() 73 | 74 | } 75 | 76 | }) 77 | } 78 | })) 79 | return ( 80 |
82 |
{chapter.name}
83 | {isLoading&&}
84 | ) 85 | }) 86 | ChapterCard.displayName = "ChapterCard" 87 | 88 | export default ChapterCard 89 | 90 | -------------------------------------------------------------------------------- /src/components/ConfirmChapters.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import React from 'react' 3 | import {Chapter,Course,Unit} from "@prisma/client"; 4 | import ChapterCard, { ChapterCardHandler } from './ChapterCard'; 5 | import { Separator } from './ui/separator'; 6 | import { Button, buttonVariants } from './ui/button'; 7 | import Link from 'next/link'; 8 | import { ChevronLeft, ChevronRight } from 'lucide-react'; 9 | 10 | 11 | 12 | 13 | 14 | 15 | type Props = { 16 | course:Course&{ 17 | units:(Unit& { 18 | chapters:Chapter[]; 19 | })[] 20 | } 21 | } 22 | 23 | const ConfirmChapters = ({course}: Props) => { 24 | const [loading, setLoading]=React.useState(false); 25 | const chapterRefs: Record>={}; 26 | course.units.forEach((unit)=>{ 27 | unit.chapters.forEach((chapter)=>{ 28 | //eslint-disable-next-line react-hooks/rule of hooks 29 | chapterRefs[chapter.id] = React.useRef(null); 30 | }) 31 | }) 32 | const [completedChapters, setCompletedChapters]=React.useState>(new Set()) 33 | const totalChaptersCount =React.useMemo(()=>{ 34 | return course.units.reduce ((acc, unit)=> 35 | { 36 | return acc+unit.chapters.length 37 | }, 0) 38 | }, [course.units]) 39 | 40 | return ( 41 |
42 | {course.units.map((unit, unitIndex)=>{ 43 | return(
44 |

45 | Unit {unitIndex+1} 46 |

47 |

48 | {unit.name} 49 |

50 |
51 | {unit.chapters.map((chapter, chapterIndex)=>{ 52 | return () 59 | })} 60 |
61 |
62 | ) 63 | 64 | })} 65 | 66 |
67 | 68 |
69 | 70 | 73 | 74 | Back 75 | 76 | { 77 | totalChaptersCount===completedChapters.size?( 78 | 79 | Save & Continue 80 | 81 | 82 | ):( 83 | 92 | ) 93 | } 94 | 95 |
96 | 97 |
98 |
99 | )} 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | export default ConfirmChapters; -------------------------------------------------------------------------------- /src/components/CourseSideBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {Chapter, Course, Unit } from "@prisma/client" 3 | import Link from 'next/link' 4 | import { cn } from '@/lib/utils' 5 | import { Separator } from './ui/separator' 6 | 7 | type Props = { 8 | course:Course&{ 9 | units:(Unit&{ 10 | chapters:Chapter[] 11 | })[] 12 | } 13 | currentChapterId:string 14 | } 15 | 16 | const CourseSideBar = async ({course, currentChapterId}: Props) => { 17 | return ( 18 |
19 |

20 | {course.name} 21 | 22 |

23 | {course.units.map((unit,unitIndex)=>{ 24 | return (
25 |

26 | Unit {unitIndex+1} 27 |

28 |

29 | {unit.name} 30 |

31 | {unit.chapters.map((chapter,chapterIndex)=>{ 32 | return( 33 |
34 | 38 | {chapter.name} 39 |
40 | ) 41 | })} 42 | 43 |
44 | ) 45 | })} 46 |
47 | ) 48 | } 49 | 50 | 51 | 52 | export default CourseSideBar -------------------------------------------------------------------------------- /src/components/CreateCourseForm.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import React from 'react' 3 | import { Form, FormField, FormItem, FormLabel, FormControl } from './ui/form' 4 | import { createChaptersSchema } from '@/validators/course' 5 | import {z} from "zod"; 6 | import {useForm} from "react-hook-form" 7 | 8 | import {zodResolver} from '@hookform/resolvers/zod' 9 | import { Input } from './ui/input'; 10 | import { Separator } from './ui/separator'; 11 | import { Button } from './ui/button'; 12 | import { Plus, Trash } from 'lucide-react'; 13 | import {motion, AnimatePresence}from 'framer-motion' 14 | import { useMutation } from '@tanstack/react-query'; 15 | import axios from 'axios'; 16 | import { useToast } from './ui/use-toast'; 17 | import { useRouter } from 'next/navigation'; 18 | 19 | 20 | 21 | type Props = {}; 22 | 23 | type Input= z.infer 24 | 25 | const CreateCourseForm = (props: Props) => { 26 | const router=useRouter(); 27 | const {toast}=useToast() 28 | const {mutate:createChapters, isLoading}=useMutation({ 29 | mutationFn: async({title,units}:Input)=>{ 30 | const response=await axios.post('/api/course/createChapters', {title,units}) 31 | return response.data 32 | 33 | } 34 | }) 35 | const form=useForm({ 36 | resolver:zodResolver(createChaptersSchema), 37 | defaultValues:{ 38 | title:'', 39 | units:['','',''] 40 | } 41 | }) 42 | 43 | function onSubmit(data:Input){ 44 | if (data.units.some(unit=>unit==='')){ 45 | toast({ 46 | title:"error", 47 | description:"please fill all the units", 48 | variant:"destructive", 49 | 50 | }) 51 | return; 52 | } 53 | createChapters(data,{ 54 | onSuccess:({course_id})=>{ 55 | toast({ 56 | title:"success", 57 | description:"course created successfully", 58 | }) 59 | router.push(`/create/${course_id}`) 60 | 61 | }, 62 | onError:(error)=>{ 63 | console.error(error) 64 | toast({ 65 | title:"Error", 66 | description:"Something went wrong", 67 | variant:"destructive", 68 | }) 69 | } 70 | }) 71 | } 72 | form.watch(); 73 | 74 | 75 | return ( 76 |
77 |
78 | 79 | { 83 | return( 84 | 85 | Title 86 | 87 | 88 | 91 | 92 | 93 | ) 94 | }}/> 95 | 96 | {form.watch('units').map((_,index)=>{ 97 | return( 98 | 106 | { 111 | return( 112 | 113 | 114 | Unit {index+1} 115 | 116 | 117 | 120 | 121 | 122 | 123 | 124 | ) 125 | }} 126 | /> 127 | 128 | 129 | ) 130 | })} 131 | 132 |
133 | 134 |
135 | 141 | 142 | 148 | 149 |
150 | 151 |
152 | 155 | 156 | 157 | 158 |
159 | ) 160 | } 161 | 162 | export default CreateCourseForm -------------------------------------------------------------------------------- /src/components/GalleryCourseCard.tsx: -------------------------------------------------------------------------------- 1 | import { Chapter, Course, Unit } from '@prisma/client' 2 | import Link from 'next/link' 3 | import Image from 'next/image' 4 | 5 | 6 | import React from 'react' 7 | 8 | type Props = { 9 | course:Course&{ 10 | units:(Unit&{ 11 | chapters:Chapter[] 12 | })[] 13 | } 14 | } 15 | 16 | const GalleryCourseCard = async ({course}: Props) => { 17 | return ( 18 | <> 19 |
20 |
21 | 22 | picture of the course 27 | 28 | {course.name} 29 | 30 | 31 |
32 |
33 |

34 | Units 35 |

36 |
37 | {course.units.map((unit,unitIndex)=>{ 38 | return( 39 | 42 | {unit.name} 43 | 44 | ) 45 | })} 46 |
47 | 48 |
49 | 50 | ) 51 | } 52 | 53 | export default GalleryCourseCard -------------------------------------------------------------------------------- /src/components/MainVideoSummary.tsx: -------------------------------------------------------------------------------- 1 | import { Chapter, Unit } from '@prisma/client' 2 | import React from 'react' 3 | 4 | type Props = { 5 | chapter: Chapter 6 | unit: Unit 7 | unitIndex: number 8 | chapterIndex: number 9 | } 10 | 11 | const MainVideoSummary = ({ unit, unitIndex, chapter, chapterIndex }: Props) => { 12 | return ( 13 |

14 | Unit{unitIndex + 1} &bull:Chapter{chapterIndex + 1}

15 |

16 | {chapter.name}

17 |