├── app ├── favicon.ico ├── layout.tsx ├── api │ └── recommendations │ │ └── route.ts ├── globals.css └── page.tsx ├── postcss.config.mjs ├── public ├── vercel.svg ├── window.svg ├── file.svg ├── globe.svg └── next.svg ├── next.config.ts ├── lib └── utils.ts ├── components.json ├── .gitignore ├── tsconfig.json ├── components └── ui │ ├── label.tsx │ ├── input.tsx │ ├── tabs.tsx │ ├── button.tsx │ └── card.tsx ├── biome.json ├── README.md └── package.json /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dapoadedire/movie-and-book-recommender/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /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": "", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "iconLibrary": "lucide", 14 | "aliases": { 15 | "components": "@/components", 16 | "utils": "@/lib/utils", 17 | "ui": "@/components/ui", 18 | "lib": "@/lib", 19 | "hooks": "@/hooks" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.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.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 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 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Label({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | ) 22 | } 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Geist, Geist_Mono } from "next/font/google"; 3 | import "./globals.css"; 4 | 5 | const geistSans = Geist({ 6 | variable: "--font-geist-sans", 7 | subsets: ["latin"], 8 | }); 9 | 10 | const geistMono = Geist_Mono({ 11 | variable: "--font-geist-mono", 12 | subsets: ["latin"], 13 | }); 14 | 15 | export const metadata: Metadata = { 16 | title: "Create Next App", 17 | description: "Generated by create next app", 18 | }; 19 | 20 | export default function RootLayout({ 21 | children, 22 | }: Readonly<{ 23 | children: React.ReactNode; 24 | }>) { 25 | return ( 26 | 27 | 30 | {children} 31 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.2.0/schema.json", 3 | "vcs": { 4 | "enabled": true, 5 | "clientKind": "git", 6 | "useIgnoreFile": true 7 | }, 8 | "files": { 9 | "ignoreUnknown": true, 10 | "includes": ["**", "!node_modules", "!.next", "!dist", "!build"] 11 | }, 12 | "formatter": { 13 | "enabled": true, 14 | "indentStyle": "space", 15 | "indentWidth": 2 16 | }, 17 | "linter": { 18 | "enabled": true, 19 | "rules": { 20 | "recommended": true, 21 | "suspicious": { 22 | "noUnknownAtRules": "off" 23 | } 24 | }, 25 | "domains": { 26 | "next": "recommended", 27 | "react": "recommended" 28 | } 29 | }, 30 | "assist": { 31 | "actions": { 32 | "source": { 33 | "organizeImports": "on" 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) { 6 | return ( 7 | 18 | ) 19 | } 20 | 21 | export { Input } 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Movie & Book Recommender 2 | 3 | A simple web application that recommends movies and books based on your favorites. 4 | 5 | ## Features 6 | 7 | - Get personalized movie recommendations 8 | - Get personalized book recommendations 9 | - Clean, modern interface with tabs 10 | - Responsive design 11 | 12 | ## Tech Stack 13 | 14 | - **Framework:** Next.js 15 15 | - **Language:** TypeScript 16 | - **Styling:** Tailwind CSS 17 | - **UI Components:** shadcn/ui 18 | - **AI:** Vercel AI SDK with OpenAI 19 | 20 | ## Getting Started 21 | 22 | 1. Install dependencies: 23 | 24 | ```bash 25 | npm install 26 | ``` 27 | 28 | 2. Run the development server: 29 | 30 | ```bash 31 | npm run dev 32 | ``` 33 | 34 | 3. Open [http://localhost:3000](http://localhost:3000) in your browser. 35 | 36 | ## Usage 37 | 38 | 1. Choose between Books or Movies tab 39 | 2. Enter your favorite books/movies (comma-separated) 40 | 3. Specify how many recommendations you want (1-10) 41 | 4. Click "Generate Recommendations" 42 | 5. Browse your personalized suggestions 43 | 44 | ## Contributing 45 | 46 | Feel free to submit issues and enhancement requests. 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "movie-and-book-recommender", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build --turbopack", 8 | "start": "next start", 9 | "lint": "biome check", 10 | "format": "biome format --write" 11 | }, 12 | "dependencies": { 13 | "@ai-sdk/openai": "^2.0.22", 14 | "@hookform/resolvers": "^5.2.1", 15 | "@radix-ui/react-label": "^2.1.7", 16 | "@radix-ui/react-slot": "^1.2.3", 17 | "@radix-ui/react-tabs": "^1.1.13", 18 | "ai": "^5.0.27", 19 | "class-variance-authority": "^0.7.1", 20 | "clsx": "^2.1.1", 21 | "lucide-react": "^0.542.0", 22 | "next": "15.5.2", 23 | "react": "19.1.0", 24 | "react-dom": "19.1.0", 25 | "react-hook-form": "^7.62.0", 26 | "tailwind-merge": "^3.3.1", 27 | "zod": "^4.1.4" 28 | }, 29 | "devDependencies": { 30 | "@biomejs/biome": "2.2.0", 31 | "@tailwindcss/postcss": "^4", 32 | "@types/node": "^20", 33 | "@types/react": "^19", 34 | "@types/react-dom": "^19", 35 | "tailwindcss": "^4", 36 | "tw-animate-css": "^1.3.7", 37 | "typescript": "^5" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TabsPrimitive from "@radix-ui/react-tabs" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Tabs({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 18 | ) 19 | } 20 | 21 | function TabsList({ 22 | className, 23 | ...props 24 | }: React.ComponentProps) { 25 | return ( 26 | 34 | ) 35 | } 36 | 37 | function TabsTrigger({ 38 | className, 39 | ...props 40 | }: React.ComponentProps) { 41 | return ( 42 | 50 | ) 51 | } 52 | 53 | function TabsContent({ 54 | className, 55 | ...props 56 | }: React.ComponentProps) { 57 | return ( 58 | 63 | ) 64 | } 65 | 66 | export { Tabs, TabsList, TabsTrigger, TabsContent } 67 | -------------------------------------------------------------------------------- /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-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 16 | outline: 17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: 21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 22 | link: "text-primary underline-offset-4 hover:underline", 23 | }, 24 | size: { 25 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 28 | icon: "size-9", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ) 37 | 38 | function Button({ 39 | className, 40 | variant, 41 | size, 42 | asChild = false, 43 | ...props 44 | }: React.ComponentProps<"button"> & 45 | VariantProps & { 46 | asChild?: boolean 47 | }) { 48 | const Comp = asChild ? Slot : "button" 49 | 50 | return ( 51 | 56 | ) 57 | } 58 | 59 | export { Button, buttonVariants } 60 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Card({ className, ...props }: React.ComponentProps<"div">) { 6 | return ( 7 | 15 | ) 16 | } 17 | 18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) { 19 | return ( 20 | 28 | ) 29 | } 30 | 31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) { 32 | return ( 33 | 38 | ) 39 | } 40 | 41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) { 42 | return ( 43 | 48 | ) 49 | } 50 | 51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) { 52 | return ( 53 | 61 | ) 62 | } 63 | 64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) { 65 | return ( 66 | 71 | ) 72 | } 73 | 74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) { 75 | return ( 76 | 81 | ) 82 | } 83 | 84 | export { 85 | Card, 86 | CardHeader, 87 | CardFooter, 88 | CardTitle, 89 | CardAction, 90 | CardDescription, 91 | CardContent, 92 | } 93 | -------------------------------------------------------------------------------- /app/api/recommendations/route.ts: -------------------------------------------------------------------------------- 1 | // app/api/recommendations/route.ts (for App Router) 2 | // OR pages/api/recommendations.ts (for Pages Router) 3 | 4 | import { openai } from "@ai-sdk/openai"; 5 | import { generateObject } from "ai"; 6 | import { z } from "zod"; 7 | import { NextRequest, NextResponse } from "next/server"; 8 | 9 | // Response schemas - individual items 10 | const bookSchema = z.object({ 11 | name: z.string(), 12 | author: z.string(), 13 | genre: z.string(), 14 | summary: z.string(), 15 | link: z.string(), 16 | }); 17 | 18 | const movieSchema = z.object({ 19 | title: z.string(), 20 | director: z.string(), 21 | genre: z.string(), 22 | summary: z.string(), 23 | link: z.string(), 24 | }); 25 | 26 | // Single recommendation schemas (wrapped in object) 27 | const singleBookRecommendationSchema = z.object({ 28 | book: bookSchema, 29 | }); 30 | 31 | const singleMovieRecommendationSchema = z.object({ 32 | movie: movieSchema, 33 | }); 34 | 35 | // Multiple recommendations schemas (wrapped in object with array) 36 | const multipleBooksRecommendationSchema = z.object({ 37 | books: z.array(bookSchema), 38 | }); 39 | 40 | const multipleMoviesRecommendationSchema = z.object({ 41 | movies: z.array(movieSchema), 42 | }); 43 | 44 | // Request body validation schema 45 | const requestSchema = z.object({ 46 | favorites: z.array(z.string()), 47 | count: z.coerce.number().int().min(1).max(10), 48 | type: z.enum(["movie", "book"]), 49 | }); 50 | 51 | export async function POST(req: NextRequest) { 52 | try { 53 | // Parse and validate request body 54 | const body = await req.json(); 55 | const { favorites, count, type } = requestSchema.parse(body); 56 | 57 | // Check for API key 58 | if (!process.env.OPENAI_API_KEY) { 59 | return NextResponse.json( 60 | { error: "OpenAI API key is not configured" }, 61 | { status: 500 } 62 | ); 63 | } 64 | 65 | // Generate the prompt based on user input 66 | const prompt = `Generate ${count} ${type} recommendation${ 67 | count > 1 ? "s" : "" 68 | } based on the following favorite ${type}${ 69 | favorites.length > 1 ? "s" : "" 70 | }: ${favorites.join(", ")}. 71 | 72 | For each recommendation, include: 73 | - ${ 74 | type === "book" 75 | ? "name, author, genre, summary" 76 | : "title, director, genre, summary" 77 | } 78 | - link: A valid URL to the ${type} on ${ 79 | type === "book" ? "Goodreads or Amazon" : "IMDb or TMDB" 80 | } 81 | 82 | Please provide detailed and accurate information for each recommendation.`; 83 | 84 | let recommendations; 85 | 86 | if (type === "book") { 87 | if (count === 1) { 88 | const { object: bookRec } = await generateObject({ 89 | model: openai("gpt-4o"), 90 | schema: singleBookRecommendationSchema, 91 | prompt, 92 | }); 93 | recommendations = [{ book: bookRec.book }]; 94 | } else { 95 | const { object: bookRecs } = await generateObject({ 96 | model: openai("gpt-4o"), 97 | schema: multipleBooksRecommendationSchema, 98 | prompt, 99 | }); 100 | recommendations = bookRecs.books.map((book) => ({ book })); 101 | } 102 | } else { 103 | if (count === 1) { 104 | const { object: movieRec } = await generateObject({ 105 | model: openai("gpt-4o"), 106 | schema: singleMovieRecommendationSchema, 107 | prompt, 108 | }); 109 | recommendations = [{ movie: movieRec.movie }]; 110 | } else { 111 | const { object: movieRecs } = await generateObject({ 112 | model: openai("gpt-4o"), 113 | schema: multipleMoviesRecommendationSchema, 114 | prompt, 115 | }); 116 | recommendations = movieRecs.movies.map((movie) => ({ movie })); 117 | } 118 | } 119 | 120 | return NextResponse.json({ recommendations }); 121 | } catch (error) { 122 | console.error("Error generating recommendations:", error); 123 | 124 | if (error instanceof z.ZodError) { 125 | return NextResponse.json( 126 | { error: "Invalid request data", details: error.message }, 127 | { status: 400 } 128 | ); 129 | } 130 | 131 | return NextResponse.json( 132 | { error: "Failed to generate recommendations" }, 133 | { status: 500 } 134 | ); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @import "tw-animate-css"; 3 | 4 | @custom-variant dark (&:is(.dark *)); 5 | 6 | /* :root { 7 | --background: #ffffff; 8 | --foreground: #171717; 9 | } */ 10 | 11 | @theme inline { 12 | --font-sans: var(--font-geist-sans); 13 | --font-mono: var(--font-geist-mono); 14 | --color-sidebar-ring: var(--sidebar-ring); 15 | --color-sidebar-border: var(--sidebar-border); 16 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 17 | --color-sidebar-accent: var(--sidebar-accent); 18 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 19 | --color-sidebar-primary: var(--sidebar-primary); 20 | --color-sidebar-foreground: var(--sidebar-foreground); 21 | --color-sidebar: var(--sidebar); 22 | --color-chart-5: var(--chart-5); 23 | --color-chart-4: var(--chart-4); 24 | --color-chart-3: var(--chart-3); 25 | --color-chart-2: var(--chart-2); 26 | --color-chart-1: var(--chart-1); 27 | --color-ring: var(--ring); 28 | --color-input: var(--input); 29 | --color-border: var(--border); 30 | --color-destructive: var(--destructive); 31 | --color-accent-foreground: var(--accent-foreground); 32 | --color-accent: var(--accent); 33 | --color-muted-foreground: var(--muted-foreground); 34 | --color-muted: var(--muted); 35 | --color-secondary-foreground: var(--secondary-foreground); 36 | --color-secondary: var(--secondary); 37 | --color-primary-foreground: var(--primary-foreground); 38 | --color-primary: var(--primary); 39 | --color-popover-foreground: var(--popover-foreground); 40 | --color-popover: var(--popover); 41 | --color-card-foreground: var(--card-foreground); 42 | --color-card: var(--card); 43 | --color-foreground: var(--foreground); 44 | --color-background: var(--background); 45 | --radius-sm: calc(var(--radius) - 4px); 46 | --radius-md: calc(var(--radius) - 2px); 47 | --radius-lg: var(--radius); 48 | --radius-xl: calc(var(--radius) + 4px); 49 | } 50 | 51 | :root { 52 | --radius: 0.625rem; 53 | --background: oklch(1 0 0); 54 | --foreground: oklch(0.145 0 0); 55 | --card: oklch(1 0 0); 56 | --card-foreground: oklch(0.145 0 0); 57 | --popover: oklch(1 0 0); 58 | --popover-foreground: oklch(0.145 0 0); 59 | --primary: oklch(0.205 0 0); 60 | --primary-foreground: oklch(0.985 0 0); 61 | --secondary: oklch(0.97 0 0); 62 | --secondary-foreground: oklch(0.205 0 0); 63 | --muted: oklch(0.97 0 0); 64 | --muted-foreground: oklch(0.556 0 0); 65 | --accent: oklch(0.97 0 0); 66 | --accent-foreground: oklch(0.205 0 0); 67 | --destructive: oklch(0.577 0.245 27.325); 68 | --border: oklch(0.922 0 0); 69 | --input: oklch(0.922 0 0); 70 | --ring: oklch(0.708 0 0); 71 | --chart-1: oklch(0.646 0.222 41.116); 72 | --chart-2: oklch(0.6 0.118 184.704); 73 | --chart-3: oklch(0.398 0.07 227.392); 74 | --chart-4: oklch(0.828 0.189 84.429); 75 | --chart-5: oklch(0.769 0.188 70.08); 76 | --sidebar: oklch(0.985 0 0); 77 | --sidebar-foreground: oklch(0.145 0 0); 78 | --sidebar-primary: oklch(0.205 0 0); 79 | --sidebar-primary-foreground: oklch(0.985 0 0); 80 | --sidebar-accent: oklch(0.97 0 0); 81 | --sidebar-accent-foreground: oklch(0.205 0 0); 82 | --sidebar-border: oklch(0.922 0 0); 83 | --sidebar-ring: oklch(0.708 0 0); 84 | } 85 | 86 | .dark { 87 | --background: oklch(0.145 0 0); 88 | --foreground: oklch(0.985 0 0); 89 | --card: oklch(0.205 0 0); 90 | --card-foreground: oklch(0.985 0 0); 91 | --popover: oklch(0.205 0 0); 92 | --popover-foreground: oklch(0.985 0 0); 93 | --primary: oklch(0.922 0 0); 94 | --primary-foreground: oklch(0.205 0 0); 95 | --secondary: oklch(0.269 0 0); 96 | --secondary-foreground: oklch(0.985 0 0); 97 | --muted: oklch(0.269 0 0); 98 | --muted-foreground: oklch(0.708 0 0); 99 | --accent: oklch(0.269 0 0); 100 | --accent-foreground: oklch(0.985 0 0); 101 | --destructive: oklch(0.704 0.191 22.216); 102 | --border: oklch(1 0 0 / 10%); 103 | --input: oklch(1 0 0 / 15%); 104 | --ring: oklch(0.556 0 0); 105 | --chart-1: oklch(0.488 0.243 264.376); 106 | --chart-2: oklch(0.696 0.17 162.48); 107 | --chart-3: oklch(0.769 0.188 70.08); 108 | --chart-4: oklch(0.627 0.265 303.9); 109 | --chart-5: oklch(0.645 0.246 16.439); 110 | --sidebar: oklch(0.205 0 0); 111 | --sidebar-foreground: oklch(0.985 0 0); 112 | --sidebar-primary: oklch(0.488 0.243 264.376); 113 | --sidebar-primary-foreground: oklch(0.985 0 0); 114 | --sidebar-accent: oklch(0.269 0 0); 115 | --sidebar-accent-foreground: oklch(0.985 0 0); 116 | --sidebar-border: oklch(1 0 0 / 10%); 117 | --sidebar-ring: oklch(0.556 0 0); 118 | } 119 | 120 | @layer base { 121 | * { 122 | @apply border-border outline-ring/50; 123 | } 124 | body { 125 | @apply bg-background text-foreground; 126 | } 127 | } -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | Card, 6 | CardContent, 7 | CardDescription, 8 | CardFooter, 9 | CardHeader, 10 | CardTitle, 11 | } from "@/components/ui/card"; 12 | import { Input } from "@/components/ui/input"; 13 | import { Label } from "@/components/ui/label"; 14 | import { useState } from "react"; 15 | 16 | interface BookRecommendation { 17 | name: string; 18 | author: string; 19 | genre: string; 20 | summary: string; 21 | link: string; 22 | } 23 | 24 | interface MovieRecommendation { 25 | title: string; 26 | director: string; 27 | genre: string; 28 | summary: string; 29 | link: string; 30 | } 31 | 32 | interface Recommendation { 33 | book?: BookRecommendation; 34 | movie?: MovieRecommendation; 35 | } 36 | 37 | export default function New() { 38 | const [favorites, setFavorites] = useState(""); 39 | const [count, setCount] = useState("3"); 40 | const [recommendations, setRecommendations] = useState([]); 41 | const [loading, setLoading] = useState(false); 42 | const [activeTab, setActiveTab] = useState("books"); 43 | const [error, setError] = useState(null); 44 | 45 | const handleSubmit = async (e: React.FormEvent, type: "book" | "movie") => { 46 | e.preventDefault(); 47 | setLoading(true); 48 | setError(null); 49 | 50 | try { 51 | const response = await fetch("/api/recommendations", { 52 | method: "POST", 53 | headers: { 54 | "Content-Type": "application/json", 55 | }, 56 | body: JSON.stringify({ 57 | favorites: favorites 58 | .split(",") 59 | .map((f) => f.trim()) 60 | .filter((f) => f), 61 | count: parseInt(count), 62 | type, 63 | }), 64 | }); 65 | 66 | const data = await response.json(); 67 | if (response.ok) { 68 | setRecommendations(data.recommendations); 69 | } else { 70 | setError(data.error || "Failed to get recommendations"); 71 | } 72 | } catch (error) { 73 | setError("Network error. Please try again."); 74 | console.error("Error:", error); 75 | } finally { 76 | setLoading(false); 77 | } 78 | }; 79 | 80 | const clearRecommendations = () => { 81 | setRecommendations([]); 82 | setError(null); 83 | }; 84 | 85 | const handleTabChange = (value: string) => { 86 | setActiveTab(value); 87 | setRecommendations([]); 88 | setError(null); 89 | }; 90 | 91 | const RecommendationCard = ({ rec }: { rec: Recommendation }) => { 92 | if (rec.book) { 93 | const { name, author, genre, summary, link } = rec.book; 94 | return ( 95 | 96 | 97 | 98 | 99 | 100 | {name} 101 | 102 | 103 | by {author} 104 | 105 | 106 | 107 | {genre} 108 | 109 | 110 | 111 | 112 | {summary} 113 | 114 | 115 | 121 | 127 | 📖 View Book 128 | 129 | 130 | 131 | 132 | ); 133 | } 134 | 135 | if (rec.movie) { 136 | const { title, director, genre, summary, link } = rec.movie; 137 | return ( 138 | 139 | 140 | 141 | 142 | 143 | {title} 144 | 145 | 146 | directed by {director} 147 | 148 | 149 | 150 | {genre} 151 | 152 | 153 | 154 | 155 | {summary} 156 | 157 | 158 | 164 | 170 | 🎬 View Movie 171 | 172 | 173 | 174 | 175 | ); 176 | } 177 | 178 | return null; 179 | }; 180 | 181 | return ( 182 | 183 | 184 | 185 | 186 | 187 | Movie & Book Recommender 188 | 189 | 190 | Discover your next favorite book or movie based on what you love 191 | 192 | 193 | 194 | 199 | 200 | 201 | 205 | 📚 Books 206 | 207 | 211 | 🎬 Movies 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 📚 Book Recommendations 222 | 223 | 224 | Tell us about your favorite books and we'll suggest 225 | similar ones 226 | 227 | 228 | 229 | handleSubmit(e, "book")} 231 | className="space-y-4" 232 | > 233 | 234 | 238 | Your favorite books 239 | 240 | setFavorites(e.target.value)} 246 | required 247 | className="transition-all duration-200 focus:ring-2 focus:ring-blue-500" 248 | /> 249 | 250 | Separate multiple books with commas 251 | 252 | 253 | 254 | 258 | Number of recommendations 259 | 260 | setCount(e.target.value)} 267 | required 268 | className="transition-all duration-200 focus:ring-2 focus:ring-blue-500" 269 | /> 270 | 271 | 276 | {loading ? ( 277 | 278 | 279 | Generating... 280 | 281 | ) : ( 282 | "Get Book Recommendations" 283 | )} 284 | 285 | 286 | 287 | 288 | 289 | 290 | {error && ( 291 | 292 | 293 | 294 | ⚠️ 295 | {error} 296 | 297 | 298 | 299 | )} 300 | 301 | {recommendations.length > 0 && ( 302 | 303 | 304 | 305 | Your Book Recommendations 306 | 307 | 308 | Based on your favorite books 309 | 310 | 315 | Clear Results 316 | 317 | 318 | 319 | {recommendations.map((rec, index) => ( 320 | 321 | ))} 322 | 323 | 324 | )} 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 🎬 Movie Recommendations 333 | 334 | 335 | Tell us about your favorite movies and we'll suggest 336 | similar ones 337 | 338 | 339 | 340 | handleSubmit(e, "movie")} 342 | className="space-y-4" 343 | > 344 | 345 | 349 | Your favorite movies 350 | 351 | setFavorites(e.target.value)} 357 | required 358 | className="transition-all duration-200 focus:ring-2 focus:ring-red-500" 359 | /> 360 | 361 | Separate multiple movies with commas 362 | 363 | 364 | 365 | 369 | Number of recommendations 370 | 371 | setCount(e.target.value)} 378 | required 379 | className="transition-all duration-200 focus:ring-2 focus:ring-red-500" 380 | /> 381 | 382 | 387 | {loading ? ( 388 | 389 | 390 | Generating... 391 | 392 | ) : ( 393 | "Get Movie Recommendations" 394 | )} 395 | 396 | 397 | 398 | 399 | 400 | 401 | {error && ( 402 | 403 | 404 | 405 | ⚠️ 406 | {error} 407 | 408 | 409 | 410 | )} 411 | 412 | {recommendations.length > 0 && ( 413 | 414 | 415 | 416 | Your Movie Recommendations 417 | 418 | 419 | Based on your favorite movies 420 | 421 | 426 | Clear Results 427 | 428 | 429 | 430 | {recommendations.map((rec, index) => ( 431 | 432 | ))} 433 | 434 | 435 | )} 436 | 437 | 438 | 439 | 440 | 441 | ); 442 | } 443 | --------------------------------------------------------------------------------
{summary}
190 | Discover your next favorite book or movie based on what you love 191 |
250 | Separate multiple books with commas 251 |
{error}
308 | Based on your favorite books 309 |
361 | Separate multiple movies with commas 362 |
419 | Based on your favorite movies 420 |