├── .eslintrc.json ├── app ├── favicon.ico ├── (preview) │ ├── twitter-image.png │ ├── opengraph-image.png │ ├── actions.ts │ ├── layout.tsx │ ├── globals.css │ └── page.tsx └── api │ └── generate-quiz │ └── route.ts ├── next.config.mjs ├── postcss.config.mjs ├── lib ├── utils.ts └── schemas.ts ├── components ├── ui │ ├── link.tsx │ ├── label.tsx │ ├── input.tsx │ ├── progress.tsx │ ├── badge.tsx │ ├── radio-group.tsx │ ├── scroll-area.tsx │ ├── card.tsx │ └── button.tsx ├── score.tsx ├── markdown.tsx ├── quiz-overview.tsx ├── icons.tsx └── quiz.tsx ├── components.json ├── .gitignore ├── tsconfig.json ├── package.json ├── README.md └── tailwind.config.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VichuA2/pdf-to-quiz-generator/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /app/(preview)/twitter-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VichuA2/pdf-to-quiz-generator/HEAD/app/(preview)/twitter-image.png -------------------------------------------------------------------------------- /app/(preview)/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VichuA2/pdf-to-quiz-generator/HEAD/app/(preview)/opengraph-image.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.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 | } -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------