├── .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 | [](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 |
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 |
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 | onSelectAnswer(answerLabels[index])}
54 | >
55 |
56 | {answerLabels[index]}
57 |
58 | {option}
59 | {(showCorrectAnswer && answerLabels[index] === question.answer) ||
60 | (selectedAnswer === answerLabels[index] && (
61 |
62 | ))}
63 | {showCorrectAnswer &&
64 | selectedAnswer === answerLabels[index] &&
65 | selectedAnswer !== question.answer && (
66 |
67 | )}
68 |
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 |
169 | Previous
170 |
171 |
172 | {currentQuestionIndex + 1} / {questions.length}
173 |
174 |
179 | {currentQuestionIndex === questions.length - 1
180 | ? "Submit"
181 | : "Next"}{" "}
182 |
183 |
184 |
185 |
186 | ) : (
187 |
188 |
192 |
193 |
194 |
195 |
196 |
201 | Reset Quiz
202 |
203 |
207 | Try Another PDF
208 |
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 |
--------------------------------------------------------------------------------