├── .eslintrc.json
├── .github
├── banner.png
├── chef.webp
└── demo.gif
├── .gitignore
├── .prettierrc.json
├── LICENSE
├── README.md
├── lib
├── env.mjs
├── prisma.ts
├── prompt.ts
├── ratelimit.ts
├── recipe.ts
└── tailwindcss.ts
├── next.config.mjs
├── package.json
├── postcss.config.js
├── prisma
├── migrations
│ ├── 20230226175856_init
│ │ └── migration.sql
│ ├── 20230226180854_recipe_content
│ │ └── migration.sql
│ ├── 20230226193255_recipe_long_content
│ │ └── migration.sql
│ ├── 20230304193849_recipe_type_size
│ │ └── migration.sql
│ └── migration_lock.toml
└── schema.prisma
├── public
├── android-chrome-192x192.png
├── android-chrome-512x512.png
├── apple-touch-icon.png
├── favicon-16x16.png
├── favicon-32x32.png
├── favicon.ico
└── og.png
├── src
├── components
│ ├── Button.tsx
│ ├── Checkbox.tsx
│ ├── Heading.tsx
│ ├── OGTags.tsx
│ ├── Spinner.tsx
│ ├── Tabs.tsx
│ ├── Text.tsx
│ └── index.ts
├── hooks
│ ├── index.ts
│ └── useDebounce.ts
├── pages
│ ├── _app.tsx
│ ├── _document.tsx
│ ├── api
│ │ ├── generate.ts
│ │ └── recipe
│ │ │ ├── count.ts
│ │ │ └── index.ts
│ └── index.tsx
└── styles
│ └── globals.css
├── tailwind.config.js
├── tsconfig.json
└── yarn.lock
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["next/core-web-vitals", "prettier"],
3 | "rules": {
4 | "prefer-const": "error"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/.github/banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/danieljpgo/iachef/d3bdbac9578ded137660bf63e3170ffebfa7b2b6/.github/banner.png
--------------------------------------------------------------------------------
/.github/chef.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/danieljpgo/iachef/d3bdbac9578ded137660bf63e3170ffebfa7b2b6/.github/chef.webp
--------------------------------------------------------------------------------
/.github/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/danieljpgo/iachef/d3bdbac9578ded137660bf63e3170ffebfa7b2b6/.github/demo.gif
--------------------------------------------------------------------------------
/.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 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 | .pnpm-debug.log*
27 |
28 | # local env files
29 | .env*.local
30 | .env
31 |
32 | # vercel
33 | .vercel
34 |
35 | # typescript
36 | *.tsbuildinfo
37 | next-env.d.ts
38 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "endOfLine": "lf",
3 | "printWidth": 80,
4 | "tabWidth": 2,
5 | "semi": true,
6 | "useTabs": false,
7 | "trailingComma": "all",
8 | "arrowParens": "always",
9 | "bracketSpacing": true
10 | }
11 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Daniel Jorge
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 |
2 |
7 |
8 |
9 | IAChef
10 |
11 |
12 |
13 |
17 |
18 |
22 |
23 |
26 |
27 |
28 |
31 |
32 |
33 |
34 |
35 | > Your personal chef power by ChatGPT 👨🍳
36 |
37 |
38 |
39 |
40 | Project |
41 | Goals |
42 | Demo |
43 | Features |
44 | Technologies |
45 | License
46 |
47 |
48 |
49 |
54 |
55 |
56 | ## Project
57 |
58 | Using OpenIA's `gpt-3.5-turbo` model to generate recipe from a combination of items such as:
59 |
60 | - Ingredients
61 | - Amount of people (1,2 & 4)
62 | - Type of recipe (healthy or tasty)
63 |
64 | ## Goals
65 |
66 | - Learn how to use the Open AI model `gpt-3.5-turbo` in a real project.
67 | - Using Edge Functions streaming data.
68 |
69 | ## Demo
70 |
71 | A demonstration of the features.
72 |
73 |
74 |
79 |
80 |
81 | ## Features
82 |
83 | - Generate recipes from selected ingredients.
84 | - Customize recipes by number of people and type (healthy or tasty).
85 | - Save and retrieve previously generated recipes.
86 | - Stream data for quick visualization.
87 | - View total recipes generated, updated periodically.
88 |
89 | ## Technologies
90 |
91 | The main technologies used to develop the project were:
92 |
93 | - [React](https://reactjs.org/)
94 | - [Next](https://nextjs.org/)
95 | - [OpenAI](https://platform.openai.com/docs/introduction)
96 | - [TypeScript](https://www.typescriptlang.org/)
97 | - [Tailwind](https://tailwindcss.com/)
98 | - [Prisma](https://www.prisma.io/)
99 | - [Zod](https://zod.dev/)
100 | - [SWR](https://swr.vercel.app/)
101 |
102 | This project was bootstrapped with:
103 |
104 | - [create-next-app](https://nextjs.org/docs/api-reference/create-next-app)
105 |
106 | ## License
107 |
108 | This project is under the [MIT license](https://github.com/danieljpgo/iachef/blob/master/LICENSE).
109 |
110 | Released in 2023.
111 |
112 | Make by [Daniel Jorge](https://github.com/danieljpgo)
113 |
--------------------------------------------------------------------------------
/lib/env.mjs:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | /**
4 | * Specify your server-side environment variables schema here. This way you can ensure the app isn't
5 | * built with invalid env vars.
6 | */
7 | const server = z.object({
8 | OPENAI_API_KEY: z.string(),
9 | AUTHORIZED_REQUEST: z.string(),
10 | DATABASE_URL: z.string().url(),
11 | SHADOW_DATABASE_URL: z.string().url(),
12 | NODE_ENV: z.enum(["development", "test", "production"]),
13 | });
14 |
15 | /**
16 | * Specify your client-side environment variables schema here. This way you can ensure the app isn't
17 | * built with invalid env vars. To expose them to the client, prefix them with `NEXT_PUBLIC_`.
18 | */
19 | const client = z.object({
20 | // NEXT_PUBLIC_CLIENTVAR: z.string().min(1),
21 | });
22 |
23 | /**
24 | * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
25 | * middlewares) or client-side so we need to destruct manually.
26 | *
27 | * @type {Record | keyof z.infer, string | undefined>}
28 | */
29 | const processEnv = {
30 | OPENAI_API_KEY: process.env.OPENAI_API_KEY,
31 | AUTHORIZED_REQUEST: process.env.AUTHORIZED_REQUEST,
32 | DATABASE_URL: process.env.DATABASE_URL,
33 | SHADOW_DATABASE_URL: process.env.SHADOW_DATABASE_URL,
34 | NODE_ENV: process.env.NODE_ENV,
35 | };
36 |
37 | // Don't touch the part below
38 | // --------------------------
39 |
40 | const merged = server.merge(client);
41 |
42 | /** @typedef {z.input} MergedInput */
43 | /** @typedef {z.infer} MergedOutput */
44 | /** @typedef {z.SafeParseReturnType} MergedSafeParseReturn */
45 |
46 | let env = /** @type {MergedOutput} */ (process.env);
47 |
48 | if (!!process.env.SKIP_ENV_VALIDATION == false) {
49 | const isServer = typeof window === "undefined";
50 |
51 | const parsed = /** @type {MergedSafeParseReturn} */ (
52 | isServer
53 | ? merged.safeParse(processEnv) // on server we can validate all env vars
54 | : client.safeParse(processEnv) // on client we can only validate the ones that are exposed
55 | );
56 |
57 | if (parsed.success === false) {
58 | console.error(
59 | "❌ Invalid environment variables:",
60 | parsed.error.flatten().fieldErrors,
61 | );
62 | throw new Error("Invalid environment variables");
63 | }
64 |
65 | env = new Proxy(parsed.data, {
66 | get(target, prop) {
67 | if (typeof prop !== "string") return undefined;
68 | // Throw a descriptive error if a server-side env var is accessed on the client
69 | // Otherwise it would just be returning `undefined` and be annoying to debug
70 | if (!isServer && !prop.startsWith("NEXT_PUBLIC_"))
71 | throw new Error(
72 | process.env.NODE_ENV === "production"
73 | ? "❌ Attempted to access a server-side environment variable on the client"
74 | : `❌ Attempted to access server-side environment variable '${prop}' on the client`,
75 | );
76 | return target[/** @type {keyof typeof target} */ (prop)];
77 | },
78 | });
79 | }
80 |
81 | export { env };
82 |
--------------------------------------------------------------------------------
/lib/prisma.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 |
3 | declare global {
4 | // eslint-disable-next-line no-var
5 | var prisma: PrismaClient | undefined;
6 | }
7 |
8 | export const prisma =
9 | global.prisma ||
10 | new PrismaClient({
11 | log:
12 | process.env.NODE_ENV === "development"
13 | ? ["query", "error", "warn"]
14 | : ["error"],
15 | });
16 |
17 | if (process.env.NODE_ENV !== "production") {
18 | global.prisma = prisma;
19 | }
20 |
--------------------------------------------------------------------------------
/lib/prompt.ts:
--------------------------------------------------------------------------------
1 | export function generatePrompt(
2 | ingredients: Array,
3 | type: string,
4 | size: string,
5 | ) {
6 | return `Gerar uma receita tentando utilizar apenas os seguintes ingredientes: ${ingredients}. A receita será feita para ${size} pessoa(s) e o seu foco será ${
7 | type === "tasty"
8 | ? "ser mais saborosa e não necessariamente ser saudável"
9 | : "ser mais saudável e não necessariamente ser saborosa"
10 | }. Listar os ingredientes neceessários e o modo de preparo, com menos de 1000 caracteres. Por fim, desejar um bom apetite no final.`;
11 | }
12 |
--------------------------------------------------------------------------------
/lib/ratelimit.ts:
--------------------------------------------------------------------------------
1 | import { Ratelimit } from "@upstash/ratelimit";
2 | import { Redis } from "@upstash/redis";
3 |
4 | // Create a new ratelimiter, that allows 3 requests per minute
5 | export const ratelimit = new Ratelimit({
6 | redis: Redis.fromEnv(),
7 | limiter: Ratelimit.slidingWindow(4, "60 s"),
8 | analytics: true,
9 | });
10 |
--------------------------------------------------------------------------------
/lib/recipe.ts:
--------------------------------------------------------------------------------
1 | export const categories: Record = {
2 | vegetable: "Vegetal",
3 | animal: "Animal",
4 | cereal: "Cereal",
5 | fruits: "Frutas",
6 | };
7 |
8 | export const types = [
9 | { value: "healthy", label: "Saudável" },
10 | { value: "tasty", label: "Saborosa" },
11 | ] as const;
12 |
13 | export const sizes = [
14 | { value: "1", label: "1 Pessoa" },
15 | { value: "2", label: "2 Pessoa" },
16 | { value: "4", label: "4 Pessoa" },
17 | ] as const;
18 |
19 | export const ingredients = [
20 | { name: "rice", emoji: "🍚", label: "Arroz" },
21 | { name: "tomato", emoji: "🍅", label: "Tomate" },
22 | { name: "chicken", emoji: "🍗", label: "Frango" },
23 | { name: "beef", emoji: "🥩", label: "Carne de boi" },
24 | { name: "onion", emoji: "🧅", label: "Cebola" },
25 | { name: "carrot", emoji: "🥕", label: "Cenoura" },
26 | { name: "potato", emoji: "🥔", label: "Batata" },
27 | { name: "egg", emoji: "🥚", label: "Ovo" },
28 | { name: "lettuce", emoji: "🥬", label: "Alface" },
29 | { name: "corn", emoji: "🌽", label: "Milho" },
30 | { name: "shrimp", emoji: "🍤", label: "Camarão" },
31 | { name: "honey", emoji: "🍯", label: "Mel" },
32 | { name: "milk", emoji: "🥛", label: "Leite" },
33 | { name: "butter", emoji: "🧈", label: "Manteiga" },
34 | { name: "garlic", emoji: "🧄", label: "Alho" },
35 | { name: "bacon", emoji: "🥓", label: "Bacon" },
36 | { name: "sweet-potato", emoji: "🍠", label: "Batata-doce" },
37 | { name: "cheese", emoji: "🧀", label: "Queijo" },
38 | { name: "bread", emoji: "🍞", label: "Pão" },
39 | { name: "pinto-beans", emoji: "🫘", label: "Feijão" },
40 | { name: "fish", emoji: "🐟", label: "Peixe" },
41 | { name: "broccoli", emoji: "🥦", label: "Brócolis" },
42 | { name: "strawberry", emoji: "🍓", label: "Morango" },
43 | { name: "banana", emoji: "🍌", label: "Banana" },
44 | { name: "lemon", emoji: "🍋", label: "Limão" },
45 | { name: "orange", emoji: "🍊", label: "Laranja" },
46 | { name: "grapes", emoji: "🍇", label: "Uva" },
47 | { name: "melon", emoji: "🍈", label: "Melão" },
48 | { name: "watermelon", emoji: "🍉", label: "Melancia" },
49 | { name: "avocado", emoji: "🥑", label: "Abacate" },
50 |
51 | // //
52 | // { name: "eggplant", emoji: "🍆", label: "Beringela" },
53 | // { name: "cucumber", emoji: "🥒", label: "Pepino" },
54 | // { name: "chili-pepper", emoji: "🌶️", label: "Pimenta" },
55 | // { name: "bell-pepper", emoji: "🫑", label: "Pimentão" },
56 | // //
57 | // { name: "mushroom", emoji: "🍄", label: "Cogumelo" },
58 | // { name: "chestnut", emoji: "🌰", label: "Castanha" },
59 | // { name: "coconut", emoji: "🥥", label: "Coco" },
60 | // { name: "peanut", emoji: "🥜", label: "Amendoim" },
61 | // //
62 | // { name: "mango", emoji: "🥭", label: "Manga" },
63 | // { name: "pineapple", emoji: "🍍", label: "Abacaxi" },
64 | // { name: "apple", emoji: "🍎", label: "Maçã" },
65 | // { name: "pear", emoji: "🍐", label: "Pêra" },
66 | // { name: "peach", emoji: "🍑", label: "Pêssego" },
67 | // { name: "kiwi-fruit", emoji: "🥝", label: "Kiwi" },
68 | // { name: "grapes", emoji: "🍇", label: "Uva" },
69 | // { name: "banana", emoji: "🍌", label: "Banana" },
70 | // { name: "strawberry", emoji: "🍓", label: "Morango" },
71 | // { name: "blueberries", emoji: "🫐", label: "Mirtilo" },
72 | // { name: "lemon", emoji: "🍋", label: "Limão" },
73 | // { name: "orange", emoji: "🍊", label: "Laranja" },
74 | // { name: "watermelon", emoji: "🍉", label: "Melancia" },
75 | // { name: "cherries", emoji: "🍒", label: "Cereja" },
76 | // { name: "avocado", emoji: "🥑", label: "Abacate" },
77 | // { name: "baguette-bread", emoji: "🥖", label: "Baguete" },
78 | // { name: "croissant", emoji: "🥐", label: "Croissant" },
79 | ] as const;
80 |
--------------------------------------------------------------------------------
/lib/tailwindcss.ts:
--------------------------------------------------------------------------------
1 | export function cn(...classes: Array) {
2 | return classes.filter(Boolean).join(" ");
3 | }
4 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | // @ts-check
2 | !process.env.SKIP_ENV_VALIDATION && (await import("./lib/env.mjs"));
3 |
4 | /** @type {import("next").NextConfig} */
5 | const config = {
6 | reactStrictMode: true,
7 | };
8 |
9 | export default config;
10 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "iachef",
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 | "@prisma/client": "4.10.1",
13 | "@radix-ui/react-checkbox": "^1.0.2",
14 | "@radix-ui/react-tabs": "^1.0.2",
15 | "@types/node": "18.14.1",
16 | "@types/react": "18.0.28",
17 | "@types/react-dom": "18.0.11",
18 | "@upstash/ratelimit": "^0.4.0",
19 | "@vercel/analytics": "^0.1.11",
20 | "eslint": "8.35.0",
21 | "eslint-config-next": "13.2.1",
22 | "eventsource-parser": "^0.1.0",
23 | "next": "13.2.1",
24 | "react": "18.2.0",
25 | "react-dom": "18.2.0",
26 | "react-wrap-balancer": "^0.4.0",
27 | "swr": "^2.1.0",
28 | "typescript": "4.9.5",
29 | "zod": "^3.20.6"
30 | },
31 | "devDependencies": {
32 | "autoprefixer": "^10.4.13",
33 | "postcss": "^8.4.21",
34 | "prettier": "^2.8.4",
35 | "prettier-plugin-tailwindcss": "^0.2.3",
36 | "prisma": "^4.10.1",
37 | "tailwindcss": "^3.2.7"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/prisma/migrations/20230226175856_init/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE `Category` (
3 | `id` VARCHAR(191) NOT NULL,
4 | `name` VARCHAR(191) NOT NULL,
5 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
6 | `updatedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
7 |
8 | PRIMARY KEY (`id`)
9 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
10 |
11 | -- CreateTable
12 | CREATE TABLE `Ingredient` (
13 | `id` VARCHAR(191) NOT NULL,
14 | `name` VARCHAR(191) NOT NULL,
15 | `categoryId` VARCHAR(191) NOT NULL,
16 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
17 | `updatedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
18 |
19 | INDEX `Ingredient_categoryId_idx`(`categoryId`),
20 | PRIMARY KEY (`id`)
21 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
22 |
23 | -- CreateTable
24 | CREATE TABLE `Recipe` (
25 | `id` VARCHAR(191) NOT NULL,
26 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
27 | `updatedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
28 |
29 | PRIMARY KEY (`id`)
30 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
31 |
32 | -- CreateTable
33 | CREATE TABLE `_IngredientToRecipe` (
34 | `A` VARCHAR(191) NOT NULL,
35 | `B` VARCHAR(191) NOT NULL,
36 |
37 | UNIQUE INDEX `_IngredientToRecipe_AB_unique`(`A`, `B`),
38 | INDEX `_IngredientToRecipe_B_index`(`B`)
39 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
40 |
--------------------------------------------------------------------------------
/prisma/migrations/20230226180854_recipe_content/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - Added the required column `content` to the `Recipe` table without a default value. This is not possible if the table is not empty.
5 |
6 | */
7 | -- AlterTable
8 | ALTER TABLE `Recipe` ADD COLUMN `content` VARCHAR(191) NOT NULL;
9 |
--------------------------------------------------------------------------------
/prisma/migrations/20230226193255_recipe_long_content/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE `Recipe` MODIFY `content` TEXT NOT NULL;
3 |
--------------------------------------------------------------------------------
/prisma/migrations/20230304193849_recipe_type_size/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - Added the required column `size` to the `Recipe` table without a default value. This is not possible if the table is not empty.
5 | - Added the required column `type` to the `Recipe` table without a default value. This is not possible if the table is not empty.
6 |
7 | */
8 | -- AlterTable
9 | ALTER TABLE `Recipe` ADD COLUMN `size` VARCHAR(191) NOT NULL,
10 | ADD COLUMN `type` VARCHAR(191) NOT NULL;
11 |
--------------------------------------------------------------------------------
/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (i.e. Git)
3 | provider = "mysql"
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | // This is your Prisma schema file,
2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema
3 |
4 | generator client {
5 | provider = "prisma-client-js"
6 | }
7 |
8 | datasource db {
9 | provider = "mysql"
10 | url = env("DATABASE_URL")
11 | shadowDatabaseUrl = env("SHADOW_DATABASE_URL")
12 | relationMode = "prisma"
13 | }
14 |
15 | model Category {
16 | id String @id @default(cuid())
17 | name String
18 |
19 | ingredient Ingredient[]
20 |
21 | createdAt DateTime @default(now())
22 | updatedAt DateTime @default(now()) @updatedAt
23 | }
24 |
25 | model Ingredient {
26 | id String @id @default(cuid())
27 | name String
28 |
29 | recipes Recipe[]
30 | category Category @relation(fields: [categoryId], references: [id])
31 | categoryId String
32 |
33 | createdAt DateTime @default(now())
34 | updatedAt DateTime @default(now()) @updatedAt
35 |
36 | @@index([categoryId])
37 | }
38 |
39 | model Recipe {
40 | id String @id @default(cuid())
41 |
42 | ingredients Ingredient[]
43 | size String
44 | type String
45 | content String @db.Text
46 |
47 | createdAt DateTime @default(now())
48 | updatedAt DateTime @default(now()) @updatedAt
49 | }
50 |
--------------------------------------------------------------------------------
/public/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/danieljpgo/iachef/d3bdbac9578ded137660bf63e3170ffebfa7b2b6/public/android-chrome-192x192.png
--------------------------------------------------------------------------------
/public/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/danieljpgo/iachef/d3bdbac9578ded137660bf63e3170ffebfa7b2b6/public/android-chrome-512x512.png
--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/danieljpgo/iachef/d3bdbac9578ded137660bf63e3170ffebfa7b2b6/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/danieljpgo/iachef/d3bdbac9578ded137660bf63e3170ffebfa7b2b6/public/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/danieljpgo/iachef/d3bdbac9578ded137660bf63e3170ffebfa7b2b6/public/favicon-32x32.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/danieljpgo/iachef/d3bdbac9578ded137660bf63e3170ffebfa7b2b6/public/favicon.ico
--------------------------------------------------------------------------------
/public/og.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/danieljpgo/iachef/d3bdbac9578ded137660bf63e3170ffebfa7b2b6/public/og.png
--------------------------------------------------------------------------------
/src/components/Button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Spinner } from "~/components";
3 | import { cn } from "~/lib/tailwindcss";
4 |
5 | const variants = {
6 | primary: "bg-orange-500 hover:bg-orange-400 focus:border-orange-400",
7 | secondary:
8 | "bg-amber-400 hover:bg-amber-300 focus:border-amber-400 focus:bg-amber-300",
9 | };
10 |
11 | const sizes = {
12 | sm: "px-3 py-1.5",
13 | md: "px-4 py-2",
14 | lg: "px-6 py-3",
15 | };
16 |
17 | type ButtonProps = {
18 | loading?: boolean;
19 | children: string;
20 | ["aria-label"]?: string;
21 | size?: keyof typeof sizes;
22 | variant?: keyof typeof variants;
23 | disabled?: React.ButtonHTMLAttributes["disabled"];
24 | name?: React.ButtonHTMLAttributes["name"];
25 | value?: React.ButtonHTMLAttributes["value"];
26 | type?: React.ButtonHTMLAttributes["type"];
27 | onClick?: (e: React.MouseEvent) => void;
28 | };
29 |
30 | export function Button(props: ButtonProps) {
31 | const {
32 | children,
33 | type = "button",
34 | name,
35 | value,
36 | disabled,
37 | loading,
38 | size = "md",
39 | variant = "primary",
40 | onClick,
41 | "aria-label": ariaLabel,
42 | } = props;
43 |
44 | return (
45 |
60 |
61 | {children}
62 | {loading && }
63 |
64 |
65 | );
66 | }
67 |
--------------------------------------------------------------------------------
/src/components/Checkbox.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
3 | import { cn } from "~/lib/tailwindcss";
4 |
5 | export const Checkbox = React.forwardRef<
6 | React.ElementRef,
7 | React.ComponentPropsWithoutRef
8 | >(({ className, ...props }, ref) => (
9 |
17 |
18 |
19 | ));
20 | Checkbox.displayName = CheckboxPrimitive.Root.displayName;
21 |
--------------------------------------------------------------------------------
/src/components/Heading.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "~/lib/tailwindcss";
2 |
3 | const tags = {
4 | h1: "h1",
5 | h2: "h2",
6 | h3: "h3",
7 | h4: "h4",
8 | h5: "h5",
9 | h6: "h6",
10 | } as const;
11 |
12 | const sizes = {
13 | base: "text-base",
14 | lg: "text-lg",
15 | xl: "text-xl",
16 | "2xl": "text-2xl",
17 | "3xl": "text-3xl",
18 | "4xl": "text-4xl",
19 | "5xl": "text-5xl",
20 | "6xl": "text-6xl",
21 | } as const;
22 |
23 | const defaultSize = {
24 | h1: "text-3xl",
25 | h2: "text-2xl",
26 | h3: "text-xl",
27 | h4: "text-lg",
28 | h5: "text-base",
29 | h6: "text-sm",
30 | } as const;
31 |
32 | const colors = {
33 | base: "text-gray-600",
34 | dark: "text-gray-700",
35 | darker: "text-gray-800",
36 | blackout: "text-gray-900",
37 | contrast: "text-white",
38 | } as const;
39 |
40 | const trackings = {
41 | tight: "tracking-tight",
42 | normal: "tracking-normal",
43 | } as const;
44 |
45 | const weights = {
46 | black: "font-black",
47 | extrabold: "font-extrabold",
48 | bold: "font-bold",
49 | semibold: "font-semibold",
50 | medium: "font-medium",
51 | normal: "font-normal",
52 | light: "font-light",
53 | } as const;
54 |
55 | type HeadingProps = {
56 | children: string | React.ReactNode;
57 | as?: keyof typeof tags;
58 | weight?: keyof typeof weights;
59 | size?: keyof typeof sizes;
60 | color?: keyof typeof colors;
61 | tracking?: keyof typeof trackings;
62 | };
63 |
64 | export function Heading(props: HeadingProps) {
65 | const {
66 | children,
67 | size,
68 | as: tag = "h2",
69 | color = "dark",
70 | tracking = "normal",
71 | weight = "normal",
72 | } = props;
73 | const Tag = tag;
74 |
75 | return (
76 |
84 | {children}
85 |
86 | );
87 | }
88 |
--------------------------------------------------------------------------------
/src/components/OGTags.tsx:
--------------------------------------------------------------------------------
1 | import { env } from "~/lib/env.mjs";
2 |
3 | type OGTagsProps = {
4 | title: string;
5 | description: string;
6 | };
7 |
8 | export function OGTags(props: OGTagsProps) {
9 | const { description = "", title = "" } = props;
10 | const domain = env.NODE_ENV === "production" ? "iachef.danieljorge.me" : "";
11 | const url = env.NODE_ENV === "production" ? `https://${domain}` : "";
12 | const image = `${url}/og.png`;
13 |
14 | return (
15 | <>
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | >
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/src/components/Spinner.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "~/lib/tailwindcss";
2 |
3 | const sizes = {
4 | sm: "h-5 w-5",
5 | md: "h-10 w-10",
6 | lg: "h-20 w-20",
7 | };
8 |
9 | type SpinnerProps = {
10 | variant?: "contrast";
11 | size?: keyof typeof sizes;
12 | };
13 |
14 | export function Spinner(props: SpinnerProps) {
15 | const { variant, size = "sm" } = props;
16 |
17 | return (
18 |
29 |
33 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/src/components/Tabs.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as TabsPrimitive from "@radix-ui/react-tabs";
3 |
4 | const Root = React.forwardRef<
5 | React.ElementRef,
6 | React.ComponentPropsWithoutRef
7 | >(function TabsRoot({ ...props }, ref) {
8 | return ;
9 | });
10 | Root.displayName = TabsPrimitive.Root.displayName;
11 |
12 | const List = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(function TabsList(props, ref) {
16 | const containerRef = React.useRef(null);
17 | const selectRef = React.useRef(null);
18 | const buttonRefs = React.useRef>([]);
19 |
20 | const childrens = React.Children.map(props.children, (child) => {
21 | if (!React.isValidElement(child) || child.type !== Trigger) {
22 | throw new Error(
23 | `Tabs.List component only accepts as children the Tabs.Trigger component`,
24 | );
25 | }
26 | return child;
27 | });
28 |
29 | React.useEffect(() => {
30 | const updateSelectPosition = () => {
31 | if (!containerRef.current) return;
32 | if (!selectRef.current) return;
33 | if (!buttonRefs.current.length) return;
34 |
35 | const selectedRef = buttonRefs.current.find(
36 | (button) => button?.ariaSelected === "true",
37 | );
38 | if (!selectedRef) return;
39 |
40 | const selectedBoundingBox = selectedRef.getBoundingClientRect();
41 | const containerBoundingBox = containerRef.current.getBoundingClientRect();
42 |
43 | selectRef.current.style.transitionDuration = "250ms";
44 | selectRef.current.style.opacity = "1";
45 | selectRef.current.style.width = `${selectedBoundingBox.width}px`;
46 | selectRef.current.style.transform = `translate(${
47 | selectedBoundingBox.left - containerBoundingBox.left
48 | }px)`;
49 | };
50 |
51 | updateSelectPosition();
52 | }, [props.children]);
53 |
54 | return (
55 |
61 |
65 | {childrens?.map((child, index) => {
66 | return React.cloneElement(child, {
67 | ref: (i: HTMLButtonElement) => (buttonRefs.current[index] = i),
68 | ...child.props,
69 | });
70 | })}
71 |
72 | );
73 | });
74 | List.displayName = TabsPrimitive.List.displayName;
75 |
76 | const Trigger = React.forwardRef<
77 | React.ElementRef,
78 | React.ComponentPropsWithoutRef
79 | >(function TabsTrigger(props, ref) {
80 | return (
81 |
86 | );
87 | });
88 | Trigger.displayName = TabsPrimitive.Trigger.displayName;
89 |
90 | const Content = React.forwardRef<
91 | React.ElementRef,
92 | React.ComponentPropsWithoutRef
93 | >(function TabsContent(props, ref) {
94 | return (
95 |
101 | );
102 | });
103 | Content.displayName = TabsPrimitive.Content.displayName;
104 |
105 | export const Tabs = Object.assign(Root, { List, Trigger, Content });
106 |
107 | // @TODO Hover state
108 | // @TODO Improve typescript
109 | // @TODO remove callback type on React.cloneElement
110 |
111 | // data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm
112 |
--------------------------------------------------------------------------------
/src/components/Text.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "~/lib/tailwindcss";
2 |
3 | const styles = {
4 | italic: "italic",
5 | "not-italic": "not-italic",
6 | } as const;
7 |
8 | const colors = {
9 | lighter: "text-gray-400",
10 | light: "text-gray-500",
11 | base: "text-gray-600",
12 | dark: "text-gray-700",
13 | darker: "text-gray-800",
14 | contrast: "text-white",
15 | error: "text-red-400",
16 | success: "text-green-500",
17 | secondary: "text-orange-500",
18 | } as const;
19 |
20 | const sizes = {
21 | "2xs": "text-[10px]",
22 | xs: "text-xs",
23 | sm: "text-sm",
24 | base: "text-base",
25 | lg: "text-lg",
26 | xl: "text-xl",
27 | } as const;
28 |
29 | const tags = {
30 | span: "span",
31 | p: "p",
32 | b: "b",
33 | i: "i",
34 | strong: "strong",
35 | em: "em",
36 | small: "small",
37 | } as const;
38 |
39 | const weights = {
40 | black: "font-black",
41 | extrabold: "font-extrabold",
42 | bold: "font-bold",
43 | semibold: "font-semibold",
44 | medium: "font-medium",
45 | normal: "font-normal",
46 | light: "font-light",
47 | "extra-light": "font-extralight",
48 | thin: "font-thin",
49 | } as const;
50 |
51 | type TextProps = {
52 | children: string | React.ReactNode;
53 | size?: keyof typeof sizes;
54 | color?: keyof typeof colors;
55 | as?: keyof typeof tags;
56 | weight?: keyof typeof weights;
57 | style?: keyof typeof styles;
58 | };
59 |
60 | export function Text(props: TextProps) {
61 | const {
62 | children,
63 | color = "base",
64 | as = "p",
65 | size = "base",
66 | weight,
67 | style,
68 | } = props;
69 | const Tag = as;
70 |
71 | return (
72 |
80 | {children}
81 |
82 | );
83 | }
84 |
--------------------------------------------------------------------------------
/src/components/index.ts:
--------------------------------------------------------------------------------
1 | export { Button } from "./Button";
2 | export { Checkbox } from "./Checkbox";
3 | export { Heading } from "./Heading";
4 | export { OGTags } from "./OGTags";
5 | export { Spinner } from "./Spinner";
6 | export { Tabs } from "./Tabs";
7 | export { Text } from "./Text";
8 |
--------------------------------------------------------------------------------
/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./useDebounce";
2 |
--------------------------------------------------------------------------------
/src/hooks/useDebounce.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | export function useDebounce(value: T, delay: number): T {
4 | const [debouncedValue, setDebouncedValue] = React.useState(value);
5 |
6 | React.useEffect(() => {
7 | const timer = setTimeout(() => setDebouncedValue(value), delay);
8 |
9 | return () => {
10 | clearTimeout(timer);
11 | };
12 | }, [value, delay]);
13 |
14 | return debouncedValue;
15 | }
16 |
--------------------------------------------------------------------------------
/src/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import "~/styles/globals.css";
2 | import type { AppProps } from "next/app";
3 | import { Inter } from "next/font/google";
4 | import { cn } from "~/lib/tailwindcss";
5 | import { Heading, Text } from "~/components";
6 | import { Analytics } from "@vercel/analytics/react";
7 |
8 | const inter = Inter({ subsets: ["latin"] });
9 |
10 | export default function App(props: AppProps) {
11 | const { Component, pageProps } = props;
12 |
13 | return (
14 | <>
15 |
34 |
35 |
36 |
37 |
96 |
97 | >
98 | );
99 | }
100 |
--------------------------------------------------------------------------------
/src/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import { Html, Head, Main, NextScript } from "next/document";
2 |
3 | export default function Document() {
4 | return (
5 | // scroll-smooth
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/src/pages/api/generate.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import {
3 | createParser,
4 | ParsedEvent,
5 | ReconnectInterval,
6 | } from "eventsource-parser";
7 | import { env } from "~/lib/env.mjs";
8 |
9 | const schema = z.object({
10 | prompt: z
11 | .string()
12 | .startsWith(
13 | "Gerar uma receita tentando utilizar apenas os seguintes ingredientes:",
14 | ),
15 | size: z.string(),
16 | type: z.string(),
17 | ingredients: z.array(z.string()),
18 | });
19 |
20 | export const config = {
21 | runtime: "edge",
22 | };
23 |
24 | export default async function handler(req: Request) {
25 | try {
26 | const validation = schema.safeParse(await req.json());
27 | if (!validation.success) {
28 | return new Response("Invalid parameters", { status: 400 });
29 | }
30 |
31 | const encoder = new TextEncoder();
32 | const decoder = new TextDecoder();
33 | const openAIResponse = await fetch(
34 | "https://api.openai.com/v1/chat/completions",
35 | {
36 | method: "POST",
37 | headers: {
38 | "Content-Type": "application/json",
39 | Authorization: `Bearer ${env.OPENAI_API_KEY}`,
40 | },
41 | body: JSON.stringify({
42 | model: "gpt-3.5-turbo",
43 | messages: [{ role: "user", content: validation.data.prompt }],
44 | temperature: 0.6,
45 | stream: true,
46 | }),
47 | },
48 | );
49 |
50 | // https://beta.openai.com/docs/api-reference/completions/create#completions/create-stream
51 | const stream = new ReadableStream({
52 | async start(controller) {
53 | let content = "";
54 |
55 | async function streamParser(event: ParsedEvent | ReconnectInterval) {
56 | if (event.type !== "event") return;
57 | if (event.data === "[DONE]") {
58 | if (!validation.success) {
59 | return new Response("Invalid parameters", { status: 400 });
60 | }
61 | const url = process.env.VERCEL_URL
62 | ? `http://${process.env.VERCEL_URL}/api/recipe`
63 | : "http://localhost:3000/api/recipe";
64 | const recipeResponse = await fetch(url, {
65 | method: "POST",
66 | headers: {
67 | "Content-Type": "application/json",
68 | Authorization: String(process.env.AUTHORIZED_REQUEST),
69 | },
70 | body: JSON.stringify({
71 | content,
72 | size: validation.data.size,
73 | type: validation.data.type,
74 | ingredients: validation.data.ingredients,
75 | }),
76 | });
77 | if (!recipeResponse.ok) {
78 | controller.error("Failed to save recipe");
79 | return;
80 | }
81 | controller.close();
82 | return;
83 | }
84 | try {
85 | const json = JSON.parse(event.data);
86 | const text = json.choices[0].delta?.content;
87 | const queue = encoder.encode(text);
88 | content = content + (text ?? "");
89 | controller.enqueue(queue);
90 | } catch (e) {
91 | controller.error(e);
92 | }
93 | }
94 | const parser = createParser(streamParser);
95 | for await (const chunk of openAIResponse.body as any) {
96 | parser.feed(decoder.decode(chunk));
97 | }
98 | },
99 | });
100 | return new Response(stream);
101 | } catch (error) {
102 | if (error instanceof Error) {
103 | return new Response(error.message, { status: 500 });
104 | }
105 | return new Response(String(error), { status: 500 });
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/src/pages/api/recipe/count.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from "next";
2 | import { prisma } from "~/lib/prisma";
3 |
4 | export default async function handler(
5 | req: NextApiRequest,
6 | res: NextApiResponse,
7 | ) {
8 | try {
9 | if (req.method === "GET") {
10 | const count = await prisma.recipe.count();
11 | return res.status(200).json({ count });
12 | }
13 |
14 | return res
15 | .setHeader("Allow", ["GET"])
16 | .status(405)
17 | .send("Method Not Allowed");
18 | } catch (error) {
19 | if (error instanceof Error) {
20 | return res.status(500).json({ statusCode: 500, message: error.message });
21 | }
22 | return res.status(500).json({ statusCode: 500, message: String(error) });
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/pages/api/recipe/index.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from "next";
2 | import { z } from "zod";
3 | import { prisma } from "~/lib/prisma";
4 |
5 | const querySchema = z.object({
6 | ingredients: z.string(),
7 | type: z.string(),
8 | size: z.string(),
9 | });
10 | const bodySchema = z.object({
11 | size: z.string(),
12 | type: z.string(),
13 | content: z.string(),
14 | ingredients: z.array(z.string()),
15 | });
16 |
17 | export default async function handler(
18 | req: NextApiRequest,
19 | res: NextApiResponse,
20 | ) {
21 | try {
22 | const { method, body, query } = req;
23 |
24 | if (method === "GET") {
25 | const validation = querySchema.safeParse(query);
26 | if (!validation.success) {
27 | return res
28 | .status(400)
29 | .json({ statusCode: 400, message: "Invalid parameters" });
30 | }
31 |
32 | const recipes = await prisma.recipe.findMany({
33 | include: { ingredients: true },
34 | where: {
35 | type: validation.data.type,
36 | size: validation.data.size,
37 | ingredients: {
38 | every: { id: { in: validation.data.ingredients.split(",") } },
39 | },
40 | },
41 | });
42 |
43 | // @TODO: melhorar
44 | const selectRecipe = recipes.find((recipe) =>
45 | query?.ingredients
46 | ?.toString()
47 | ?.split(",")
48 | .every((id) =>
49 | recipe.ingredients.some((ingredient) => ingredient.id === id),
50 | ),
51 | );
52 |
53 | return res.status(200).json(selectRecipe ?? null); // todo melhorar
54 | }
55 |
56 | if (method === "POST") {
57 | const validation = bodySchema.safeParse(body);
58 | if (!validation.success) {
59 | return res
60 | .status(400)
61 | .json({ statusCode: 400, message: "Invalid parameters" });
62 | }
63 | if (req.headers.authorization !== process.env.AUTHORIZED_REQUEST) {
64 | return res
65 | .status(401)
66 | .json({ statusCode: 401, message: "Unauthorized request" });
67 | }
68 |
69 | const recipes = await prisma.recipe.create({
70 | data: {
71 | content: validation.data.content,
72 | size: validation.data.size,
73 | type: validation.data.type,
74 | ingredients: {
75 | connect: validation.data.ingredients.map((id) => ({ id })),
76 | },
77 | },
78 | });
79 |
80 | return res.status(200).json(recipes);
81 | }
82 |
83 | return res
84 | .setHeader("Allow", ["GET", "POST"])
85 | .status(405)
86 | .send("Method Not Allowed");
87 | } catch (error) {
88 | if (error instanceof Error) {
89 | return res.status(500).json({ statusCode: 500, message: error.message });
90 | }
91 | return res.status(500).json({ statusCode: 500, message: String(error) });
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import type { Category, Ingredient } from "@prisma/client";
2 | import type { GetStaticProps, InferGetStaticPropsType } from "next";
3 | import * as React from "react";
4 | import Head from "next/head";
5 | import useSWR from "swr";
6 | import Balancer from "react-wrap-balancer";
7 | import { z } from "zod";
8 | import * as recipes from "~/lib/recipe";
9 | import { prisma } from "~/lib/prisma";
10 | import { generatePrompt } from "~/lib/prompt";
11 | import { useDebounce } from "~/hooks";
12 | import { Button, Checkbox, Heading, Tabs, Text, OGTags } from "~/components";
13 | import { cn } from "~/lib/tailwindcss";
14 |
15 | export default function Home(
16 | props: InferGetStaticPropsType,
17 | ) {
18 | const { categories, ingredients } = props;
19 | const [recipe, setRecipe] = React.useState("");
20 | const [type, setType] = React.useState<"idle" | "new" | "book">("idle");
21 | const [status, setStatus] = React.useState<
22 | "idle" | "loading" | "success" | "error"
23 | >("idle");
24 | const query = useSWR<{ count: number }>(
25 | "/api/recipe/count",
26 | (key) => fetch(key).then((res) => res.json()),
27 | {
28 | fallback: props.fallback,
29 | refreshInterval: 1000 * 60,
30 | dedupingInterval: 1000 * 60,
31 | },
32 | );
33 | const typeDelayed = useDebounce(type, 1000);
34 | const amountDelayed = useDebounce(query.data.count, 3000);
35 |
36 | async function handleSubmit(form: Form) {
37 | if (status === "loading") return;
38 |
39 | setRecipe("");
40 | setType("idle");
41 | setStatus("loading");
42 |
43 | const recipeResponse = await fetch(
44 | "/api/recipe?" +
45 | new URLSearchParams({
46 | ingredients: form.ingredients.toString(),
47 | size: form.size,
48 | type: form.type,
49 | }),
50 | );
51 |
52 | const recipeData = await recipeResponse.json();
53 | if (recipeData) {
54 | setType("book");
55 | const content = recipeData.content.split("\n");
56 | let count = 0;
57 | while (count < content.length) {
58 | await new Promise((resolve) => setTimeout(resolve, 100));
59 | setRecipe((prev) => prev + content[count] + "\n");
60 | count++;
61 | }
62 |
63 | setStatus("success");
64 | setType("idle");
65 | return;
66 | }
67 |
68 | setType("new");
69 | const prompt = generatePrompt(
70 | ingredients
71 | .filter(({ id }) => form.ingredients.includes(id))
72 | .map(
73 | ({ name }) =>
74 | recipes.ingredients.find((b) => b.name === name)?.label ?? "",
75 | ),
76 | form.type,
77 | form.size,
78 | );
79 |
80 | const openIAResponse = await fetch("/api/generate", {
81 | method: "POST",
82 | headers: { "Content-Type": "application/json" },
83 | body: JSON.stringify({
84 | prompt,
85 | size: form.size,
86 | type: form.type,
87 | ingredients: form.ingredients,
88 | }),
89 | });
90 | if (openIAResponse.status === 429) {
91 | setRecipe("\nNúmero de receitas excedidos, espere alguns minutos.");
92 | setType("idle");
93 | setStatus("error");
94 | return;
95 | }
96 | if (!openIAResponse.ok || !openIAResponse.body) {
97 | setRecipe("\nAlgo de errado aconteceu, tente novamente.");
98 | setType("idle");
99 | setStatus("error");
100 | return;
101 | }
102 |
103 | const reader = openIAResponse.body.getReader();
104 | const decoder = new TextDecoder();
105 |
106 | let done = false;
107 | let content = "";
108 |
109 | while (!done) {
110 | const { value, done: doneReading } = await reader.read();
111 | done = doneReading;
112 | const chunkValue = decoder.decode(value);
113 | content = content + chunkValue;
114 | setRecipe((prev) => prev + chunkValue);
115 | }
116 | setType("idle");
117 | setStatus("success");
118 | query.mutate({ count: query.data.count + 1 });
119 | }
120 |
121 | React.useEffect(() => {
122 | if (recipe.length > 0) {
123 | window.scrollTo(0, document.body.scrollHeight);
124 | }
125 | }, [recipe]);
126 |
127 | return (
128 | <>
129 |
130 | IAChef
131 |
135 |
136 |
137 |
141 |
142 |
143 |
144 |
145 |
151 |
158 |
159 |
160 | Estrela no GitHub
161 |
162 |
163 |
164 |
165 | Gere sua próxima receita em segundos usando{" "}
166 | ChatGPT
167 |
168 |
169 |
170 |
171 |
172 |
173 | Gere sua próxima receita em segundos usando{" "}
174 | ChatGPT
175 |
176 |
177 |
178 |
179 |
180 | {query.data.count} receitas únicas geradas.
181 |
182 |
188 |
189 |
190 |
191 |
192 |
193 |
194 |
202 |
203 |
204 |
205 |
206 |
207 | 👨🍳
208 |
209 | {(() => {
210 | if (type === "book") return "📖";
211 | if (type === "new") return "💬";
212 | if (typeDelayed === "book") return "📖";
213 | if (typeDelayed === "new") return "💬";
214 | if (status === "loading") return "💭";
215 | if (status === "error") return "🚧";
216 | return "";
217 | })()}
218 |
219 |
220 |
221 | {recipe}
222 |
223 |
224 |
225 |
226 |
227 |
228 | >
229 | );
230 | }
231 |
232 | const schema = z.object({
233 | ingredients: z.array(z.string()).min(1),
234 | category: z.string(),
235 | type: z.enum([recipes.types[0].value, recipes.types[1].value]),
236 | size: z.enum([
237 | recipes.sizes[0].value,
238 | recipes.sizes[1].value,
239 | recipes.sizes[2].value,
240 | ]),
241 | });
242 |
243 | type Form = z.infer;
244 |
245 | type HomeFormProps = {
246 | status: "idle" | "loading" | "success" | "error";
247 | categories: InferGetStaticPropsType["categories"];
248 | ingredients: InferGetStaticPropsType["ingredients"];
249 | onSubmit: (data: Form) => void;
250 | };
251 |
252 | function HomeForm(props: HomeFormProps) {
253 | const { categories, ingredients, status, onSubmit } = props;
254 | const [form, setForm] = React.useState