├── .editorconfig ├── .env.example ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .prettierignore ├── LICENSE.md ├── README.md ├── app ├── (auth) │ ├── layout.tsx │ ├── sign-in │ │ └── [[...sign-in]] │ │ │ └── page.tsx │ └── sign-up │ │ └── [[...sign-up]] │ │ └── page.tsx ├── (home) │ ├── layout.tsx │ └── page.tsx ├── api │ └── generate-recipe │ │ └── route.ts ├── dashboard │ ├── account │ │ └── page.tsx │ ├── layout.tsx │ └── my-recipes │ │ ├── [id] │ │ ├── loading.tsx │ │ ├── not-found.tsx │ │ └── page.tsx │ │ └── page.tsx ├── layout.tsx ├── recipes │ ├── [id] │ │ ├── loading.tsx │ │ ├── not-found.tsx │ │ └── page.tsx │ └── layout.tsx ├── robots.ts └── sitemap.ts ├── components.json ├── components ├── dashboard │ ├── columns.tsx │ ├── data-table-column-header.tsx │ ├── data-table-faceted-filter.tsx │ ├── data-table-pagination.tsx │ ├── data-table-toolbar.tsx │ ├── data-table-view-options.tsx │ ├── data-table.tsx │ ├── data.ts │ └── user-profile.tsx ├── form │ ├── label-form-field.tsx │ ├── radio-group-form-field.tsx │ ├── recipe-form.tsx │ ├── select-form-field.tsx │ └── switch-form-field.tsx ├── generate-recipe.tsx ├── icons.tsx ├── layout │ ├── main-nav.tsx │ ├── page-header.tsx │ ├── site-footer.tsx │ ├── site-header.tsx │ └── theme-toggle.tsx ├── recent-recipes.tsx ├── recipe │ ├── recipe-card-preview.tsx │ ├── recipe-card-skeleton.tsx │ ├── recipe-card.tsx │ ├── recipe-constants.tsx │ └── save-recipe-button.tsx ├── recipes-counter.tsx ├── theme-provider.tsx └── ui │ ├── avatar.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── card.tsx │ ├── command.tsx │ ├── dialog.tsx │ ├── dropdown-menu.tsx │ ├── form.tsx │ ├── input.tsx │ ├── label.tsx │ ├── popover.tsx │ ├── radio-group.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── skeleton.tsx │ ├── slider.tsx │ ├── switch.tsx │ ├── table.tsx │ ├── toast.tsx │ ├── toaster.tsx │ └── use-toast.ts ├── config └── site.ts ├── lib ├── actions.ts ├── fonts.ts ├── generate-prompt.ts ├── supabase-client.ts ├── supabase-queries.ts └── utils.ts ├── middleware.ts ├── next-env.d.ts ├── next.config.mjs ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── prettier.config.js ├── public ├── favicon.ico └── og.png ├── styles └── globals.css ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.tsbuildinfo └── types ├── database.types.ts └── types.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | OPENAI_API_KEY=your-key 2 | CLERK_SECRET_KEY=your-key 3 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=your-key 4 | NEXT_PUBLIC_SUPABASE_KEY=your-key 5 | NEXT_PUBLIC_SUPABASE_URL=your-supabase-url -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/* 2 | .cache 3 | public 4 | node_modules 5 | *.esm.js 6 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/eslintrc", 3 | "root": true, 4 | "extends": [ 5 | "next/core-web-vitals", 6 | "prettier", 7 | "plugin:tailwindcss/recommended" 8 | ], 9 | "plugins": ["tailwindcss"], 10 | "rules": { 11 | "@next/next/no-html-link-for-pages": "off", 12 | "react/jsx-key": "off", 13 | "tailwindcss/no-custom-classname": "off" 14 | }, 15 | "settings": { 16 | "tailwindcss": { 17 | "callees": ["cn"], 18 | "config": "./tailwind.config.js" 19 | }, 20 | "next": { 21 | "rootDir": ["./"] 22 | } 23 | }, 24 | "overrides": [ 25 | { 26 | "files": ["*.ts", "*.tsx"], 27 | "parser": "@typescript-eslint/parser" 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage 10 | 11 | # next.js 12 | .next/ 13 | out/ 14 | build 15 | 16 | # misc 17 | .DS_Store 18 | *.pem 19 | 20 | # debug 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | .pnpm-debug.log* 25 | 26 | # local env files 27 | .env.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | .env.development 32 | 33 | # turbo 34 | .turbo 35 | 36 | # misc 37 | .contentlayer 38 | .env 39 | .editorconfig 40 | .vscode 41 | .vercel 42 | supabase 43 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | cache 2 | .cache 3 | package.json 4 | package-lock.json 5 | public 6 | CHANGELOG.md 7 | .yarn 8 | dist 9 | node_modules 10 | .next 11 | build 12 | .contentlayer 13 | types/supabase.ts -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 faultyled 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Chef Genie](https://chef-genie.vercel.app) 2 | 3 | An open source recipe generator powered by OpenAi and ChatGPT. 4 | 5 | ![hero](public/og.png) 6 | 7 | > **Warning** 8 | This project is still in development and is not ready for production use. 9 | 10 | ## Features 11 | 12 | - **Framework**: [Next.js](https://nextjs.org/) 14 App Router with Server Actions 13 | - **AI**: [OpenAI - GPT 3.5 Turbo](https://openai.com) with [Vercel AI SDK](https://sdk.vercel.ai) 14 | - **Database**: [Supabase](https://supabase.com/) 15 | - **Authentication**: [Clerk](https://clerk.com/) 16 | - **Styling**: [Tailwind CSS](https://tailwindcss.com/) 17 | - **Primitives**: [Radix UI](https://radix-ui.com/) 18 | - **Components**: [shadcn/ui](https://ui.shadcn.com/) 19 | - **Icons**: [Lucide](https://lucide.dev/) 20 | - **Deployment**: [Vercel](https://vercel.com/) 21 | - **Analytics**: [Vercel Analytics](https://vercel.com/analytics/) 22 | - [Zod](https://zod.dev/) for TypeScript-first schema declaration and validation 23 | - Automatic import sorting with `@ianvs/prettier-plugin-sort-imports` 24 | 25 | ## Running Locally 26 | 27 | 1. Clone the repository and install the dependencies 28 | 29 | ```bash 30 | git clone https://github.com/giacomogaglione/chef-genie.git 31 | cd chef-genie 32 | pnpm install 33 | pnpm dev 34 | ``` 35 | 36 | 2. Copy the `.env.example` to `.env` and update the variables. 37 | 38 | ```bash 39 | cp .env.example .env 40 | ``` 41 | 42 | 3. Start the development server 43 | 44 | ```bash 45 | pnpm run dev 46 | ``` 47 | 48 | ## License 49 | 50 | Licensed under the [MIT license](https://github.com/giacomogaglione/chef-gpt/blob/main/LICENSE.md). 51 | -------------------------------------------------------------------------------- /app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { currentUser } from "@clerk/nextjs" 2 | 3 | import { SiteFooter } from "@/components/layout/site-footer" 4 | import { SiteHeader } from "@/components/layout/site-header" 5 | 6 | interface AuthLayoutProps { 7 | children: React.ReactNode 8 | } 9 | 10 | export default async function AuthLayout({ children }: AuthLayoutProps) { 11 | const user = await currentUser() 12 | 13 | return ( 14 |
15 | 16 |
{children}
17 | 18 |
19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /app/(auth)/sign-in/[[...sign-in]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { type Metadata } from "next" 2 | import { SignIn } from "@clerk/nextjs" 3 | 4 | export const metadata: Metadata = { 5 | metadataBase: new URL("https://chef-genie.app"), 6 | title: "Sign In", 7 | description: "Sign in to your account", 8 | } 9 | 10 | export default function SignInPage() { 11 | return ( 12 |
13 | 14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /app/(auth)/sign-up/[[...sign-up]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { type Metadata } from "next" 2 | import { SignUp } from "@clerk/nextjs" 3 | 4 | export const metadata: Metadata = { 5 | metadataBase: new URL("https://chef-genie.app"), 6 | title: "Sign Up", 7 | description: "Sign up for an account", 8 | } 9 | 10 | export default function SignUpPage() { 11 | return ( 12 |
13 | 14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /app/(home)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { currentUser } from "@clerk/nextjs" 2 | 3 | import { SiteFooter } from "@/components/layout/site-footer" 4 | import { SiteHeader } from "@/components/layout/site-header" 5 | 6 | interface HomeLayoutProps { 7 | children: React.ReactNode 8 | } 9 | 10 | export default async function HomeLayout({ children }: HomeLayoutProps) { 11 | const user = await currentUser() 12 | 13 | return ( 14 |
15 | 16 |
{children}
17 | 18 |
19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /app/(home)/page.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react" 2 | 3 | import { GenerateRecipe } from "@/components/generate-recipe" 4 | import { 5 | PageHeader, 6 | PageHeaderDescription, 7 | PageHeaderHeading, 8 | } from "@/components/layout/page-header" 9 | import { RecentRecipes } from "@/components/recent-recipes" 10 | import { RecipesCounter } from "@/components/recipes-counter" 11 | 12 | export default async function IndexPage() { 13 | return ( 14 |
15 | 16 | 17 | 18 | Say goodbye to mealtime indecision with 19 | 20 | {" Chef Genie"} 21 | 22 | 23 | 24 | Free. Open Source. Recipe generator powered by OpenAI and ChatGPT. 25 | 26 | 27 | 28 | 29 | 30 | 31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /app/api/generate-recipe/route.ts: -------------------------------------------------------------------------------- 1 | import { OpenAIStream, StreamingTextResponse } from "ai" 2 | import OpenAI from "openai" 3 | 4 | export const runtime = "edge" 5 | 6 | const openai = new OpenAI({ 7 | apiKey: process.env.OPENAI_API_KEY!, 8 | }) 9 | 10 | export async function POST(req: Request) { 11 | // Extract the `messages` from the body of the request 12 | const { prompt } = await req.json() 13 | 14 | // Request the OpenAI API for the response based on the prompt 15 | const response = await openai.chat.completions.create({ 16 | model: "gpt-3.5-turbo-1106", 17 | temperature: 0.6, 18 | frequency_penalty: 0.2, 19 | presence_penalty: 0.3, 20 | max_tokens: 700, 21 | stream: true, 22 | n: 1, 23 | messages: [ 24 | { role: "user", content: prompt }, 25 | { role: "system", content: "You are an expert culinary chef" }, 26 | ], 27 | response_format: { type: "json_object" }, 28 | }) 29 | 30 | const stream = OpenAIStream(response) 31 | 32 | return new StreamingTextResponse(stream) 33 | } 34 | -------------------------------------------------------------------------------- /app/dashboard/account/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next" 2 | 3 | import { UserProfile } from "@/components/dashboard/user-profile" 4 | 5 | export const metadata: Metadata = { 6 | metadataBase: new URL("https://chef-genie.app"), 7 | title: "Account", 8 | description: "Manage your account settings", 9 | } 10 | 11 | export default function AccountPage() { 12 | return ( 13 |
14 | 15 |
16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /app/dashboard/layout.tsx: -------------------------------------------------------------------------------- 1 | import { currentUser } from "@clerk/nextjs" 2 | 3 | import { SiteFooter } from "@/components/layout/site-footer" 4 | import { SiteHeader } from "@/components/layout/site-header" 5 | 6 | interface DashboardLayoutProps { 7 | children: React.ReactNode 8 | } 9 | 10 | export default async function DashboardLayout({ 11 | children, 12 | }: DashboardLayoutProps) { 13 | const user = await currentUser() 14 | 15 | return ( 16 |
17 | 18 |
{children}
19 | 20 |
21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /app/dashboard/my-recipes/[id]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | PageHeader, 3 | PageHeaderDescription, 4 | PageHeaderHeading, 5 | } from "@/components/layout/page-header" 6 | import { RecipeCardSkeleton } from "@/components/recipe/recipe-card-skeleton" 7 | 8 | export default function RecipeLoading() { 9 | return ( 10 |
11 | 12 | Your Recipe in the Making! 13 | 14 | Simmering up a digital feast! Your recipe is currently undergoing a 15 | gourmet transformation!✨ 16 | 17 | 18 |
19 | 20 |
21 |
22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /app/dashboard/my-recipes/[id]/not-found.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { Button } from "@/components/ui/button" 4 | import { 5 | PageActions, 6 | PageHeader, 7 | PageHeaderDescription, 8 | PageHeaderHeading, 9 | } from "@/components/layout/page-header" 10 | 11 | export default function RecipeNotFound() { 12 | return ( 13 |
14 | 15 | Oops! 16 | 17 | It seems our chef got a bit too creative and lost the recipe! 18 | We're on a quest to find it. Meanwhile, let's share a 19 | virtual cookie and try another delicious adventure!✨ 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /app/dashboard/my-recipes/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { type Metadata } from "next" 2 | import { notFound } from "next/navigation" 3 | import { auth } from "@clerk/nextjs" 4 | 5 | import { getRecipePrivate } from "@/lib/supabase-queries" 6 | import { 7 | PageHeader, 8 | PageHeaderDescription, 9 | PageHeaderHeading, 10 | } from "@/components/layout/page-header" 11 | import { RecipeCard } from "@/components/recipe/recipe-card" 12 | 13 | export const metadata: Metadata = { 14 | metadataBase: new URL("https://chef-genie.app"), 15 | title: "My Recipes", 16 | description: "Manage your recipes.", 17 | } 18 | 19 | interface RecipePageProps { 20 | params: { 21 | id: string 22 | } 23 | } 24 | 25 | export default async function RecipePage({ params }: RecipePageProps) { 26 | const { getToken } = auth() 27 | const id = params.id 28 | const supabaseAccessToken = await getToken({ template: "chef-genie" }) 29 | const [recipe] = await Promise.all([ 30 | getRecipePrivate(id, supabaseAccessToken), 31 | ]) 32 | 33 | if (!recipe) { 34 | notFound() 35 | } 36 | 37 | return ( 38 |
39 | 40 | {recipe.title} 41 | {recipe.description} 42 | 43 |
44 | 45 |
46 |
47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /app/dashboard/my-recipes/page.tsx: -------------------------------------------------------------------------------- 1 | import { type Metadata } from "next" 2 | import { auth } from "@clerk/nextjs" 3 | 4 | import type { Tables } from "@/types/database.types" 5 | import { supabaseClient } from "@/lib/supabase-client" 6 | import { getRecipesByUserId } from "@/lib/supabase-queries" 7 | import { columns, RecipeTable } from "@/components/dashboard/columns" 8 | import { DataTable } from "@/components/dashboard/data-table" 9 | import { 10 | PageHeader, 11 | PageHeaderDescription, 12 | PageHeaderHeading, 13 | } from "@/components/layout/page-header" 14 | import { RecipeCardPreview } from "@/components/recipe/recipe-card-preview" 15 | 16 | type Recipe = Tables<"recipes"> 17 | 18 | export const metadata: Metadata = { 19 | metadataBase: new URL("https://chef-genie.app"), 20 | title: "Your Culinary Creations", 21 | description: 22 | "Explore your saved recipes in one place. Your culinary journey starts here!", 23 | } 24 | 25 | async function getRecipesPrivate(): Promise { 26 | const { getToken, userId } = auth() 27 | const supabaseAccessToken = await getToken({ template: "chef-genie" }) 28 | const supabase = await supabaseClient(supabaseAccessToken as string) 29 | try { 30 | const { data: recipes } = await supabase 31 | .from("recipes") 32 | .select() 33 | .eq("user_id", userId) 34 | .order("created_at", { ascending: false }) 35 | 36 | return recipes || null 37 | } catch (error) { 38 | console.error("Error:", error) 39 | return null 40 | } 41 | } 42 | 43 | export default async function RecipePage() { 44 | const { getToken, userId } = auth() 45 | const supabaseAccessToken = await getToken({ template: "chef-genie" }) 46 | const recipes = await getRecipesByUserId(userId, supabaseAccessToken) 47 | const data = await getRecipesPrivate() 48 | 49 | return ( 50 |
51 | 52 | Your Culinary Creations 53 | 54 | Explore your saved recipes in one place. Your culinary journey starts 55 | here! 56 | 57 | 58 | {data && } 59 |
60 | {recipes?.map((recipe) => ( 61 |
62 | 63 |
64 | ))} 65 |
66 |
67 | ) 68 | } 69 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "@/styles/globals.css" 2 | 3 | import { Metadata, Viewport } from "next" 4 | import { ClerkProvider } from "@clerk/nextjs" 5 | import { Analytics } from "@vercel/analytics/react" 6 | import { SpeedInsights } from "@vercel/speed-insights/next" 7 | import { Toaster } from "sonner" 8 | 9 | import { siteConfig } from "@/config/site" 10 | import { fontSans } from "@/lib/fonts" 11 | import { cn } from "@/lib/utils" 12 | import { ThemeProvider } from "@/components/theme-provider" 13 | 14 | export const metadata: Metadata = { 15 | metadataBase: new URL("https://chef-genie.app"), 16 | title: { 17 | default: siteConfig.name, 18 | template: `%s - ${siteConfig.name}`, 19 | }, 20 | description: siteConfig.description, 21 | keywords: [ 22 | "Chef GPT", 23 | "Chef Genie", 24 | "Recipe Generator", 25 | "Recipe ChatGPT", 26 | "Recipe AI", 27 | "Chef AI", 28 | "Meal generator", 29 | "Cook GPT", 30 | "Cooking generator", 31 | ], 32 | authors: [ 33 | { 34 | name: "faultyled", 35 | url: "https://github.com/giacomogaglione", 36 | }, 37 | ], 38 | creator: "faultyled", 39 | openGraph: { 40 | type: "website", 41 | locale: "en_US", 42 | url: siteConfig.url, 43 | title: siteConfig.name, 44 | description: siteConfig.description, 45 | siteName: siteConfig.name, 46 | images: [ 47 | { 48 | url: siteConfig.ogImage, 49 | width: 1200, 50 | height: 630, 51 | alt: siteConfig.name, 52 | }, 53 | ], 54 | }, 55 | twitter: { 56 | card: "summary_large_image", 57 | title: siteConfig.name, 58 | description: siteConfig.description, 59 | images: [siteConfig.ogImage], 60 | creator: "@faultyled", 61 | }, 62 | icons: { 63 | icon: "/favicon.ico", 64 | }, 65 | } 66 | 67 | export const viewport: Viewport = { 68 | colorScheme: "dark light", 69 | themeColor: [ 70 | { media: "(prefers-color-scheme: light)", color: "white" }, 71 | { media: "(prefers-color-scheme: dark)", color: "black" }, 72 | ], 73 | } 74 | 75 | interface RootLayoutProps { 76 | children: React.ReactNode 77 | } 78 | 79 | export default async function RootLayout({ children }: RootLayoutProps) { 80 | return ( 81 | 82 | 83 | 84 | 90 | 91 | {children} 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | ) 100 | } 101 | -------------------------------------------------------------------------------- /app/recipes/[id]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | PageHeader, 3 | PageHeaderDescription, 4 | PageHeaderHeading, 5 | } from "@/components/layout/page-header" 6 | import { RecipeCardSkeleton } from "@/components/recipe/recipe-card-skeleton" 7 | 8 | export default function RecipeLoading() { 9 | return ( 10 |
11 | 12 | Your Recipe in the Making! 13 | 14 | Simmering up a digital feast! Your recipe is currently undergoing a 15 | gourmet transformation!✨ 16 | 17 | 18 |
19 | 20 |
21 |
22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /app/recipes/[id]/not-found.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { Button } from "@/components/ui/button" 4 | import { 5 | PageActions, 6 | PageHeader, 7 | PageHeaderDescription, 8 | PageHeaderHeading, 9 | } from "@/components/layout/page-header" 10 | 11 | export default function RecipeNotFound() { 12 | return ( 13 |
14 | 15 | Oops! 16 | 17 | It seems our chef got a bit too creative and lost the recipe! 18 | We're on a quest to find it. Meanwhile, let's share a 19 | virtual cookie and try another delicious adventure!✨ 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /app/recipes/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { type Metadata } from "next" 2 | import { notFound } from "next/navigation" 3 | 4 | import { getRecipePublic } from "@/lib/supabase-queries" 5 | import { 6 | PageHeader, 7 | PageHeaderDescription, 8 | PageHeaderHeading, 9 | } from "@/components/layout/page-header" 10 | import { RecipeCard } from "@/components/recipe/recipe-card" 11 | 12 | interface RecipePageProps { 13 | params: { 14 | id: string 15 | } 16 | } 17 | 18 | export async function generateMetadata({ 19 | params, 20 | }: RecipePageProps): Promise { 21 | const id = params.id 22 | const [recipe] = await Promise.all([getRecipePublic(id)]) 23 | 24 | if (!recipe) { 25 | return {} 26 | } 27 | 28 | return { 29 | metadataBase: new URL("https://chef-genie.app"), 30 | title: recipe.title, 31 | description: recipe.description, 32 | } 33 | } 34 | 35 | export default async function RecipePage({ params }: RecipePageProps) { 36 | const id = params.id 37 | const [recipe] = await Promise.all([getRecipePublic(id)]) 38 | 39 | if (!recipe) { 40 | notFound() 41 | } 42 | 43 | return ( 44 |
45 | 46 | {recipe.title} 47 | {recipe.description} 48 | 49 |
50 | 51 |
52 |
53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /app/recipes/layout.tsx: -------------------------------------------------------------------------------- 1 | import { currentUser } from "@clerk/nextjs" 2 | 3 | import { SiteFooter } from "@/components/layout/site-footer" 4 | import { SiteHeader } from "@/components/layout/site-header" 5 | 6 | interface RecipesLayoutProps { 7 | children: React.ReactNode 8 | } 9 | 10 | export default async function RecipesLayout({ children }: RecipesLayoutProps) { 11 | const user = await currentUser() 12 | 13 | return ( 14 |
15 | 16 |
{children}
17 | 18 |
19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /app/robots.ts: -------------------------------------------------------------------------------- 1 | import { MetadataRoute } from "next" 2 | 3 | export default function robots(): MetadataRoute.Robots { 4 | const baseUrl = "https://chef-genie.app" 5 | 6 | return { 7 | rules: [ 8 | { 9 | userAgent: "*", 10 | allow: "/", 11 | }, 12 | ], 13 | sitemap: `${baseUrl}/sitemap.xml`, 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/sitemap.ts: -------------------------------------------------------------------------------- 1 | import { MetadataRoute } from "next" 2 | 3 | export default function sitemap(): MetadataRoute.Sitemap { 4 | const baseUrl = "https://chef-genie.app" 5 | return [ 6 | { 7 | url: baseUrl, 8 | lastModified: new Date(), 9 | }, 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tailwind": { 6 | "config": "tailwind.config.js", 7 | "css": "styles/globals.css", 8 | "baseColor": "slate", 9 | "cssVariables": true 10 | }, 11 | "aliases": { 12 | "components": "@/components", 13 | "utils": "@/lib/utils" 14 | } 15 | } -------------------------------------------------------------------------------- /components/dashboard/columns.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import Link from "next/link" 4 | import { ColumnDef } from "@tanstack/react-table" 5 | import { MoreHorizontal } from "lucide-react" 6 | import { toast } from "sonner" 7 | 8 | import { deleteRecipe } from "@/lib/actions" 9 | import { Badge } from "@/components/ui/badge" 10 | import { Button } from "@/components/ui/button" 11 | import { 12 | DropdownMenu, 13 | DropdownMenuContent, 14 | DropdownMenuItem, 15 | DropdownMenuSeparator, 16 | DropdownMenuTrigger, 17 | } from "@/components/ui/dropdown-menu" 18 | import { DataTableColumnHeader } from "@/components/dashboard/data-table-column-header" 19 | 20 | const handleDeleteRecipe = async (id: string) => { 21 | toast.promise(deleteRecipe(id), { 22 | loading: "Deleting...", 23 | success: () => "Recipe deleted successfully.", 24 | }) 25 | } 26 | 27 | export interface RecipeTable { 28 | id: string 29 | title: string 30 | description: string 31 | cooking_time: number 32 | calories: number 33 | difficulty: string 34 | macros: { 35 | protein: number 36 | fats: number 37 | carbs: number 38 | } 39 | ingredients2: Array<{ name: string; amount: number | string }> 40 | instructions: Array<{ step: number; description: string | string }> 41 | vegan: string 42 | low_calories: string 43 | paleo: string 44 | } 45 | 46 | export const columns: ColumnDef[] = [ 47 | { 48 | accessorKey: "title", 49 | header: ({ column }) => ( 50 | 51 | ), 52 | }, 53 | { 54 | accessorKey: "difficulty", 55 | header: ({ column }) => ( 56 | 57 | ), 58 | }, 59 | { 60 | accessorKey: "cooking_time", 61 | header: ({ column }) => ( 62 | 63 | ), 64 | }, 65 | { 66 | accessorKey: "status", 67 | header: "Status", 68 | cell: ({ row }) => { 69 | const { vegan, paleo } = row.original 70 | 71 | if (vegan === "Yes") { 72 | return Vegan 73 | } else if (paleo === "Yes") { 74 | return Paleo 75 | } 76 | }, 77 | }, 78 | { 79 | id: "actions", 80 | cell: ({ row }) => { 81 | const recipe = row.original 82 | 83 | return ( 84 | 85 | 86 | 90 | 91 | 92 | 93 | View 94 | 95 | 96 | 97 | 106 | 107 | 108 | 109 | ) 110 | }, 111 | }, 112 | ] 113 | -------------------------------------------------------------------------------- /components/dashboard/data-table-column-header.tsx: -------------------------------------------------------------------------------- 1 | import { Column } from "@tanstack/react-table" 2 | import { ArrowDown, ArrowUp, ChevronsUpDown, EyeOff } from "lucide-react" 3 | 4 | import { cn } from "@/lib/utils" 5 | import { Button } from "@/components/ui/button" 6 | import { 7 | DropdownMenu, 8 | DropdownMenuContent, 9 | DropdownMenuItem, 10 | DropdownMenuSeparator, 11 | DropdownMenuTrigger, 12 | } from "@/components/ui/dropdown-menu" 13 | 14 | interface DataTableColumnHeaderProps 15 | extends React.HTMLAttributes { 16 | column: Column 17 | title: string 18 | } 19 | 20 | export function DataTableColumnHeader({ 21 | column, 22 | title, 23 | className, 24 | }: DataTableColumnHeaderProps) { 25 | if (!column.getCanSort()) { 26 | return
{title}
27 | } 28 | 29 | return ( 30 |
31 | 32 | 33 | 47 | 48 | 49 | column.toggleSorting(false)}> 50 | 51 | Asc 52 | 53 | column.toggleSorting(true)}> 54 | 55 | Desc 56 | 57 | 58 | column.toggleVisibility(false)}> 59 | 60 | Hide 61 | 62 | 63 | 64 |
65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /components/dashboard/data-table-faceted-filter.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Column } from "@tanstack/react-table" 3 | import { Check, PlusCircle } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | import { Badge } from "@/components/ui/badge" 7 | import { Button } from "@/components/ui/button" 8 | import { 9 | Command, 10 | CommandEmpty, 11 | CommandGroup, 12 | CommandInput, 13 | CommandItem, 14 | CommandList, 15 | CommandSeparator, 16 | } from "@/components/ui/command" 17 | import { 18 | Popover, 19 | PopoverContent, 20 | PopoverTrigger, 21 | } from "@/components/ui/popover" 22 | import { Separator } from "@/components/ui/separator" 23 | 24 | interface DataTableFacetedFilterProps { 25 | column?: Column 26 | title?: string 27 | options: { 28 | label: string 29 | value: string 30 | icon?: React.ComponentType<{ className?: string }> 31 | }[] 32 | } 33 | 34 | export function DataTableFacetedFilter({ 35 | column, 36 | title, 37 | options, 38 | }: DataTableFacetedFilterProps) { 39 | const facets = column?.getFacetedUniqueValues() 40 | const selectedValues = new Set(column?.getFilterValue() as string[]) 41 | 42 | return ( 43 | 44 | 45 | 82 | 83 | 84 | 85 | 86 | 87 | No results found. 88 | 89 | {options.map((option) => { 90 | const isSelected = selectedValues.has(option.value) 91 | return ( 92 | { 95 | if (isSelected) { 96 | selectedValues.delete(option.value) 97 | } else { 98 | selectedValues.add(option.value) 99 | } 100 | const filterValues = Array.from(selectedValues) 101 | column?.setFilterValue( 102 | filterValues.length ? filterValues : undefined 103 | ) 104 | }} 105 | > 106 |
114 | 115 |
116 | {option.icon && ( 117 | 118 | )} 119 | {option.label} 120 | {facets?.get(option.value) && ( 121 | 122 | {facets.get(option.value)} 123 | 124 | )} 125 |
126 | ) 127 | })} 128 |
129 | {selectedValues.size > 0 && ( 130 | <> 131 | 132 | 133 | column?.setFilterValue(undefined)} 135 | className="justify-center text-center" 136 | > 137 | Clear filters 138 | 139 | 140 | 141 | )} 142 |
143 |
144 |
145 |
146 | ) 147 | } 148 | -------------------------------------------------------------------------------- /components/dashboard/data-table-pagination.tsx: -------------------------------------------------------------------------------- 1 | import { Table } from "@tanstack/react-table" 2 | import { 3 | ChevronLeft, 4 | ChevronRight, 5 | ChevronsLeft, 6 | ChevronsRight, 7 | } from "lucide-react" 8 | 9 | import { Button } from "@/components/ui/button" 10 | import { 11 | Select, 12 | SelectContent, 13 | SelectItem, 14 | SelectTrigger, 15 | SelectValue, 16 | } from "@/components/ui/select" 17 | 18 | interface DataTablePaginationProps { 19 | table: Table 20 | } 21 | 22 | export function DataTablePagination({ 23 | table, 24 | }: DataTablePaginationProps) { 25 | return ( 26 |
27 |
28 |
29 |

Rows per page

30 | 47 |
48 |
49 | Page {table.getState().pagination.pageIndex + 1} of{" "} 50 | {table.getPageCount()} 51 |
52 |
53 | 62 | 71 | 80 | 89 |
90 |
91 |
92 | ) 93 | } 94 | -------------------------------------------------------------------------------- /components/dashboard/data-table-toolbar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Table } from "@tanstack/react-table" 4 | import { X } from "lucide-react" 5 | 6 | import { Button } from "@/components/ui/button" 7 | import { Input } from "@/components/ui/input" 8 | import { difficulties, statuses } from "@/components/dashboard/data" 9 | import { DataTableFacetedFilter } from "@/components/dashboard/data-table-faceted-filter" 10 | import { DataTableViewOptions } from "@/components/dashboard/data-table-view-options" 11 | 12 | interface DataTableToolbarProps { 13 | table: Table 14 | } 15 | 16 | export function DataTableToolbar({ 17 | table, 18 | }: DataTableToolbarProps) { 19 | const isFiltered = table.getState().columnFilters.length > 0 20 | 21 | return ( 22 |
23 |
24 | 28 | table.getColumn("title")?.setFilterValue(event.target.value) 29 | } 30 | className="h-8 w-[150px] lg:w-[250px]" 31 | /> 32 | {table.getColumn("difficulty") && ( 33 | 38 | )} 39 | {table.getColumn("status") && ( 40 | 45 | )} 46 | 47 | {isFiltered && ( 48 | 56 | )} 57 |
58 | 59 |
60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /components/dashboard/data-table-view-options.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu" 4 | import { Table } from "@tanstack/react-table" 5 | import { SlidersHorizontal } from "lucide-react" 6 | 7 | import { Button } from "@/components/ui/button" 8 | import { 9 | DropdownMenu, 10 | DropdownMenuCheckboxItem, 11 | DropdownMenuContent, 12 | DropdownMenuLabel, 13 | DropdownMenuSeparator, 14 | } from "@/components/ui/dropdown-menu" 15 | 16 | interface DataTableViewOptionsProps { 17 | table: Table 18 | } 19 | 20 | export function DataTableViewOptions({ 21 | table, 22 | }: DataTableViewOptionsProps) { 23 | return ( 24 | 25 | 26 | 34 | 35 | 36 | Toggle columns 37 | 38 | {table 39 | .getAllColumns() 40 | .filter( 41 | (column) => 42 | typeof column.accessorFn !== "undefined" && column.getCanHide() 43 | ) 44 | .map((column) => { 45 | return ( 46 | column.toggleVisibility(!!value)} 51 | > 52 | {column.id} 53 | 54 | ) 55 | })} 56 | 57 | 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /components/dashboard/data-table.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import React from "react" 4 | import { 5 | ColumnDef, 6 | ColumnFiltersState, 7 | flexRender, 8 | getCoreRowModel, 9 | getFilteredRowModel, 10 | getPaginationRowModel, 11 | getSortedRowModel, 12 | SortingState, 13 | useReactTable, 14 | VisibilityState, 15 | } from "@tanstack/react-table" 16 | 17 | import { 18 | Table, 19 | TableBody, 20 | TableCell, 21 | TableHead, 22 | TableHeader, 23 | TableRow, 24 | } from "@/components/ui/table" 25 | import { DataTablePagination } from "@/components/dashboard/data-table-pagination" 26 | import { DataTableToolbar } from "@/components/dashboard/data-table-toolbar" 27 | 28 | interface DataTableProps { 29 | columns: ColumnDef[] 30 | data: TData[] 31 | } 32 | 33 | export function DataTable({ 34 | columns, 35 | data, 36 | }: DataTableProps) { 37 | const [sorting, setSorting] = React.useState([]) 38 | const [columnFilters, setColumnFilters] = React.useState( 39 | [] 40 | ) 41 | const [columnVisibility, setColumnVisibility] = 42 | React.useState({}) 43 | 44 | const table = useReactTable({ 45 | data, 46 | columns, 47 | getCoreRowModel: getCoreRowModel(), 48 | getPaginationRowModel: getPaginationRowModel(), 49 | onSortingChange: setSorting, 50 | getSortedRowModel: getSortedRowModel(), 51 | onColumnFiltersChange: setColumnFilters, 52 | getFilteredRowModel: getFilteredRowModel(), 53 | onColumnVisibilityChange: setColumnVisibility, 54 | 55 | state: { 56 | sorting, 57 | columnFilters, 58 | columnVisibility, 59 | }, 60 | }) 61 | 62 | return ( 63 |
64 | 65 |
66 | 67 | 68 | {table.getHeaderGroups().map((headerGroup) => ( 69 | 70 | {headerGroup.headers.map((header) => { 71 | return ( 72 | 73 | {header.isPlaceholder 74 | ? null 75 | : flexRender( 76 | header.column.columnDef.header, 77 | header.getContext() 78 | )} 79 | 80 | ) 81 | })} 82 | 83 | ))} 84 | 85 | 86 | {table.getRowModel().rows?.length ? ( 87 | table.getRowModel().rows.map((row) => ( 88 | 92 | {row.getVisibleCells().map((cell) => ( 93 | 94 | {flexRender( 95 | cell.column.columnDef.cell, 96 | cell.getContext() 97 | )} 98 | 99 | ))} 100 | 101 | )) 102 | ) : ( 103 | 104 | 108 | No results. 109 | 110 | 111 | )} 112 | 113 |
114 |
115 |
116 | 117 |
118 |
119 | ) 120 | } 121 | -------------------------------------------------------------------------------- /components/dashboard/data.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BatteryFull, 3 | BatteryLow, 4 | BatteryMedium, 5 | Beef, 6 | Vegan, 7 | } from "lucide-react" 8 | 9 | export const difficulties = [ 10 | { 11 | value: "easy", 12 | label: "Easy", 13 | icon: BatteryLow, 14 | }, 15 | { 16 | value: "medium", 17 | label: "Medium", 18 | icon: BatteryMedium, 19 | }, 20 | { 21 | value: "Expert", 22 | label: "expert", 23 | icon: BatteryFull, 24 | }, 25 | ] 26 | 27 | export const statuses = [ 28 | { 29 | label: "Vegan", 30 | value: "vegan", 31 | icon: Vegan, 32 | }, 33 | { 34 | label: "Paleo", 35 | value: "paleo", 36 | icon: Beef, 37 | }, 38 | ] 39 | -------------------------------------------------------------------------------- /components/dashboard/user-profile.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { UserProfile as ClerkUserProfile } from "@clerk/nextjs" 4 | import { dark } from "@clerk/themes" 5 | import { useTheme } from "next-themes" 6 | 7 | export function UserProfile() { 8 | const { resolvedTheme } = useTheme() 9 | 10 | return ( 11 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /components/form/label-form-field.tsx: -------------------------------------------------------------------------------- 1 | import { FormLabel } from "@/components/ui/form" 2 | 3 | interface RecipeFormLabelProps { 4 | stepIndex: string 5 | labelIndex: string 6 | } 7 | 8 | export function RecipeFormLabel({ 9 | stepIndex, 10 | labelIndex, 11 | }: RecipeFormLabelProps) { 12 | return ( 13 | 14 | 15 | {stepIndex} 16 | 17 | {labelIndex} 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /components/form/radio-group-form-field.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { FieldValues } from "react-hook-form" 4 | 5 | import { 6 | FormControl, 7 | FormField, 8 | FormItem, 9 | FormLabel, 10 | } from "@/components/ui/form" 11 | import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" 12 | 13 | interface RadioGroupOption { 14 | label: string 15 | value: string 16 | } 17 | 18 | interface RadioGroupFormFieldProps { 19 | form: FieldValues 20 | name: string 21 | options: RadioGroupOption[] 22 | } 23 | 24 | export const options: RadioGroupOption[] = [ 25 | { label: "2 People", value: "2" }, 26 | { label: "4 People", value: "4" }, 27 | { label: "6 People", value: "6" }, 28 | ] 29 | 30 | export function RadioGroupFormField({ 31 | form, 32 | name, 33 | options, 34 | }: RadioGroupFormFieldProps) { 35 | return ( 36 | ( 40 | 41 | 42 | 48 | {options.map((option) => ( 49 | 50 | 51 | 52 | 57 | 58 | {option.label} 59 | 60 | 61 | ))} 62 | 63 | 64 | 65 | )} 66 | /> 67 | ) 68 | } 69 | -------------------------------------------------------------------------------- /components/form/recipe-form.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import React, { useState } from "react" 4 | import { zodResolver } from "@hookform/resolvers/zod" 5 | import { useForm } from "react-hook-form" 6 | 7 | import { defaultValues, formSchema, type FormData } from "@/types/types" 8 | import { Button } from "@/components/ui/button" 9 | import { 10 | Form, 11 | FormControl, 12 | FormDescription, 13 | FormField, 14 | FormItem, 15 | FormMessage, 16 | } from "@/components/ui/form" 17 | import { Input } from "@/components/ui/input" 18 | import { Slider, SliderThumb } from "@/components/ui/slider" 19 | import { RecipeFormLabel } from "@/components/form/label-form-field" 20 | import { 21 | options, 22 | RadioGroupFormField, 23 | } from "@/components/form/radio-group-form-field" 24 | import { SelectFormField } from "@/components/form/select-form-field" 25 | import { SwitchFormField } from "@/components/form/switch-form-field" 26 | import { Icons } from "@/components/icons" 27 | 28 | interface RecipeFormProps { 29 | onSubmit: (values: FormData, e: React.FormEvent) => void 30 | isLoading: boolean 31 | } 32 | 33 | export function RecipeForm({ onSubmit, isLoading }: RecipeFormProps) { 34 | const [showAdditionalFields, setShowAdditionalFields] = useState(false) 35 | 36 | const form = useForm({ 37 | resolver: zodResolver(formSchema), 38 | defaultValues, 39 | }) 40 | 41 | return ( 42 |
43 | 44 | ( 48 | 49 | {showAdditionalFields && ( 50 | 54 | )} 55 | 56 |
57 | setShowAdditionalFields(true)} 61 | className="rounded-xl bg-primary text-secondary shadow-lg placeholder:text-secondary/70" 62 | /> 63 | 64 |
65 |
66 | 67 |
68 | )} 69 | /> 70 | {showAdditionalFields && ( 71 | <> 72 | ( 76 | 77 | 81 | 82 | 92 | 93 | 94 | 95 | 96 | 🕛 {field.value} minutes 97 | 98 | 99 | )} 100 | /> 101 | 102 | 106 | 111 | 112 | 113 | 117 | 118 | 119 | 120 | 124 | 129 | 130 | 131 | 132 | {isLoading ? ( 133 | 140 | ) : ( 141 | 145 | )}{" "} 146 | 147 | )} 148 | 149 | 150 | ) 151 | } 152 | -------------------------------------------------------------------------------- /components/form/select-form-field.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { FieldValues } from "react-hook-form" 4 | 5 | import { FormControl, FormField, FormItem } from "@/components/ui/form" 6 | import { 7 | Select, 8 | SelectContent, 9 | SelectItem, 10 | SelectTrigger, 11 | SelectValue, 12 | } from "@/components/ui/select" 13 | 14 | interface SelectFormFieldProps { 15 | form: FieldValues 16 | name: string 17 | } 18 | 19 | export function SelectFormField({ form, name }: SelectFormFieldProps) { 20 | return ( 21 | ( 25 | 26 | 38 | 39 | )} 40 | /> 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /components/form/switch-form-field.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { FieldValues } from "react-hook-form" 4 | 5 | import { 6 | FormControl, 7 | FormField, 8 | FormItem, 9 | FormLabel, 10 | } from "@/components/ui/form" 11 | import { Switch } from "@/components/ui/switch" 12 | 13 | interface SwitchFormFieldProps { 14 | form: FieldValues 15 | name: string 16 | label: string 17 | } 18 | 19 | export function SwitchFormField({ form, name, label }: SwitchFormFieldProps) { 20 | return ( 21 | ( 25 | 26 | {label} 27 | 28 | 33 | 34 | 35 | )} 36 | /> 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /components/generate-recipe.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import React, { useCallback, useEffect, useState } from "react" 4 | import { useCompletion } from "ai/react" 5 | import { toast } from "sonner" 6 | 7 | import { defaultValues, Recipe, type FormData } from "@/types/types" 8 | import { saveGeneration } from "@/lib/actions" 9 | import { generatePrompt } from "@/lib/generate-prompt" 10 | import { cn } from "@/lib/utils" 11 | import { RecipeForm } from "@/components/form/recipe-form" 12 | import { RecipeCard } from "@/components/recipe/recipe-card" 13 | import { RecipeCardSkeleton } from "@/components/recipe/recipe-card-skeleton" 14 | 15 | export function GenerateRecipe() { 16 | const [isRecipeVisible, setIsRecipeVisible] = useState(false) 17 | const [formValues, setFormValues] = useState(defaultValues) 18 | const [recipe, setRecipe] = useState(null) 19 | 20 | const { complete, isLoading } = useCompletion({ 21 | api: "/api/generate-recipe", 22 | onFinish: () => { 23 | setIsRecipeVisible(true) 24 | }, 25 | }) 26 | 27 | useEffect(() => { 28 | if (recipe) { 29 | saveGeneration(recipe) 30 | } 31 | }, [recipe]) 32 | 33 | const onSubmit = useCallback( 34 | async (values: FormData, e: React.FormEvent) => { 35 | const prompt = generatePrompt(values) 36 | const completion = await complete(prompt) 37 | setFormValues(values) 38 | if (!completion) throw new Error("Failed to generate recipe. Try again.") 39 | try { 40 | const result = JSON.parse(completion) 41 | setRecipe(result) 42 | } catch (error) { 43 | console.error("Error parsing JSON:", error) 44 | toast.error("Uh oh! Failed to generate recipe. Try again.") 45 | } 46 | }, 47 | [complete] 48 | ) 49 | 50 | return ( 51 |
52 |
58 |
63 | 64 |
65 |
70 |
71 | {!isLoading && recipe && } 72 | {isLoading && } 73 |
74 |
75 |
76 |
77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /components/icons.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Activity, 3 | ChefHat, 4 | CornerDownLeft, 5 | Flame, 6 | Heart, 7 | Loader2, 8 | LogOut, 9 | Moon, 10 | SendHorizontal, 11 | Settings, 12 | Soup, 13 | Star, 14 | SunMedium, 15 | Terminal, 16 | Timer, 17 | Trash2, 18 | Twitter, 19 | User, 20 | Users, 21 | type LucideIcon, 22 | type LucideProps, 23 | } from "lucide-react" 24 | 25 | export type Icon = LucideIcon 26 | 27 | export const Icons = { 28 | sun: SunMedium, 29 | moon: Moon, 30 | twitter: Twitter, 31 | chef: ChefHat, 32 | user: User, 33 | terminal: Terminal, 34 | settings: Settings, 35 | logout: LogOut, 36 | spinner: Activity, 37 | generate: SendHorizontal, 38 | save: Heart, 39 | loader: Loader2, 40 | cooking_time: Timer, 41 | calories: Flame, 42 | people: Users, 43 | difficulty: Star, 44 | logo: Soup, 45 | input: CornerDownLeft, 46 | delete: Trash2, 47 | gitHub: (props: LucideProps) => ( 48 | 49 | 53 | 54 | ), 55 | } 56 | -------------------------------------------------------------------------------- /components/layout/main-nav.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import Link from "next/link" 3 | 4 | import { NavItem } from "@/types/types" 5 | import { siteConfig } from "@/config/site" 6 | import { Icons } from "@/components/icons" 7 | 8 | interface MainNavProps { 9 | items?: NavItem[] 10 | } 11 | 12 | export function MainNav({ items }: MainNavProps) { 13 | return ( 14 |
15 | 20 | 21 | 22 | {siteConfig.name} 23 | 24 | 25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /components/layout/page-header.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function PageHeader({ 4 | className, 5 | children, 6 | ...props 7 | }: React.HTMLAttributes) { 8 | return ( 9 |
16 | {children} 17 |
18 | ) 19 | } 20 | 21 | function PageHeaderHeading({ 22 | className, 23 | ...props 24 | }: React.HTMLAttributes) { 25 | return ( 26 |

33 | ) 34 | } 35 | 36 | function PageHeaderDescription({ 37 | className, 38 | ...props 39 | }: React.HTMLAttributes) { 40 | return ( 41 |
45 | ) 46 | } 47 | 48 | function PageActions({ 49 | className, 50 | ...props 51 | }: React.HTMLAttributes) { 52 | return ( 53 |
60 | ) 61 | } 62 | 63 | export { PageHeader, PageHeaderHeading, PageHeaderDescription, PageActions } 64 | -------------------------------------------------------------------------------- /components/layout/site-footer.tsx: -------------------------------------------------------------------------------- 1 | import { siteConfig } from "@/config/site" 2 | 3 | export function SiteFooter() { 4 | return ( 5 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /components/layout/site-header.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | import { SignOutButton } from "@clerk/nextjs" 3 | import type { User } from "@clerk/nextjs/dist/types/server" 4 | 5 | import { siteConfig } from "@/config/site" 6 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" 7 | import { Button, buttonVariants } from "@/components/ui/button" 8 | import { 9 | DropdownMenu, 10 | DropdownMenuContent, 11 | DropdownMenuGroup, 12 | DropdownMenuItem, 13 | DropdownMenuLabel, 14 | DropdownMenuSeparator, 15 | DropdownMenuShortcut, 16 | DropdownMenuTrigger, 17 | } from "@/components/ui/dropdown-menu" 18 | import { Icons } from "@/components/icons" 19 | import { MainNav } from "@/components/layout/main-nav" 20 | import { ThemeToggle } from "@/components/layout/theme-toggle" 21 | 22 | interface SiteHeaderProps { 23 | user: User | null 24 | } 25 | 26 | export function SiteHeader({ user }: SiteHeaderProps) { 27 | const initials = `${user?.firstName?.charAt(0) ?? ""} ${ 28 | user?.lastName?.charAt(0) ?? "" 29 | }` 30 | 31 | return ( 32 |
33 |
34 | 35 |
36 | 119 |
120 |
121 |
122 | ) 123 | } 124 | -------------------------------------------------------------------------------- /components/layout/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { useTheme } from "next-themes" 5 | 6 | import { Button } from "@/components/ui/button" 7 | import { Icons } from "@/components/icons" 8 | 9 | export function ThemeToggle() { 10 | const { setTheme, theme } = useTheme() 11 | 12 | return ( 13 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /components/recent-recipes.tsx: -------------------------------------------------------------------------------- 1 | import { getLatestRecipes } from "@/lib/supabase-queries" 2 | import { RecipeCardPreview } from "@/components/recipe/recipe-card-preview" 3 | 4 | export async function RecentRecipes() { 5 | const [recipes] = await Promise.all([getLatestRecipes()]) 6 | return ( 7 |
8 |

Recent Recipes

9 |
10 | {recipes?.map((recipe) => ( 11 |
12 | 13 |
14 | ))} 15 |
16 |
17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /components/recipe/recipe-card-preview.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import Link from "next/link" 4 | 5 | import type { Tables } from "@/types/database.types" 6 | import { Badge } from "@/components/ui/badge" 7 | import { 8 | Card, 9 | CardContent, 10 | CardDescription, 11 | CardHeader, 12 | CardTitle, 13 | } from "@/components/ui/card" 14 | 15 | type Recipe = Tables<"recipes"> 16 | 17 | interface RecipeCardProps { 18 | recipe: Recipe 19 | isPrivate?: boolean 20 | } 21 | 22 | export function RecipeCardPreview({ 23 | recipe, 24 | isPrivate = false, 25 | }: RecipeCardProps) { 26 | const isVegan = recipe?.vegan === "Yes" 27 | const isPaleo = recipe?.paleo === "Yes" 28 | const cookingTime = recipe?.cooking_time?.replaceAll(/[^0-9]/g, "") 29 | const href = isPrivate 30 | ? `/dashboard/my-recipes/${recipe.id}` 31 | : `/recipes/${recipe.id}` 32 | 33 | return ( 34 | 35 | 36 | 37 |
38 | 39 | {recipe?.title} 40 | 41 | 42 | {recipe?.description} 43 | 44 |
45 |
46 | 47 |
48 | {recipe?.difficulty} 49 | 🕓 {cookingTime} min 50 | {isVegan && Vegan} 51 | {isPaleo && Paleo} 52 |
53 |
54 |
55 | 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /components/recipe/recipe-card-skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button" 2 | import { 3 | Card, 4 | CardContent, 5 | CardDescription, 6 | CardFooter, 7 | CardHeader, 8 | CardTitle, 9 | } from "@/components/ui/card" 10 | import { Skeleton } from "@/components/ui/skeleton" 11 | import { Icons } from "@/components/icons" 12 | import { recipeInfo } from "@/components/recipe/recipe-constants" 13 | 14 | export function RecipeCardSkeleton() { 15 | return ( 16 | 17 | 18 | 19 | Your Recipe in the Making! 20 | 21 | 22 | Get ready for a pixel-perfect culinary experience! ✨ 23 | 24 | 25 | 26 |
27 | {/* Recipe Info Section */} 28 |
29 |
30 |

