├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README.md ├── app ├── (preview) │ ├── actions.ts │ ├── globals.css │ ├── layout.tsx │ ├── opengraph-image.png │ ├── page.tsx │ └── twitter-image.png ├── api │ └── generate-quiz │ │ └── route.ts └── favicon.ico ├── components.json ├── components ├── icons.tsx ├── markdown.tsx ├── quiz-overview.tsx ├── quiz.tsx ├── score.tsx └── ui │ ├── badge.tsx │ ├── button.tsx │ ├── card.tsx │ ├── input.tsx │ ├── label.tsx │ ├── link.tsx │ ├── progress.tsx │ ├── radio-group.tsx │ └── scroll-area.tsx ├── lib ├── schemas.ts └── utils.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── tailwind.config.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env* 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Vercel, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AI SDK PDF Support Example 2 | 3 | This example demonstrates how to use the [AI SDK](https://sdk.vercel.ai/docs) with [Next.js](https://nextjs.org/) with the `useObject` hook to submit PDF messages to the AI provider of your choice (Google or Anthropic). 4 | 5 | ## Deploy your own 6 | 7 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fai-sdk-preview-pdf-support&env=GOOGLE_API_KEY&envDescription=API%20keys%20needed%20for%20application&envLink=google.com) 8 | 9 | ## How to use 10 | 11 | Run [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init), [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/), or [pnpm](https://pnpm.io) to bootstrap the example: 12 | 13 | ```bash 14 | npx create-next-app --example https://github.com/vercel-labs/ai-sdk-preview-pdf-support ai-sdk-preview-pdf-support-example 15 | ``` 16 | 17 | ```bash 18 | yarn create next-app --example https://github.com/vercel-labs/ai-sdk-preview-pdf-support ai-sdk-preview-pdf-support-example 19 | ``` 20 | 21 | ```bash 22 | pnpm create next-app --example https://github.com/vercel-labs/ai-sdk-preview-pdf-support ai-sdk-preview-pdf-support-example 23 | ``` 24 | 25 | To run the example locally you need to: 26 | 27 | 1. Sign up for accounts with the AI providers you want to use (e.g., Google). 28 | 2. Obtain API keys for Google provider. 29 | 3. Set the required environment variables as shown in the `.env.example` file, but in a new file called `.env`. 30 | 4. `npm install` to install the required dependencies. 31 | 5. `npm run dev` to launch the development server. 32 | 33 | 34 | ## Learn More 35 | 36 | To learn more about Vercel AI SDK or Next.js take a look at the following resources: 37 | 38 | - [AI SDK docs](https://sdk.vercel.ai/docs) 39 | - [Vercel AI Playground](https://play.vercel.ai) 40 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 41 | 42 | -------------------------------------------------------------------------------- /app/(preview)/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { google } from "@ai-sdk/google"; 4 | import { generateObject } from "ai"; 5 | import { z } from "zod"; 6 | 7 | export const generateQuizTitle = async (file: string) => { 8 | const result = await generateObject({ 9 | model: google("gemini-1.5-flash-latest"), 10 | schema: z.object({ 11 | title: z 12 | .string() 13 | .describe( 14 | "A max three word title for the quiz based on the file provided as context", 15 | ), 16 | }), 17 | prompt: 18 | "Generate a title for a quiz based on the following (PDF) file name. Try and extract as much info from the file name as possible. If the file name is just numbers or incoherent, just return quiz.\n\n " + file, 19 | }); 20 | return result.object.title; 21 | }; 22 | -------------------------------------------------------------------------------- /app/(preview)/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 240 10% 3.9%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 240 10% 3.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 240 10% 3.9%; 13 | --primary: 240 5.9% 10%; 14 | --primary-foreground: 0 0% 98%; 15 | --secondary: 240 4.8% 95.9%; 16 | --secondary-foreground: 240 5.9% 10%; 17 | --muted: 240 4.8% 95.9%; 18 | --muted-foreground: 240 3.8% 46.1%; 19 | --accent: 240 4.8% 95.9%; 20 | --accent-foreground: 240 5.9% 10%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 0 0% 98%; 23 | --border: 240 5.9% 90%; 24 | --input: 240 5.9% 90%; 25 | --ring: 240 10% 3.9%; 26 | --chart-1: 12 76% 61%; 27 | --chart-2: 173 58% 39%; 28 | --chart-3: 197 37% 24%; 29 | --chart-4: 43 74% 66%; 30 | --chart-5: 27 87% 67%; 31 | --radius: 0.5rem; 32 | } 33 | .dark { 34 | --background: 240 10% 3.9%; 35 | --foreground: 0 0% 98%; 36 | --card: 240 10% 3.9%; 37 | --card-foreground: 0 0% 98%; 38 | --popover: 240 10% 3.9%; 39 | --popover-foreground: 0 0% 98%; 40 | --primary: 0 0% 98%; 41 | --primary-foreground: 240 5.9% 10%; 42 | --secondary: 240 3.7% 15.9%; 43 | --secondary-foreground: 0 0% 98%; 44 | --muted: 240 3.7% 15.9%; 45 | --muted-foreground: 240 5% 64.9%; 46 | --accent: 240 3.7% 15.9%; 47 | --accent-foreground: 0 0% 98%; 48 | --destructive: 0 62.8% 30.6%; 49 | --destructive-foreground: 0 0% 98%; 50 | --border: 240 3.7% 15.9%; 51 | --input: 240 3.7% 15.9%; 52 | --ring: 240 4.9% 83.9%; 53 | --chart-1: 220 70% 50%; 54 | --chart-2: 160 60% 45%; 55 | --chart-3: 30 80% 55%; 56 | --chart-4: 280 65% 60%; 57 | --chart-5: 340 75% 55%; 58 | } 59 | } 60 | 61 | @layer base { 62 | * { 63 | @apply border-border; 64 | } 65 | body { 66 | @apply bg-background text-foreground; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /app/(preview)/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.css"; 2 | import { Metadata } from "next"; 3 | import { Toaster } from "sonner"; 4 | import { ThemeProvider } from "next-themes"; 5 | import { Geist } from "next/font/google"; 6 | 7 | const geist = Geist({ subsets: ["latin"] }); 8 | 9 | export const metadata: Metadata = { 10 | metadataBase: new URL("https://ai-sdk-preview-pdf-support.vercel.app"), 11 | title: "PDF Support Preview", 12 | description: "Experimental preview of PDF support with the AI SDK", 13 | }; 14 | 15 | export default function RootLayout({ 16 | children, 17 | }: Readonly<{ 18 | children: React.ReactNode; 19 | }>) { 20 | return ( 21 | 22 | 23 | 24 | 25 | {children} 26 | 27 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /app/(preview)/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/ai-sdk-preview-pdf-support/daeee86dd41b33a7de422d4f23eb2b78a7348647/app/(preview)/opengraph-image.png -------------------------------------------------------------------------------- /app/(preview)/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { experimental_useObject } from "ai/react"; 5 | import { questionsSchema } from "@/lib/schemas"; 6 | import { z } from "zod"; 7 | import { toast } from "sonner"; 8 | import { FileUp, Plus, Loader2 } from "lucide-react"; 9 | import { Button } from "@/components/ui/button"; 10 | import { 11 | Card, 12 | CardContent, 13 | CardFooter, 14 | CardHeader, 15 | CardTitle, 16 | CardDescription, 17 | } from "@/components/ui/card"; 18 | import { Progress } from "@/components/ui/progress"; 19 | import Quiz from "@/components/quiz"; 20 | import { Link } from "@/components/ui/link"; 21 | import NextLink from "next/link"; 22 | import { generateQuizTitle } from "./actions"; 23 | import { AnimatePresence, motion } from "framer-motion"; 24 | import { VercelIcon, GitIcon } from "@/components/icons"; 25 | 26 | export default function ChatWithFiles() { 27 | const [files, setFiles] = useState([]); 28 | const [questions, setQuestions] = useState>( 29 | [], 30 | ); 31 | const [isDragging, setIsDragging] = useState(false); 32 | const [title, setTitle] = useState(); 33 | 34 | const { 35 | submit, 36 | object: partialQuestions, 37 | isLoading, 38 | } = experimental_useObject({ 39 | api: "/api/generate-quiz", 40 | schema: questionsSchema, 41 | initialValue: undefined, 42 | onError: (error) => { 43 | toast.error("Failed to generate quiz. Please try again."); 44 | setFiles([]); 45 | }, 46 | onFinish: ({ object }) => { 47 | setQuestions(object ?? []); 48 | }, 49 | }); 50 | 51 | const handleFileChange = (e: React.ChangeEvent) => { 52 | const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); 53 | 54 | if (isSafari && isDragging) { 55 | toast.error( 56 | "Safari does not support drag & drop. Please use the file picker.", 57 | ); 58 | return; 59 | } 60 | 61 | const selectedFiles = Array.from(e.target.files || []); 62 | const validFiles = selectedFiles.filter( 63 | (file) => file.type === "application/pdf" && file.size <= 5 * 1024 * 1024, 64 | ); 65 | console.log(validFiles); 66 | 67 | if (validFiles.length !== selectedFiles.length) { 68 | toast.error("Only PDF files under 5MB are allowed."); 69 | } 70 | 71 | setFiles(validFiles); 72 | }; 73 | 74 | const encodeFileAsBase64 = (file: File): Promise => { 75 | return new Promise((resolve, reject) => { 76 | const reader = new FileReader(); 77 | reader.readAsDataURL(file); 78 | reader.onload = () => resolve(reader.result as string); 79 | reader.onerror = (error) => reject(error); 80 | }); 81 | }; 82 | 83 | const handleSubmitWithFiles = async (e: React.FormEvent) => { 84 | e.preventDefault(); 85 | const encodedFiles = await Promise.all( 86 | files.map(async (file) => ({ 87 | name: file.name, 88 | type: file.type, 89 | data: await encodeFileAsBase64(file), 90 | })), 91 | ); 92 | submit({ files: encodedFiles }); 93 | const generatedTitle = await generateQuizTitle(encodedFiles[0].name); 94 | setTitle(generatedTitle); 95 | }; 96 | 97 | const clearPDF = () => { 98 | setFiles([]); 99 | setQuestions([]); 100 | }; 101 | 102 | const progress = partialQuestions ? (partialQuestions.length / 4) * 100 : 0; 103 | 104 | if (questions.length === 4) { 105 | return ( 106 | 107 | ); 108 | } 109 | 110 | return ( 111 |
{ 114 | e.preventDefault(); 115 | setIsDragging(true); 116 | }} 117 | onDragExit={() => setIsDragging(false)} 118 | onDragEnd={() => setIsDragging(false)} 119 | onDragLeave={() => setIsDragging(false)} 120 | onDrop={(e) => { 121 | e.preventDefault(); 122 | setIsDragging(false); 123 | console.log(e.dataTransfer.files); 124 | handleFileChange({ 125 | target: { files: e.dataTransfer.files }, 126 | } as React.ChangeEvent); 127 | }} 128 | > 129 | 130 | {isDragging && ( 131 | 137 |
Drag and drop files here
138 |
139 | {"(PDFs only)"} 140 |
141 |
142 | )} 143 |
144 | 145 | 146 |
147 |
148 | 149 |
150 | 151 |
152 | 153 |
154 |
155 |
156 | 157 | PDF Quiz Generator 158 | 159 | 160 | Upload a PDF to generate an interactive quiz based on its content 161 | using the AI SDK and{" "} 162 | 163 | Google's Gemini Pro 164 | 165 | . 166 | 167 |
168 |
169 | 170 |
171 |
174 | 180 | 181 |

182 | {files.length > 0 ? ( 183 | 184 | {files[0].name} 185 | 186 | ) : ( 187 | Drop your PDF here or click to browse. 188 | )} 189 |

190 |
191 | 205 |
206 |
207 | {isLoading && ( 208 | 209 |
210 |
211 | Progress 212 | {Math.round(progress)}% 213 |
214 | 215 |
216 |
217 |
218 |
223 | 224 | {partialQuestions 225 | ? `Generating question ${partialQuestions.length + 1} of 4` 226 | : "Analyzing PDF content"} 227 | 228 |
229 |
230 | 231 | )} 232 | 233 | 238 | 243 | 244 | View Source Code 245 | 246 | 247 | 252 | 253 | Deploy with Vercel 254 | 255 | 256 |
257 | ); 258 | } 259 | -------------------------------------------------------------------------------- /app/(preview)/twitter-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/ai-sdk-preview-pdf-support/daeee86dd41b33a7de422d4f23eb2b78a7348647/app/(preview)/twitter-image.png -------------------------------------------------------------------------------- /app/api/generate-quiz/route.ts: -------------------------------------------------------------------------------- 1 | import { questionSchema, questionsSchema } from "@/lib/schemas"; 2 | import { google } from "@ai-sdk/google"; 3 | import { streamObject } from "ai"; 4 | 5 | export const maxDuration = 60; 6 | 7 | export async function POST(req: Request) { 8 | const { files } = await req.json(); 9 | const firstFile = files[0].data; 10 | 11 | const result = streamObject({ 12 | model: google("gemini-1.5-pro-latest"), 13 | messages: [ 14 | { 15 | role: "system", 16 | content: 17 | "You are a teacher. Your job is to take a document, and create a multiple choice test (with 4 questions) based on the content of the document. Each option should be roughly equal in length.", 18 | }, 19 | { 20 | role: "user", 21 | content: [ 22 | { 23 | type: "text", 24 | text: "Create a multiple choice test based on this document.", 25 | }, 26 | { 27 | type: "file", 28 | data: firstFile, 29 | mimeType: "application/pdf", 30 | }, 31 | ], 32 | }, 33 | ], 34 | schema: questionSchema, 35 | output: "array", 36 | onFinish: ({ object }) => { 37 | const res = questionsSchema.safeParse(object); 38 | if (res.error) { 39 | throw new Error(res.error.errors.map((e) => e.message).join("\n")); 40 | } 41 | }, 42 | }); 43 | 44 | return result.toTextStreamResponse(); 45 | } 46 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/ai-sdk-preview-pdf-support/daeee86dd41b33a7de422d4f23eb2b78a7348647/app/favicon.ico -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/(preview)/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /components/icons.tsx: -------------------------------------------------------------------------------- 1 | export const BotIcon = () => { 2 | return ( 3 | 10 | 16 | 17 | ); 18 | }; 19 | 20 | export const UserIcon = () => { 21 | return ( 22 | 30 | 36 | 37 | ); 38 | }; 39 | 40 | export const AttachmentIcon = () => { 41 | return ( 42 | 49 | 55 | 56 | ); 57 | }; 58 | 59 | // export const VercelIcon = () => { 60 | // return ( 61 | // 68 | // 74 | // 75 | // ); 76 | // }; 77 | 78 | export const VercelIcon = ({ size = 18 }: { size: number }) => { 79 | return ( 80 | 87 | 93 | 94 | ); 95 | }; 96 | 97 | export const GitIcon = () => { 98 | return ( 99 | 106 | 107 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | ); 121 | }; -------------------------------------------------------------------------------- /components/markdown.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactMarkdown from "react-markdown"; 3 | import remarkGfm from "remark-gfm"; 4 | 5 | export const NonMemoizedMarkdown = ({ children }: { children: string }) => { 6 | const components = { 7 | code: ({ node, inline, className, children, ...props }: any) => { 8 | const match = /language-(\w+)/.exec(className || ""); 9 | return !inline && match ? ( 10 |
14 |           {children}
15 |         
16 | ) : ( 17 | 21 | {children} 22 | 23 | ); 24 | }, 25 | ol: ({ node, children, ...props }: any) => { 26 | return ( 27 |
    28 | {children} 29 |
30 | ); 31 | }, 32 | li: ({ node, children, ...props }: any) => { 33 | return ( 34 |
  • 35 | {children} 36 |
  • 37 | ); 38 | }, 39 | ul: ({ node, children, ...props }: any) => { 40 | return ( 41 |
      42 | {children} 43 |
    44 | ); 45 | }, 46 | }; 47 | 48 | return ( 49 | 50 | {children} 51 | 52 | ); 53 | }; 54 | 55 | export const Markdown = React.memo( 56 | NonMemoizedMarkdown, 57 | (prevProps, nextProps) => prevProps.children === nextProps.children 58 | ); 59 | -------------------------------------------------------------------------------- /components/quiz-overview.tsx: -------------------------------------------------------------------------------- 1 | import { Check, X } from 'lucide-react' 2 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" 3 | import { ScrollArea } from "@/components/ui/scroll-area" 4 | import { Question } from '@/lib/schemas' 5 | 6 | 7 | interface QuizReviewProps { 8 | questions: Question[] 9 | userAnswers: string[] 10 | } 11 | 12 | export default function QuizReview({ questions, userAnswers }: QuizReviewProps) { 13 | const answerLabels: ("A" | "B" | "C" | "D")[] = ["A", "B", "C", "D"] 14 | 15 | return ( 16 | 17 | 18 | Quiz Review 19 | 20 | 21 | {questions.map((question, questionIndex) => ( 22 |
    23 |

    {question.question}

    24 |
    25 | {question.options.map((option, optionIndex) => { 26 | const currentLabel = answerLabels[optionIndex] 27 | const isCorrect = currentLabel === question.answer 28 | const isSelected = currentLabel === userAnswers[questionIndex] 29 | const isIncorrectSelection = isSelected && !isCorrect 30 | 31 | return ( 32 |
    42 | {currentLabel} 43 | {option} 44 | {isCorrect && ( 45 | 46 | )} 47 | {isIncorrectSelection && ( 48 | 49 | )} 50 |
    51 | ) 52 | })} 53 |
    54 |
    55 | ))} 56 |
    57 |
    58 | ) 59 | } 60 | 61 | -------------------------------------------------------------------------------- /components/quiz.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { motion, AnimatePresence } from "framer-motion"; 3 | import { Button } from "@/components/ui/button"; 4 | import { Progress } from "@/components/ui/progress"; 5 | import { 6 | ChevronLeft, 7 | ChevronRight, 8 | Check, 9 | X, 10 | RefreshCw, 11 | FileText, 12 | } from "lucide-react"; 13 | import QuizScore from "./score"; 14 | import QuizReview from "./quiz-overview"; 15 | import { Question } from "@/lib/schemas"; 16 | 17 | type QuizProps = { 18 | questions: Question[]; 19 | clearPDF: () => void; 20 | title: string; 21 | }; 22 | 23 | const QuestionCard: React.FC<{ 24 | question: Question; 25 | selectedAnswer: string | null; 26 | onSelectAnswer: (answer: string) => void; 27 | isSubmitted: boolean; 28 | showCorrectAnswer: boolean; 29 | }> = ({ question, selectedAnswer, onSelectAnswer, showCorrectAnswer }) => { 30 | const answerLabels = ["A", "B", "C", "D"]; 31 | 32 | return ( 33 |
    34 |

    35 | {question.question} 36 |

    37 |
    38 | {question.options.map((option, index) => ( 39 | 69 | ))} 70 |
    71 |
    72 | ); 73 | }; 74 | 75 | export default function Quiz({ 76 | questions, 77 | clearPDF, 78 | title = "Quiz", 79 | }: QuizProps) { 80 | const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); 81 | const [answers, setAnswers] = useState( 82 | Array(questions.length).fill(null), 83 | ); 84 | const [isSubmitted, setIsSubmitted] = useState(false); 85 | const [score, setScore] = useState(null); 86 | const [progress, setProgress] = useState(0); 87 | 88 | useEffect(() => { 89 | const timer = setTimeout(() => { 90 | setProgress((currentQuestionIndex / questions.length) * 100); 91 | }, 100); 92 | return () => clearTimeout(timer); 93 | }, [currentQuestionIndex, questions.length]); 94 | 95 | const handleSelectAnswer = (answer: string) => { 96 | if (!isSubmitted) { 97 | const newAnswers = [...answers]; 98 | newAnswers[currentQuestionIndex] = answer; 99 | setAnswers(newAnswers); 100 | } 101 | }; 102 | 103 | const handleNextQuestion = () => { 104 | if (currentQuestionIndex < questions.length - 1) { 105 | setCurrentQuestionIndex(currentQuestionIndex + 1); 106 | } else { 107 | handleSubmit(); 108 | } 109 | }; 110 | 111 | const handlePreviousQuestion = () => { 112 | if (currentQuestionIndex > 0) { 113 | setCurrentQuestionIndex(currentQuestionIndex - 1); 114 | } 115 | }; 116 | 117 | const handleSubmit = () => { 118 | setIsSubmitted(true); 119 | const correctAnswers = questions.reduce((acc, question, index) => { 120 | return acc + (question.answer === answers[index] ? 1 : 0); 121 | }, 0); 122 | setScore(correctAnswers); 123 | }; 124 | 125 | const handleReset = () => { 126 | setAnswers(Array(questions.length).fill(null)); 127 | setIsSubmitted(false); 128 | setScore(null); 129 | setCurrentQuestionIndex(0); 130 | setProgress(0); 131 | }; 132 | 133 | const currentQuestion = questions[currentQuestionIndex]; 134 | 135 | return ( 136 |
    137 |
    138 |

    139 | {title} 140 |

    141 |
    142 | {!isSubmitted && } 143 |
    144 | {" "} 145 | {/* Prevent layout shift */} 146 | 147 | 154 | {!isSubmitted ? ( 155 |
    156 | 163 |
    164 | 171 | 172 | {currentQuestionIndex + 1} / {questions.length} 173 | 174 | 184 |
    185 |
    186 | ) : ( 187 |
    188 | 192 |
    193 | 194 |
    195 |
    196 | 203 | 209 |
    210 |
    211 | )} 212 |
    213 |
    214 |
    215 |
    216 |
    217 |
    218 | ); 219 | } 220 | -------------------------------------------------------------------------------- /components/score.tsx: -------------------------------------------------------------------------------- 1 | import { Progress } from "@/components/ui/progress" 2 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" 3 | 4 | interface QuizScoreProps { 5 | correctAnswers: number 6 | totalQuestions: number 7 | } 8 | 9 | export default function QuizScore({ correctAnswers, totalQuestions }: QuizScoreProps) { 10 | const score = (correctAnswers / totalQuestions) * 100 11 | const roundedScore = Math.round(score) 12 | 13 | const getMessage = () => { 14 | if (score === 100) return "Perfect score! Congratulations!" 15 | if (score >= 80) return "Great job! You did excellently!" 16 | if (score >= 60) return "Good effort! You're on the right track." 17 | if (score >= 40) return "Not bad, but there's room for improvement." 18 | return "Keep practicing, you'll get better!" 19 | } 20 | 21 | return ( 22 | 23 | 24 |
    25 |

    {roundedScore}%

    26 |

    27 | {correctAnswers} out of {totalQuestions} correct 28 |

    29 |
    30 |

    {getMessage()}

    31 |
    32 |
    33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /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-md 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 shadow 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 shadow 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 gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground border border-secondary shadow-sm hover:bg-secondary/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-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 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 | HTMLDivElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |
    41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLDivElement, 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 { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | 17 | ) 18 | } 19 | ) 20 | Input.displayName = "Input" 21 | 22 | export { Input } 23 | -------------------------------------------------------------------------------- /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/link.tsx: -------------------------------------------------------------------------------- 1 | import NextLink, { LinkProps as NextLinkProps } from "next/link"; 2 | 3 | export const Link = ({ 4 | children, 5 | ...props 6 | }: NextLinkProps & { children: React.ReactNode }) => { 7 | return ( 8 | 13 | {children} 14 | 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /components/ui/progress.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as ProgressPrimitive from "@radix-ui/react-progress" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Progress = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, value, ...props }, ref) => ( 12 | 20 | 24 | 25 | )) 26 | Progress.displayName = ProgressPrimitive.Root.displayName 27 | 28 | export { Progress } 29 | -------------------------------------------------------------------------------- /components/ui/radio-group.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" 5 | import { Circle } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const RadioGroup = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => { 13 | return ( 14 | 19 | ) 20 | }) 21 | RadioGroup.displayName = RadioGroupPrimitive.Root.displayName 22 | 23 | const RadioGroupItem = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => { 27 | return ( 28 | 36 | 37 | 38 | 39 | 40 | ) 41 | }) 42 | RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName 43 | 44 | export { RadioGroup, RadioGroupItem } 45 | -------------------------------------------------------------------------------- /components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const ScrollArea = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, children, ...props }, ref) => ( 12 | 17 | 18 | {children} 19 | 20 | 21 | 22 | 23 | )) 24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName 25 | 26 | const ScrollBar = React.forwardRef< 27 | React.ElementRef, 28 | React.ComponentPropsWithoutRef 29 | >(({ className, orientation = "vertical", ...props }, ref) => ( 30 | 43 | 44 | 45 | )) 46 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName 47 | 48 | export { ScrollArea, ScrollBar } 49 | -------------------------------------------------------------------------------- /lib/schemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const questionSchema = z.object({ 4 | question: z.string(), 5 | options: z 6 | .array(z.string()) 7 | .length(4) 8 | .describe( 9 | "Four possible answers to the question. Only one should be correct. They should all be of equal lengths.", 10 | ), 11 | answer: z 12 | .enum(["A", "B", "C", "D"]) 13 | .describe( 14 | "The correct answer, where A is the first option, B is the second, and so on.", 15 | ), 16 | }); 17 | 18 | export type Question = z.infer; 19 | 20 | export const questionsSchema = z.array(questionSchema).length(4); 21 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "three-three-blog", 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 | "@ai-sdk/anthropic": "^1.0.5", 13 | "@ai-sdk/google": "^1.0.9", 14 | "@ai-sdk/openai": "^1.0.8", 15 | "@radix-ui/react-label": "^2.1.0", 16 | "@radix-ui/react-progress": "^1.1.0", 17 | "@radix-ui/react-radio-group": "^1.2.1", 18 | "@radix-ui/react-scroll-area": "^1.2.1", 19 | "@radix-ui/react-slot": "^1.1.0", 20 | "@vercel/analytics": "^1.4.1", 21 | "@vercel/kv": "^3.0.0", 22 | "ai": "^4.0.16", 23 | "class-variance-authority": "^0.7.1", 24 | "clsx": "^2.1.1", 25 | "framer-motion": "^11.14.1", 26 | "lucide-react": "^0.468.0", 27 | "next": "15.1.0", 28 | "next-themes": "^0.4.4", 29 | "react": "^19.0.0", 30 | "react-dom": "^19.0.0", 31 | "react-markdown": "^9.0.1", 32 | "remark-gfm": "^4.0.0", 33 | "sonner": "^1.7.1", 34 | "tailwind-merge": "^2.5.5", 35 | "tailwindcss-animate": "^1.0.7", 36 | "zod": "^3.24.1" 37 | }, 38 | "devDependencies": { 39 | "@types/node": "^22.10.2", 40 | "@types/react": "^19.0.1", 41 | "@types/react-dom": "^19.0.2", 42 | "eslint": "^9.16.0", 43 | "eslint-config-next": "15.1.0", 44 | "postcss": "^8.4.49", 45 | "tailwindcss": "^3.4.16", 46 | "typescript": "^5.7.2" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | darkMode: ["class"], 5 | content: [ 6 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 8 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 9 | ], 10 | theme: { 11 | extend: { 12 | backgroundImage: { 13 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 14 | 'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))' 15 | }, 16 | borderRadius: { 17 | lg: 'var(--radius)', 18 | md: 'calc(var(--radius) - 2px)', 19 | sm: 'calc(var(--radius) - 4px)' 20 | }, 21 | colors: { 22 | background: 'hsl(var(--background))', 23 | foreground: 'hsl(var(--foreground))', 24 | card: { 25 | DEFAULT: 'hsl(var(--card))', 26 | foreground: 'hsl(var(--card-foreground))' 27 | }, 28 | popover: { 29 | DEFAULT: 'hsl(var(--popover))', 30 | foreground: 'hsl(var(--popover-foreground))' 31 | }, 32 | primary: { 33 | DEFAULT: 'hsl(var(--primary))', 34 | foreground: 'hsl(var(--primary-foreground))' 35 | }, 36 | secondary: { 37 | DEFAULT: 'hsl(var(--secondary))', 38 | foreground: 'hsl(var(--secondary-foreground))' 39 | }, 40 | muted: { 41 | DEFAULT: 'hsl(var(--muted))', 42 | foreground: 'hsl(var(--muted-foreground))' 43 | }, 44 | accent: { 45 | DEFAULT: 'hsl(var(--accent))', 46 | foreground: 'hsl(var(--accent-foreground))' 47 | }, 48 | destructive: { 49 | DEFAULT: 'hsl(var(--destructive))', 50 | foreground: 'hsl(var(--destructive-foreground))' 51 | }, 52 | border: 'hsl(var(--border))', 53 | input: 'hsl(var(--input))', 54 | ring: 'hsl(var(--ring))', 55 | chart: { 56 | '1': 'hsl(var(--chart-1))', 57 | '2': 'hsl(var(--chart-2))', 58 | '3': 'hsl(var(--chart-3))', 59 | '4': 'hsl(var(--chart-4))', 60 | '5': 'hsl(var(--chart-5))' 61 | } 62 | } 63 | } 64 | }, 65 | plugins: [require("tailwindcss-animate")], 66 | }; 67 | export default config; 68 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | --------------------------------------------------------------------------------