Overview

31 |
32 | {recipeInfo.map((info, index) => ( 33 |
34 | {info.icon} 35 | 36 |
37 | ))} 38 |
39 | {/* Macros BarChart Section */} 40 |
41 |

Macros

42 |
43 | 44 | 45 | 46 |
47 |
48 |
49 | {/* Ingredients Section */} 50 |
51 |

Ingredients

52 | 53 | 54 | 55 | 56 |
57 | {/* Instructions Info Section */} 58 |
59 |

Instructions

60 | 61 | 62 | 63 | 64 | 65 | 66 |
67 |
68 | 69 | 73 | 74 |
75 | ) 76 | } 77 | -------------------------------------------------------------------------------- /components/recipe/recipe-card.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis } from "recharts" 4 | import { toast } from "sonner" 5 | 6 | import { Recipe } from "@/types/types" 7 | import { saveRecipe } from "@/lib/actions" 8 | import { 9 | Card, 10 | CardContent, 11 | CardDescription, 12 | CardFooter, 13 | CardHeader, 14 | CardTitle, 15 | } from "@/components/ui/card" 16 | import { macroInfo, recipeInfo } from "@/components/recipe/recipe-constants" 17 | import { SaveRecipeButton } from "@/components/recipe/save-recipe-button" 18 | 19 | interface GeneratedRecipeContentProps { 20 | recipe: Recipe 21 | } 22 | 23 | export function RecipeCard({ recipe }: GeneratedRecipeContentProps) { 24 | const macroChartData = macroInfo.map((macro) => ({ 25 | label: macro.label, 26 | value: recipe?.macros[macro.value], 27 | })) 28 | 29 | const onSaveRecipe = async () => { 30 | toast.promise(saveRecipe(recipe), { 31 | loading: "Saving...", 32 | success: () => "Cool! Recipe saved successfully.", 33 | error: "Oh No! Sign-In to save recipes!", 34 | }) 35 | } 36 | 37 | return ( 38 | 39 | 40 | {recipe?.title} 41 | {recipe?.description} 42 | 43 | 44 |
45 | {/* Recipe Info Section */} 46 |
47 |
48 |

Overview

49 |
50 | {recipeInfo.map((info, index) => ( 51 |
52 | {info.icon} 53 | 54 | {recipe[info.value]} {info.additionalText} 55 | 56 |
57 | ))} 58 |
59 | {/* Macros BarChart Section */} 60 |
61 |

Macros

62 | 63 | 64 | 72 | `${value}g`} 79 | /> 80 | 85 | 86 | 87 |
88 |
89 | {/* Ingredients Section */} 90 |
91 |

Ingredients

92 |
    93 | {recipe?.ingredients.map( 94 | ( 95 | ingredient: { name: string; amount: number | string }, 96 | i: number 97 | ) => { 98 | return ( 99 |
  1. 100 | {ingredient.name} - {ingredient.amount} 101 |
  2. 102 | ) 103 | } 104 | )} 105 |
106 |
107 | {/* Instructions Section */} 108 |
109 |

Instructions

110 |
    111 | {recipe?.instructions.map( 112 | ( 113 | instruction: { step: number; description: string | string }, 114 | i: number 115 | ) => { 116 | return ( 117 |
  1. {instruction.description}
  2. 118 | ) 119 | } 120 | )} 121 |
122 |
123 |
124 | 125 | 126 | 127 |
128 | ) 129 | } 130 | -------------------------------------------------------------------------------- /components/recipe/recipe-constants.tsx: -------------------------------------------------------------------------------- 1 | import { Icons } from "@/components/icons" 2 | 3 | export const macroInfo = [ 4 | { label: "Protein", value: "protein" }, 5 | { label: "Fats", value: "fats" }, 6 | { label: "Carbs", value: "carbs" }, 7 | ] 8 | 9 | export const recipeInfo = [ 10 | { 11 | icon: