Compare commits

..

No commits in common. "master" and "v0.1.0" have entirely different histories.

87 changed files with 15383 additions and 5094 deletions

View file

@ -1,2 +0,0 @@
NUXT_API_URL=https://www.themealdb.com/api/json/v1/1/
NUXT_PUBLIC_SENTRY_DSN=

40
.gitignore vendored
View file

@ -1,27 +1,23 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Node dependencies
node_modules
# dependencies
/node_modules
/.pnp
.pnp.js
# Logs
logs
*.log
# testing
/coverage
# Misc
# production
/build
# misc
.DS_Store
.fleet
.idea
.env.local
.env.development.local
.env.test.local
.env.production.local
# Local env files
.env
.env.*
!.env.example
.aider*
# Sentry Config File
.env.sentry-build-plugin
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View file

@ -1 +0,0 @@
src/

View file

@ -1,15 +0,0 @@
# CHANGELOG
## v.0.1
- WebApp
- Random meal suggestion
- List of meals by categories
- Search by name: you're looking for a recipe? Ours are easy to make and yummy!
## v.0.2
- Progressive Web App
- User Interface Enhancement
- Secured User Profiles
- Contact form

View file

@ -1,92 +0,0 @@
# Contributing
When contributing to this repository, please first discuss the change you wish to make via issue,
email, or any other method with the owners of this repository before making a change.
Please note we have a code of conduct, please follow it in all your interactions with the project.
## Pull Request Process
1. Ensure any install or build dependencies are removed before the end of the layer when doing a
build.
2. Update the README.md with details of changes to the interface, this includes new environment
variables, exposed ports, useful file locations and container parameters.
3. Increase the version numbers in any examples files and the README.md to the new version that this
Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/).
4. You may merge the Pull Request in once you have the sign-off of two other developers, or if you
do not have permission to do that, you may request the second reviewer to merge it for you.
## Code of Conduct
### Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, gender identity and expression, level of experience,
nationality, personal appearance, race, religion, or sexual identity and
orientation.
### Our Standards
Examples of behavior that contributes to creating a positive environment
include:
- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Gracefully accepting constructive criticism
- Focusing on what is best for the community
- Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
- The use of sexualized language or imagery and unwelcome sexual attention or
advances
- Trolling, insulting/derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or electronic
address, without explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
### Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
### Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
### Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at ruidy.nemausat@gmail.com. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
### Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at [http://contributor-covenant.org/version/1/4][version]
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/

View file

@ -1,21 +0,0 @@
# MIT License
Copyright (c) 2021 Ruidy Nemausat
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,34 +1,13 @@
# Chef's Meal Planner
![header image](https://socialify.git.ci/rjnemo/meal_planner/image?description=1&font=Raleway&language=1&logo=https%3A%2F%2Fmood2food.netlify.app%2Flogo192.png&owner=1&pattern=Diagonal%20Stripes&stargazers=1&theme=Dark)
![license](https://img.shields.io/github/license/rjNemo/meal_planner?style=for-the-badge)
![release tag](https://img.shields.io/github/v/release/rjNemo/meal_planner?style=for-the-badge)
Free meal planner for cooks short on ideas! (like me …)
## Demo
[🚀 App live at this address!](https://mood2food.netlify.app/)
![Screenshot](docs/short_clip.gif)
### Screenshots
#### Home page
![Screenshot](docs/homepage.png)
#### Meal page
![Screenshot](docs/mealpage.png)
## Features
- Random meal suggestion
- Search by name: you look for a recipe? Ours are easy to make and Yummy!
- Random meal suggestion
- Search by name: you're look for a recipe? Ours are easy to make and Yummy!
- What's in the fridge ? Choose your main ingredient and get a meal suggestion
- Choose by a category:
- Choose by category:
- Beef
- Breakfast
- Chicken
@ -69,59 +48,33 @@ Free meal planner for cooks short on ideas! (like me …)
- Unknown
- Vietnamese
- Cocktail selection
- Create a profile and save your favourite meals
- Create a profile and save your favourite meals
- Notation system: know what are the most loved meals
- Share recipe with your friends and family
- Suggestions based on what your personal taste
- Recipes in Video ✓
- Get a full menu (Starter, Main, Dessert + Cocktail)
- Send a daily suggestion to newsletter
- History
- Language selection
- Nutritive value
- Add personal notes
- Recipes in Video
## Supports
- Web
- Progressive Web App
- Web
- Progressive Web App
- Mobile
## Deployment
## Technical Stack
The application is hosted on [Netlify](https://netlify.com/) at the following
address: [link](https://mood2food.netlify.app/).
- `React` client on the front-end
- [Materialize](https://materializecss.com) CSS librairy for styling
- Public API: [TheMealDb](https://www.themealdb.com/api.php) and [TheCocktailDb](https://www.thecocktaildb.com/api.php)
- Hosting: anywhere
## Built With
## Versions
- [Nuxt](https://nuxt.com/) - The Intuitive Vue Framework
- [tRPC](https://trpc.io/) - End-to-end typesafe APIs made easy
- [Tailwindcss](https://tailwindcss.com) - Rapidly build modern websites without
ever leaving your HTML.
- [daisyUI](https://daisyui.com/) - The most popular component library for
Tailwind CSS
- [TheMealDb](https://www.themealdb.com/api.php) - An open, crowd-sourced database
of Recipes from around the world
### Features in V.1
## Contributing
- WebApp
- Random meal suggestion
- List of meals by categories
- Search by name: you're looking for a recipe? Ours are easy to make and yummy!
Please read [CONTRIBUTING.md](https://github.com/rjNemo/meal_planner/contributors)
for details on our code of conduct, and the process for submitting pull requests
to us.
## TO DO
## Versioning
We use [SemVer](http://semver.org/) for versioning. For the versions available, see
the [tags on this repository](https://github.com/rjNemo/meal_planner/tags).
## Authors
- **Ruidy Nemausat** - _Initial work_ - [GitHub](https://github.com/rjNemo)
See also the list of [contributors](https://github.com/rjNemo/meal_planner/contributors)
who participated in this project.
## License
This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md)
file for details
- put a preloader

18
TODO.md
View file

@ -1,18 +0,0 @@
# TO DO
- [x] use bun package manager
- [x] use nuxt framework
- [x] rewrite the random page, the current landing page
- [x] rewrite the recipe page
- [x] deploy
- [x] nuxt image
- [x] prettier and eslint
- [x] transition and loading times
- [ ] pwa
- [x] seo, robots.txt
- [x] update the README
- [ ] create image provider
- [x] fetch recipe per id
- [ ] add mood section
- [ ] store recipes into my db (SQLite)
- [ ] process them using AI

27
app.vue
View file

@ -1,27 +0,0 @@
<template>
<div data-theme="cupcake" class="flex flex-col h-screen">
<app-navbar>
<template #menu>
<li><nuxt-link to="/categories">Categories</nuxt-link></li>
<li><nuxt-link to="/cookbook">Cookbook</nuxt-link></li>
</template>
</app-navbar>
<main class="flex-grow">
<nuxt-page />
</main>
<app-footer />
</div>
</template>
<style>
.page-enter-active,
.page-leave-active {
transition: all 0.4s;
}
.page-enter-from,
.page-leave-to {
opacity: 0;
filter: blur(1rem);
}
</style>

View file

@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

3450
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -1,32 +0,0 @@
<template>
<footer
class="footer bg-base-300 text-base-content items-center p-4 flex justify-between"
>
<aside class="items-center">
<p>
<span class="hidden sm:inline"
>Copyright &copy; {{ new Date().getFullYear() }}
</span>
Made with
</p>
</aside>
<nav class="grid-flow-col gap-4">
<nuxt-link
to="https://github.com/rjNemo/meal_planner"
:external="true"
target="_blank"
aria-label="navigate to the source code on GitHub"
>
<icon name="cib:github" class="w-6 h-6" />
</nuxt-link>
<nuxt-link
to="https://ruidy.nemausat.com"
:external="true"
target="_blank"
aria-label="navigate to my website"
>
<icon name="uil:globe" class="w-6 h-6" />
</nuxt-link>
</nav>
</footer>
</template>

View file

@ -1,60 +0,0 @@
<script setup lang="ts">
const router = useRouter();
const route = useRoute();
const searchQuery = ref((route.query.q as string) || "");
const { execute } = useRecipeRandom();
const handleRandomClick = async () => {
if (route.path !== "/random") {
await router.push("/random");
}
await execute();
};
</script>
<template>
<nav class="navbar bg-base-300">
<div class="navbar-start">
<div class="dropdown">
<div
tabindex="0"
role="button"
class="btn btn-ghost lg:hidden"
arial-label="Menu button"
>
<icon name="uil:bars" class="w-6 h-6" />
<!-- TODO: add transition into cross on click -->
</div>
<ul
tabindex="0"
class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-200 rounded-box w-52"
>
<slot name="menu" />
</ul>
</div>
<nuxt-link to="/" class="btn btn-ghost text-xl">
<nuxt-img src="/logo192.png" width="50" height="50" alt="logo" />
<span style="font-family: cursive"> Chefs </span>
</nuxt-link>
</div>
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1">
<slot name="menu" />
</ul>
</div>
<div class="navbar-end gap-2">
<!-- Search icon for mobile -->
<nuxt-link
to="/search"
class="btn btn-ghost sm:hidden"
aria-label="Search"
>
<icon name="uil:search" class="w-6 h-6" />
</nuxt-link>
<!-- Search bar for larger screens -->
<recipe-search v-model="searchQuery" class="hidden sm:flex" />
<button class="btn btn-primary" @click="handleRandomClick">Random</button>
</div>
</nav>
</template>

View file

@ -1,40 +0,0 @@
<script setup lang="ts">
defineProps<{
title: string;
pictureUrl: string;
videoUrl: string;
category: string;
origin: string;
}>();
</script>
<template>
<div class="card-body items-center text-center bg-base-200">
<h2 class="card-title">{{ title }}</h2>
<figure class="px-10 py-5">
<nuxt-img
:src="pictureUrl"
alt="`${title} picture`"
:placeholder="[300]"
width="300"
height="300"
format="webp"
/>
</figure>
<div class="card-actions space-between">
<nuxt-link
:to="videoUrl"
target="_blank"
aria-label="watch the recipe in video"
>
<icon name="cib:youtube" color="red" />
</nuxt-link>
<div class="badge badge-secondary">
<icon name="cil:apple" /> {{ category }}
</div>
<div class="badge badge-secondary">
<icon name="cil:location-pin" /> {{ origin }}
</div>
</div>
</div>
</template>

View file

@ -1,26 +0,0 @@
<script setup lang="ts">
defineProps<{
ingredients: { name: string; quantity: string }[];
}>();
</script>
<template>
<div class="overflow-x-auto">
<table class="table table-s table-pin-rows table-pin-cols">
<thead>
<tr>
<th />
<td>Ingredient</td>
<td>Quantity</td>
</tr>
</thead>
<tbody>
<tr v-for="(ingredient, i) in ingredients" :key="i">
<th>{{ i + 1 }}</th>
<td>{{ ingredient.name }}</td>
<td>{{ ingredient.quantity }}</td>
</tr>
</tbody>
</table>
</div>
</template>

View file

@ -1,57 +0,0 @@
<template>
<label
class="input input-bordered input-primary flex items-center gap-2 container mx-auto px-4 lg:px-8 my-4"
>
<input
v-model="model"
type="text"
class="grow"
placeholder="Search recipes..."
:autofocus="autofocus"
@focus="isFocused = true"
@blur="isFocused = false"
>
<kbd
class="hidden md:inline-block kbd kbd-sm"
:class="{ 'opacity-50': !isFocused }"
></kbd
>
<kbd
class="hidden md:inline-block kbd kbd-sm"
:class="{ 'opacity-50': !isFocused }"
>K</kbd
>
</label>
</template>
<script setup lang="ts">
const model = defineModel<string>();
defineProps<{ autofocus?: boolean }>();
const isFocused = ref(false);
// Debounced navigation
const debouncedSearch = useDebounceFn((query: string) => {
navigateTo(`/search?q=${encodeURIComponent(query || "")}`);
}, 200);
// Watch for changes in model
watch(model, (newQuery) => {
debouncedSearch(newQuery || "");
});
onMounted(() => {
const handleKeydown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
const inputEl = document.querySelector("input");
inputEl?.focus();
}
};
window.addEventListener("keydown", handleKeydown);
onUnmounted(() => {
window.removeEventListener("keydown", handleKeydown);
});
});
</script>

View file

@ -1,93 +0,0 @@
<script setup lang="ts">
import type { Recipe } from "~/types/recipe";
import { useLocalStorage } from "@vueuse/core";
const { recipe } = defineProps<{ recipe: Recipe }>();
const cookbook = useLocalStorage<Recipe[]>("cookbook", []);
const likedRecipes = ref(new Set<string>());
onMounted(() => {
likedRecipes.value = new Set(cookbook.value.map((recipe) => recipe.id));
console.log("cook", likedRecipes.value);
});
const toggleLike = (recipeId: string) => {
if (likedRecipes.value.has(recipeId)) {
likedRecipes.value.delete(recipeId);
cookbook.value = cookbook.value.filter((recipe) => recipe.id !== recipeId);
} else {
likedRecipes.value.add(recipeId);
const recipeToAdd = recipe;
if (recipeToAdd) {
cookbook.value = [...cookbook.value, recipeToAdd];
}
}
};
const shareRecipe = async (recipe: Recipe) => {
const url =
useRequestURL().href.split("/").slice(0, -1).join("/") + "/" + recipe.id;
if (navigator.share) {
try {
await navigator.share({
title: recipe.title,
text: `Check out this recipe: ${recipe.title}`,
url,
});
} catch (error) {
console.error(error);
alert("Failed to share the recipe.");
}
} else {
alert("Sharing not supported on this device.");
}
};
</script>
<template>
<div class="container mx-auto px-4 lg:px-8">
<div class="flex flex-col lg:flex-row lg:justify-start gap-6 py-4">
<div class="w-full lg:w-[480px]">
<div class="card bg-base-100 shadow-xl mx-auto lg:mx-0">
<recipe-card
:title="recipe.title"
:picture-url="recipe.pictureUrl"
:video-url="recipe.videoUrl"
:category="recipe.category"
:origin="recipe.origin"
/>
</div>
</div>
<div class="w-full lg:w-[480px]">
<recipe-ingredients :ingredients="recipe.ingredients" />
</div>
</div>
<div class="flex flex-col items-center py-6">
<h2 class="text-2xl lg:text-3xl font-semibold mb-4">Instructions</h2>
<p class="prose prose-lg max-w-none w-full">
{{ recipe.instructions }}
</p>
<div class="flex gap-4 mt-4">
<button class="btn btn-accent" @click="shareRecipe(recipe)">
<icon name="uil:share-alt" class="mr-2 w-6 h-6" />
Share Recipe
</button>
<button
class="btn btn-ghost"
:class="{ 'text-red-500': likedRecipes.has(recipe.id) }"
@click="toggleLike(recipe.id)"
>
<icon
:name="likedRecipes.has(recipe.id) ? 'uil:heart' : 'uil:heart-alt'"
class="w-6 h-6"
/>
Like
</button>
</div>
</div>
</div>
</template>

View file

@ -1,4 +0,0 @@
export function useCategories() {
const { $client } = useNuxtApp();
return $client.listCategories.useQuery();
}

View file

@ -1,4 +0,0 @@
export function useCategoryRecipes(category: string) {
const { $client } = useNuxtApp();
return $client.recipesByCategory.useQuery(category);
}

View file

@ -1,4 +0,0 @@
export default function useRecipeById(id: number) {
const { $client } = useNuxtApp();
return $client.recipeGet.useQuery(id);
}

View file

@ -1,4 +0,0 @@
export default function useRecipeRandom() {
const { $client } = useNuxtApp();
return $client.recipeRandom.useQuery();
}

View file

@ -1,4 +0,0 @@
export default function useRecipeSearch(query: string) {
const { $client } = useNuxtApp();
return $client.recipeSearch.useQuery(query);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

View file

@ -1,45 +0,0 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-base-200">
<div class="text-center max-w-md p-8">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<!-- Error Icon -->
<div class="text-error text-6xl mb-4">
<icon name="uil:exclamation-triangle" class="w-16 h-16" />
</div>
<!-- Error Details -->
<h1 class="text-4xl font-bold mb-4">
{{ error?.statusCode || "Error" }}
</h1>
<p class="text-xl mb-6">
{{ error?.statusMessage || "Something went wrong" }}
</p>
<!-- Action Buttons -->
<div class="flex justify-center gap-4">
<button class="btn btn-primary" @click="handleError">
Try Again
</button>
<button class="btn btn-ghost" @click="navigateToHome">
Go Home
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
const error = useError();
const route = useRoute();
const handleError = () => {
clearError({ redirect: route.redirectedFrom?.fullPath ?? "/" });
};
const navigateToHome = () => {
clearError({ redirect: "/" });
};
</script>

View file

@ -1,6 +0,0 @@
// @ts-check
import withNuxt from "./.nuxt/eslint.config.mjs";
export default withNuxt({
ignores: ["**/src/*"],
});

View file

@ -1,83 +0,0 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
devtools: { enabled: true },
modules: [
"@nuxt/eslint",
"@nuxt/image",
"@nuxtjs/robots",
"@sentry/nuxt/module",
"@vueuse/nuxt",
"nuxt-delay-hydration",
"nuxt-icon",
],
app: {
head: {
title: "Mood2Food",
htmlAttrs: { lang: "en" },
meta: [
{ charset: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{
hid: "description",
name: "description",
content: "Meal Planner",
},
],
link: [{ rel: "icon", type: "image/x-icon", href: "/favicon.ico" }],
},
pageTransition: { name: "page", mode: "out-in" },
layoutTransition: { name: "slide", mode: "out-in" },
},
build: {
transpile: ["trpc-nuxt"],
},
css: ["~/assets/css/main.css"],
delayHydration: {
mode: "init",
// enables nuxt-delay-hydration in dev mode for testing
debug: process.env.NODE_ENV === "development",
},
image: {
domains: ["www.themealdb.com"],
},
postcss: {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
},
runtimeConfig: {
// The private keys which are only available server-side
apiUrl: "",
// Keys within public are also exposed client-side
public: {
sentry: {
dsn: "",
},
},
},
ssr: true,
compatibilityDate: "2024-12-13",
sentry: {
sourceMapsUploadOptions: {
org: "ruidy",
project: "meal-planner",
},
autoInjectServerSentry: "top-level-import",
},
sourcemap: {
client: "hidden",
},
});

14398
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,43 +1,36 @@
{
"name": "chefs",
"name": "meal-planner",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev --port=3009",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"format": "bun prettier . --write",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"dependencies": {
"@nuxt/eslint": "^0.7.6",
"@nuxt/image": "^1.11.0",
"@nuxtjs/robots": "5.0.1",
"@sentry/nuxt": "^9.47.1",
"@trpc/client": "^10.45.2",
"@trpc/server": "^10.45.2",
"@vueuse/nuxt": "12.0.0",
"nuxt": "^3.20.1",
"nuxt-icon": "^0.6.10",
"trpc-nuxt": "^0.10.22",
"vue": "^3.5.25",
"vue-router": "^4.6.3",
"zod": "^3.25.76"
"@material-ui/core": "^4.9.0",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.4.0",
"@testing-library/user-event": "^7.2.1",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-router-dom": "^5.1.2",
"react-scripts": "3.3.0"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.19",
"autoprefixer": "^10.4.22",
"daisyui": "^4.12.24",
"nuxt-delay-hydration": "^1.3.8",
"postcss": "^8.5.6",
"prettier": "3.4.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^3.4.18"
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"overrides": {
"@vercel/nft": "^0.27.4"
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

View file

@ -1,52 +0,0 @@
<script setup lang="ts">
const { params } = useRoute();
const routeParam = params.id;
const id = typeof routeParam === "string" ? routeParam : routeParam[0];
const {
data: recipe,
status,
error,
} = id === "random" ? await useRecipeRandom() : await useRecipeById(Number(id));
if (error.value) {
if (error.value.message === "Recipe not found") {
throw createError({
statusCode: 404,
statusMessage: "Recipe not found",
});
}
throw createError({
statusCode: 400,
statusMessage: "Invalid recipe id",
message: error.value.message,
});
}
const url = useRequestURL();
useSeoMeta({
title: `${recipe.value!.title} | Mood2Food`,
description: "The perfect meal that fits your mood",
ogTitle: `${recipe.value!.title} | Mood2Food`,
ogDescription: "The perfect meal that fits your mood",
ogImage: recipe.value!.pictureUrl,
ogUrl: url.href,
twitterTitle: `${recipe.value!.title} | Mood2Food`,
twitterDescription: "The perfect meal that fits your mood",
twitterImage: recipe.value!.pictureUrl,
twitterCard: "summary",
});
</script>
<template>
<div v-if="status !== 'success'" class="container mx-auto px-4 lg:px-8">
<span
class="loading loading-bars loading-lg flex justify-center items-center min-h-screen mx-auto"
/>
</div>
<section v-else>
<recipe-view :recipe="recipe!" />
</section>
</template>

View file

@ -1,88 +0,0 @@
<script setup lang="ts">
const route = useRoute();
const categoryName = route.params.name as string;
const { data: recipes, status } = await useCategoryRecipes(categoryName);
if (!recipes.value) {
throw createError({
statusCode: 404,
statusMessage: "Category not found",
});
}
const { data: categories } = await useCategories();
const category = categories.value?.find((c) => c.name === categoryName);
const url = useRequestURL();
useSeoMeta({
title: `${categoryName} | Mood2Food`,
description: "The perfect meal that fits your mood",
ogTitle: `${categoryName} | Mood2Food`,
ogDescription: "The perfect meal that fits your mood",
ogImage: category!.picture,
ogUrl: url.href,
twitterTitle: `${categoryName} | Mood2Food`,
twitterDescription: "The perfect meal that fits your mood",
twitterImage: category!.picture,
twitterCard: "summary",
});
</script>
<template>
<div>
<div
class="hero h-[40vh] bg-cover bg-center relative"
:style="`background-image: url(${category!.picture})`"
>
<div class="hero-overlay bg-opacity-60" />
<div class="hero-content text-center text-neutral-content">
<h1 class="text-5xl font-bold">{{ category?.name || categoryName }}</h1>
</div>
</div>
<div class="container mx-auto px-4 py-8">
<div class="prose max-w-none mb-12">
<p>{{ category!.description }}</p>
</div>
<div v-if="status === 'pending'" class="flex justify-center my-8">
<span class="loading loading-spinner loading-lg text-primary" />
</div>
<div
v-else-if="recipes?.length"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
>
<div
v-for="recipe in recipes"
:key="recipe.id"
class="card bg-base-100 shadow-xl"
>
<figure>
<nuxt-img
:src="recipe.pictureUrl"
:alt="recipe.title"
:placeholder="[300]"
height="300"
width="300"
format="webp"
/>
</figure>
<div class="card-body">
<h2 class="card-title">{{ recipe.title }}</h2>
<div class="card-actions justify-end">
<nuxt-link :to="`/${recipe.id}`" class="btn btn-primary">
View Recipe
</nuxt-link>
</div>
</div>
</div>
</div>
<div v-else class="alert alert-info">
<span>No recipes found in this category.</span>
</div>
</div>
</div>
</template>

View file

@ -1,73 +0,0 @@
<script setup lang="ts">
const { data: categories, status, error } = useCategories();
if (error.value) {
throw createError({
statusCode: 500,
message: error.value.message,
});
}
const url = useRequestURL();
useSeoMeta({
title: `Recipe categories | Mood2Food`,
description: "The perfect meal that fits your mood",
ogTitle: `Recipe categories | Mood2Food`,
ogDescription: "The perfect meal that fits your mood",
ogImage: "/logo192.png",
ogUrl: url.href,
twitterTitle: `Recipe categories | Mood2Food`,
twitterDescription: "The perfect meal that fits your mood",
twitterImage: "/logo192.png",
twitterCard: "summary",
});
</script>
<template>
<div class="container mx-auto px-4">
<div v-if="status === 'pending'" class="flex justify-center my-8">
<span class="loading loading-spinner loading-lg text-primary" />
</div>
<div
v-else-if="categories!.length > 0"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 my-8"
>
<div
v-for="category in categories"
:key="category.name"
class="card bg-base-100 shadow-xl h-[28rem] sm:h-[32rem] md:h-[36rem] lg:h-[32rem]"
>
<figure>
<nuxt-img
:src="category.picture"
:alt="category.name"
:placeholder="[160, 100]"
height="100"
width="160"
format="webp"
loading="lazy"
/>
</figure>
<div class="card-body">
<h2 class="card-title">{{ category.name }}</h2>
<p class="line-clamp-6 text-sm">
{{ category.description }}
</p>
<div class="card-actions justify-end">
<nuxt-link
:to="`/categories/${category.name}`"
class="btn btn-primary"
>
View Recipes
</nuxt-link>
</div>
</div>
</div>
</div>
<div v-else role="alert" class="alert alert-info my-8 items-center flex">
<icon name="uil:info-circle" class="w-8 h-8" />
<span>No categories available</span>
</div>
</div>
</template>

View file

@ -1,31 +0,0 @@
<script setup lang="ts">
import type { Recipe } from "~/types/recipe";
import { useStorage } from "@vueuse/core";
const cookbook = useStorage<Recipe[]>("cookbook", [], localStorage);
</script>
<template>
<main>
<div
v-if="cookbook.length === 0"
class="flex justify-center items-center min-h-screen"
>
<div class="alert alert-info">
<span>No recipes found in this category.</span>
</div>
</div>
<ul>
<li v-for="recipe in cookbook" :key="recipe.id">
<nuxt-link :to="`/${recipe.id}`">
<recipe-card
:title="recipe.title"
:picture-url="recipe.pictureUrl"
:video-url="recipe.videoUrl"
:category="recipe.category"
:origin="recipe.origin"
/>
</nuxt-link>
</li>
</ul>
</main>
</template>

View file

@ -1,38 +0,0 @@
<script setup lang="ts">
const url = useRequestURL();
useSeoMeta({
title: `Mood2Food`,
description: "The perfect meal that fits your mood",
ogTitle: `Mood2Food`,
ogDescription: "The perfect meal that fits your mood",
ogImage: "/logo192.png",
ogUrl: url.href,
twitterTitle: `Mood2Food`,
twitterDescription: "The perfect meal that fits your mood",
twitterImage: "/logo192.png",
twitterCard: "summary",
});
</script>
<template>
<div class="hero min-h-full bg-base-200">
<div class="hero-content flex-col lg:flex-row-reverse h-full">
<nuxt-img
src="/chef.svg"
alt="Chef holding a knife"
class="max-w-sm object-contain rounded-lg"
:placeholder="[400, 300]"
format="webp"
height="300"
width="400"
/>
<div class="flex flex-col justify-center">
<h1 class="text-5xl font-bold prose">Eat Something New</h1>
<p class="py-6 prose">Generate a random recipe.</p>
<nuxt-link to="/random" class="btn btn-primary">
Random Recipe Now
</nuxt-link>
</div>
</div>
</div>
</template>

View file

@ -1,90 +0,0 @@
<script setup lang="ts">
import type { Recipe } from "~/types/recipe";
const route = useRoute();
const searchQuery = computed(() => route.query.q as string);
const searchResults = ref<Recipe[]>([]);
const loading = ref(false);
if (searchQuery.value) {
loading.value = true;
const { data, error } = await useRecipeSearch(searchQuery.value);
if (error.value) {
throw createError({
statusCode: 500,
message: error.value.message,
});
}
searchResults.value = data.value!;
loading.value = false;
}
watch(searchQuery, async (newQuery) => {
loading.value = true;
const { data, error } = await useRecipeSearch(newQuery.trim());
if (error.value) {
throw createError({
statusCode: 500,
message: error.value.message,
});
}
searchResults.value = data.value!;
loading.value = false;
});
</script>
<template>
<div class="container mx-auto px-4">
<recipe-search
class="md:hidden mb-6"
:initial-query="searchQuery"
:autofocus="true"
/>
<div v-if="loading" class="flex justify-center my-8">
<span class="loading loading-spinner loading-lg text-primary" />
</div>
<div
v-if="searchResults.length > 0"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 my-8"
>
<div
v-for="recipe in searchResults"
:key="recipe.id"
class="card bg-base-100 shadow-xl"
>
<figure>
<nuxt-img
:src="recipe.pictureUrl"
:alt="recipe.title"
format="webp"
loading="lazy"
:placeholder="[350]"
height="350"
width="350"
/>
</figure>
<div class="card-body">
<h2 class="card-title">{{ recipe.title }}</h2>
<p>{{ recipe.category }} {{ recipe.origin }}</p>
<div class="card-actions justify-end">
<nuxt-link :to="`/${recipe.id}`" class="btn btn-primary">
View Recipe
</nuxt-link>
</div>
</div>
</div>
</div>
<div
v-else-if="searchQuery"
role="alert"
class="alert alert-info my-8 items-center flex"
>
<icon name="uil:info-circle" class="w-8 h-8" />
<span>No recipes found for "{{ searchQuery }}"</span>
</div>
</div>
</template>

View file

@ -1,30 +0,0 @@
import { createTRPCNuxtClient, httpBatchLink } from "trpc-nuxt/client";
import type { AppRouter } from "~/server/trpc/routers";
export default defineNuxtPlugin(() => {
const headers = useRequestHeaders();
/**
* createTRPCNuxtClient adds a `useQuery` composable
* built on top of `useAsyncData`.
*/
const client = createTRPCNuxtClient<AppRouter>({
links: [
httpBatchLink({
url: "/api/trpc",
headers() {
// add custom headers here
return {
Authorization: "Bearer token",
...headers,
};
},
}),
],
});
return {
provide: {
client,
},
};
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

40
public/index.html Normal file
View file

@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Chef's Meal Planner" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css"
/>
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/icon?family=Material+Icons"
/>
<title>Chef's Meal Planner</title>
</head>
<body>
<script>
document.addEventListener("DOMContentLoaded", function() {
var elems = document.querySelectorAll(".sidenav");
var instances = M.Sidenav.init(elems, options);
});
</script>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

View file

@ -1,10 +1,10 @@
{
"short_name": "Chef's",
"name": "Chef's | Meal Planner",
"short_name": "Meal Planner",
"name": "Chef's Meal Planner",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 48x48 32x32 24x24 16x16",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
@ -16,15 +16,10 @@
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
},
{
"src": "apple-touch-icon.png",
"type": "image/png",
"sizes": "180x180"
}
],
"start_url": "/",
"start_url": ".",
"display": "standalone",
"theme_color": "#ff6d00",
"theme_color": "#000000",
"background_color": "#ffffff"
}
}

2
public/robots.txt Normal file
View file

@ -0,0 +1,2 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *

View file

@ -1,24 +0,0 @@
import * as Sentry from "@sentry/nuxt";
Sentry.init({
dsn: useRuntimeConfig().public.sentry.dsn,
// We recommend adjusting this value in production, or using tracesSampler for finer control
tracesSampleRate: 1.0,
// This sets the sample rate to be 10%. You may want this to be 100% while
// in development and sample at a lower rate in production
replaysSessionSampleRate: 0.1,
// If the entire session is not sampled, use the below sample rate to sample
// sessions when an error occurs.
replaysOnErrorSampleRate: 1.0,
// If you don't want to use Session Replay, just remove the line below:
integrations: [
Sentry.replayIntegration(),
Sentry.consoleLoggingIntegration(),
],
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
sendDefaultPii: true,
_experiments: {
enableLogs: true,
},
});

View file

@ -1,9 +0,0 @@
import * as Sentry from "@sentry/nuxt";
Sentry.init({
dsn: useRuntimeConfig().public.sentry.dsn,
// We recommend adjusting this value in production, or using tracesSampler for finer control
tracesSampleRate: 1.0,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});

View file

@ -1,8 +0,0 @@
import { createNuxtApiHandler } from "trpc-nuxt";
import { appRouter } from "~/server/trpc/routers";
import { createContext } from "~/server/trpc/context";
export default createNuxtApiHandler({
router: appRouter,
createContext,
});

View file

@ -1,21 +0,0 @@
import type { inferAsyncReturnType } from "@trpc/server";
/**
* Creates context for an incoming request
* @link https://trpc.io/docs/context
*/
export async function createContext(event: H3Event) {
const authorization = getRequestHeader(event, "authorization");
async function getUserFromHeader() {
if (authorization) {
return { isAdmin: true };
}
return null;
}
const user = await getUserFromHeader();
return {
user,
};
}
export type Context = inferAsyncReturnType<typeof createContext>;

View file

@ -1,8 +0,0 @@
import type { inferRouterOutputs } from "@trpc/server";
import { mergeRouters } from "../trpc";
import { recipeRouter } from "./recipes";
export const appRouter = mergeRouters(recipeRouter);
export type AppRouter = typeof appRouter;
export type RouterOutput = inferRouterOutputs<AppRouter>;

View file

@ -1,105 +0,0 @@
import { z } from "zod";
import {
categoriesResponseSchema,
categoryRecipesResponseSchema,
type CategoriesResponse,
} from "~/types/category";
import type { Meal } from "~/types/recipe";
import { parseRecipeData } from "~/utils/recipes";
import { publicProcedure, router } from "../trpc";
const { apiUrl } = useRuntimeConfig();
export const recipeRouter = router({
recipesByCategory: publicProcedure
.input(z.string())
.query(async ({ input }) => {
const data = await $fetch<{ meals: Meal[] }>(
new URL(`filter.php?c=${input}`, apiUrl).href,
);
const result = categoryRecipesResponseSchema.safeParse(data);
if (!result.success) {
throw createError({
statusCode: 404,
statusMessage: "Recipes for category not found",
});
}
return result.data.recipes;
}),
recipeGet: publicProcedure
.input(
z.coerce
.number({
required_error: "recipe id is required",
invalid_type_error: "recipe id must be a number",
})
.positive("recipe id must be positive"),
)
.query(async ({ input }) => {
const data = await $fetch<{ meals: Meal[] }>(
new URL(`lookup.php?i=${input}`, apiUrl).href,
);
if (!data?.meals) {
throw createError({
statusCode: 404,
statusMessage: "Recipe not found",
});
}
const recipes = parseRecipeData(data);
return recipes[0];
}),
recipeRandom: publicProcedure.query(async () => {
const data = await $fetch<{ meals: Meal[] }>(
new URL("random.php", apiUrl).toString(),
);
if (!data?.meals) {
throw createError({
statusCode: 500,
});
}
const recipes = parseRecipeData(data);
return recipes[0];
}),
recipeSearch: publicProcedure
.input(
z.string({
required_error: "search query is required",
}),
)
.query(async ({ input }) => {
const data = await $fetch<{ meals: Meal[] }>(
new URL(`search.php?s=${input}`, apiUrl).href,
);
if (!data?.meals) {
return [];
}
const recipes = parseRecipeData(data);
return recipes;
}),
listCategories: publicProcedure.query(async () => {
const response = await $fetch<CategoriesResponse>(
new URL("categories.php", apiUrl).href,
);
const result = categoriesResponseSchema.safeParse(response);
if (!result.success) {
throw createError({
statusCode: 500,
statusMessage: "Invalid API response format",
data: result.error,
});
}
return result.data.categories.sort((a, b) => a.name.localeCompare(b.name));
}),
});

View file

@ -1,26 +0,0 @@
import { initTRPC, TRPCError } from "@trpc/server";
import type { Context } from "~/server/trpc/context";
// import { authMiddleware } from "~/server/trpc/middleware";
const t = initTRPC.context<Context>().create();
/**
* Unprotected procedure
**/
export const publicProcedure = t.procedure;
export const router = t.router;
export const middleware = t.middleware;
export const mergeRouters = t.mergeRouters;
export const authMiddleware = middleware(({ next, ctx }) => {
if (!ctx.user?.isAdmin) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
user: ctx.user,
},
});
});
export const privateProcedure = t.procedure.use(authMiddleware);

183
src/App.js Normal file
View file

@ -0,0 +1,183 @@
import React, { useState } from "react";
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import HomePage from "./pages/Home";
import MealPage from "./pages/Meal";
import SearchPage from "./pages/Search";
import CategoryListPage from "./pages/CategoryList";
import CategoryPage from "./pages/Category";
import NotFound from "./pages/NotFound";
import Navbar from "./components/Navbar";
import SearchBar from "./components/SearchBar";
import Footer from "./components/Footer";
import "./index.css";
const App = () => {
// State Hooks
const [searchString, setSearchString] = useState("");
const [categories, setCategories] = useState({ categories: [] });
// const [isLoading, setIsLoading] = useState(true); For Preloader
// Default meal object. TODO: Find a better alternative …
const mealDef = {
meals: [
{
idMeal: "52837",
strMeal: "Pilchard puttanesca",
strDrinkAlternate: null,
strCategory: "Pasta",
strArea: "Italian",
strInstructions:
"Cook the pasta following pack instructions.\r\n\r\nHeat the oil in a non-stick frying pan and cook the onion, garlic and chilli for 3-4 mins to soften. Stir in the tomato pur\u00e9e and cook for 1 min, then add the pilchards with their sauce. Cook, breaking up the fish with a wooden spoon, then add the olives and continue to cook for a few more mins.\r\n\r\nDrain the pasta and add to the pan with 2-3 tbsp of the cooking water. Toss everything together well, then divide between plates and serve, scattered with Parmesan.",
strMealThumb:
"https://www.themealdb.com/images/media/meals/vvtvtr1511180578.jpg",
strTags: null,
strYoutube: "https://www.youtube.com/watch?v=wqZzLAPmr9k",
strIngredient1: "Spaghetti",
strIngredient2: "Olive Oil",
strIngredient3: "Onion",
strIngredient4: "Garlic",
strIngredient5: "Red Chilli",
strIngredient6: "Tomato Puree",
strIngredient7: "Pilchards",
strIngredient8: "Black Olives",
strIngredient9: "Parmesan",
strIngredient10: "",
strIngredient11: "",
strIngredient12: "",
strIngredient13: "",
strIngredient14: "",
strIngredient15: "",
strIngredient16: "",
strIngredient17: "",
strIngredient18: "",
strIngredient19: "",
strIngredient20: "",
strMeasure1: "300g",
strMeasure2: "1 tbls",
strMeasure3: "1 finely chopped ",
strMeasure4: "2 cloves minced",
strMeasure5: "1",
strMeasure6: "1 tbls",
strMeasure7: "425g",
strMeasure8: "70g",
strMeasure9: "Shaved",
strMeasure10: "",
strMeasure11: "",
strMeasure12: "",
strMeasure13: "",
strMeasure14: "",
strMeasure15: "",
strMeasure16: "",
strMeasure17: "",
strMeasure18: "",
strMeasure19: "",
strMeasure20: "",
strSource: "https://www.bbcgoodfood.com/recipes/pilchard-puttanesca",
dateModified: null
}
]
};
const [meal, setMeal] = useState(mealDef);
// Fetch API functions
const createURI = (keyword, option) => {
const ROOT = "https://www.themealdb.com/api/json/v1/1/";
if (option === null) {
return `${ROOT}${keyword}.php`;
} else if (option === "filter") {
return `${ROOT}${option}.php?c=${keyword}`;
} else if (option === "lookup") {
return `${ROOT}${option}.php?i=${keyword}`;
}
};
const getFromAPI = (keyword, set, option = null) => {
const URI = createURI(keyword, option);
fetch(URI)
.then(response => response.json())
.then(data => set(data));
};
// Fetch wrappers for each use
const getRandomMeal = () => {
// setIsLoading(true);
getFromAPI("random", setMeal);
// setIsLoading(false);
};
const getMeal = id => {
getFromAPI(id, setMeal, "lookup");
};
const getCategories = () => {
getFromAPI("categories", setCategories);
};
const handleChange = ev => {
const { value } = ev.target;
setSearchString(value);
};
const buttonUrl = "/random";
return (
<Router>
<Navbar handleClick={getRandomMeal} buttonUrl={buttonUrl} />
<div className="container">
<SearchBar searchString={searchString} handleChange={handleChange} />
</div>
<Switch>
<Route
exact
path="/"
render={props => (
<HomePage
{...props}
handleClick={getRandomMeal}
buttonUrl={buttonUrl}
/>
)}
/>
<Route
exact
path={buttonUrl}
render={props => (
<MealPage
{...props}
meal={meal}
getMeal={getRandomMeal}
// isLoading={isLoading}
/>
)}
/>
<Route
exact
path="/categories"
render={props => (
<CategoryListPage
{...props}
categories={categories}
getCategories={getCategories}
/>
)}
/>
<Route path="/categories/:strCategory/">
<CategoryPage
getFromAPI={getFromAPI}
getMeal={getMeal}
setMeal={setMeal}
meal={meal}
/>
</Route>
<Route path="/:idMeal">
<MealPage meal={meal} getMeal={getMeal} />
</Route>
<Route exact path="/search" component={SearchPage} />
{/* We'll have to input searchResults somewhere */}
<Route
render={props => <NotFound {...props} handleClick={getRandomMeal} />}
/>
</Switch>
<Footer />
</Router>
);
};
export default App;

9
src/App.test.js Normal file
View file

@ -0,0 +1,9 @@
import React from 'react';
import { render } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
const { getByText } = render(<App />);
const linkElement = getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View file

@ -0,0 +1,25 @@
import React from "react";
import { Link, useRouteMatch } from "react-router-dom";
const CategoryEntry = props => {
const {
strCategory,
strCategoryThumb,
strCategoryDescription
} = props.category;
const { url } = useRouteMatch();
return (
<div className="row">
<Link to={`${url}/${strCategory}`}>
<li key={props.i}>
<img src={strCategoryThumb} alt={strCategory} />
<h3>{strCategory}</h3> {strCategoryDescription}
</li>
</Link>
</div>
);
};
export default CategoryEntry;

View file

@ -0,0 +1,14 @@
import React from "react";
const CopyrightText = () => {
return (
<span>
© 2020 - Chef's - Made with{" "}
<span role="img" aria-label="heart">
</span>
</span>
);
};
export default CopyrightText;

18
src/components/Footer.js Normal file
View file

@ -0,0 +1,18 @@
import React from "react";
import CopyrightText from "./CopyrightText";
import GitHubLink from "./GitHubLink";
const Footer = () => {
return (
<footer className="page-footer">
<div className="footer-copyright">
<div className="container">
<CopyrightText />
<GitHubLink />
</div>
</div>
</footer>
);
};
export default Footer;

View file

@ -0,0 +1,16 @@
import React from "react";
const GitHubLink = () => {
return (
<a
className="grey-text text-lighten-4 right"
href="https://github.com/rjNemo/meal_planner"
target="blank"
rel="noopener"
>
GitHub
</a>
);
};
export default GitHubLink;

View file

@ -0,0 +1,18 @@
import React from "react";
const IngredientList = props => {
const { ingredients } = props;
return (
<div className="ingredientList">
<h3>Ingredients</h3>
<ul>
{ingredients.map((ing, i) => (
<li key={i}>
<b>{ing[0]}:</b> {ing[1]}
</li>
))}
</ul>
</div>
);
};
export default IngredientList;

14
src/components/Logo.js Normal file
View file

@ -0,0 +1,14 @@
import React from "react";
import { Link } from "react-router-dom";
const Logo = () => {
return (
<Link to="/" className="brand-logo">
<span role="img" aria-label="cookie">
👩🍳 Chef's
</span>
</Link>
);
};
export default Logo;

View file

@ -0,0 +1,56 @@
import React from "react";
import { Link } from "react-router-dom";
const MealPresentation = props => {
const {
mealName,
imgAddress,
videoAddress,
mealCategory,
mealArea
} = props.meal;
return (
<div className="row">
<div className="col s12">
<div className="card blue-grey darken-1">
<div className="card-content white-text">
<span className="card-title">{mealName}</span>
<img className="responsive-img" src={imgAddress} alt={mealName} />
<ul>
<li>
<a href={videoAddress} target="blank">
See in video
</a>
</li>
{/* <video width="" height="" controls autoplay>
<source src={videoAddress} type="video/mp4" />
Your browser does not support the video tag.
</video> */}
{/* <iframe
title="video"
width="560"
height="315"
src="https://www.youtube.com/embed/wqZzLAPmr9k"
frameborder="0"
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe> */}
<li>
<b>Category: </b> {mealCategory} (
<Link to={`/categories/${mealCategory}`}>
See every {mealCategory} recipes
</Link>
)
</li>
<li>
<b>Origin:</b> {mealArea}
</li>
</ul>
</div>
</div>
</div>
</div>
);
};
export default MealPresentation;

73
src/components/Navbar.js Normal file
View file

@ -0,0 +1,73 @@
import React from "react";
import Logo from "./Logo";
import RandomButton from "./RandomButton";
import { Link } from "react-router-dom";
const Navbar = props => {
return (
<div className="row">
<nav>
<div className="nav-wrapper">
<div className="container">
<Logo />
<ul id="nav-mobile" className="right hide-on-med-and-down">
<li>
<Link to="/categories">Categories</Link>
</li>
<li>
<RandomButton
handleClick={props.handleClick}
url={props.buttonUrl}
/>
</li>
</ul>
</div>
</div>
</nav>
{/* <ul id="slide-out" class="sidenav">
<li>
<div class="user-view">
<div class="background">
<img src="images/office.jpg" />
</div>
<a href="#user">
<img class="circle" src="images/yuna.jpg" />
</a>
<a href="#name">
<span class="white-text name">John Doe</span>
</a>
<a href="#email">
<span class="white-text email">jdandturk@gmail.com</span>
</a>
</div>
</li>
<li>
<a href="#!">
<i class="material-icons">cloud</i>First Link With Icon
</a>
</li>
<li>
<a href="#!">Second Link</a>
</li>
<li>
<div class="divider"></div>
</li>
<li>
<a class="subheader">Subheader</a>
</li>
<li>
<a class="waves-effect" href="#!">
Third Link With Waves
</a>
</li>
</ul>
<a href="#" data-target="slide-out" class="sidenav-trigger">
<i class="material-icons">menu</i>
</a> */}
</div>
);
};
export default Navbar;

View file

@ -0,0 +1,21 @@
import React from "react";
const PreLoader = () => {
return (
<div className="preloader-wrapper active">
<div className="spinner-layer spinner-red-only">
<div className="circle-clipper left">
<div className="circle"></div>
</div>
<div className="gap-patch">
<div className="circle"></div>
</div>
<div className="circle-clipper right">
<div className="circle"></div>
</div>
</div>
</div>
);
};
export default PreLoader;

View file

@ -0,0 +1,17 @@
import React from "react";
import { Link } from "react-router-dom";
const RandomButton = props => {
return (
<Link to={props.url}>
<button
className="waves-effect waves-light btn-small"
onClick={props.handleClick}
>
Random Recipe
</button>
</Link>
);
};
export default RandomButton;

11
src/components/Recipe.js Normal file
View file

@ -0,0 +1,11 @@
import React from "react";
const Recipe = props => {
return (
<div className="recipe">
<h3>Instructions</h3>
<div dangerouslySetInnerHTML={{ __html: props.recipe }} />
</div>
);
};
export default Recipe;

View file

@ -0,0 +1,15 @@
import React from "react";
const SearchBar = props => {
return (
<input
type="text"
name="search"
value={props.searchString}
placeholder="Search a recipe"
onChange={props.handleChange}
//{onSubmit={props.handleSubmit}
/>
);
};
export default SearchBar;

View file

@ -0,0 +1,16 @@
import React from "react";
const SearchResultList = () => {
return (
<div>
<ul>
<li>Recipe #1</li>
<li>Recipe #2</li>
<li>Recipe #</li>
<li>Recipe #N</li>
</ul>
</div>
);
};
export default SearchResultList;

BIN
src/images/parallax1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 MiB

BIN
src/images/parallax2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 556 KiB

28
src/index.css Normal file
View file

@ -0,0 +1,28 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
display: flex;
min-height: 100vh;
flex-direction: column;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
}
main {
flex: 1 0 auto;
}
div {
white-space: pre-wrap;
}
.background {
background-image: url(./images/parallax1.jpg);
}

9
src/index.js Normal file
View file

@ -0,0 +1,9 @@
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import * as serviceWorker from "./serviceWorker";
ReactDOM.render(<App />, document.getElementById("root"));
serviceWorker.unregister();

44
src/pages/Category.js Normal file
View file

@ -0,0 +1,44 @@
import React, { useEffect, useState } from "react";
import { useParams, Link, useRouteMatch } from "react-router-dom";
const CategoryPage = props => {
const [meals, setMeals] = useState({ meals: [] });
const { getFromAPI } = props;
const { strCategory } = useParams();
const getMeals = () => {
getFromAPI(strCategory, setMeals, "filter");
};
useEffect(() => {
getMeals();
}, []);
const { url } = useRouteMatch();
// const {
// strCategory,
// strCategoryThumb,
// strCategoryDescription
// } = props.category;
return (
<div className="container">
<h1>Chef's {strCategory} Recipes</h1>
{/* <img src={strCategoryThumb} alt={strCategory} />
<p>{strCategoryDescription}</p> */}
<ul>
{meals.meals.map((meal, i) => (
<li key={i}>
<Link to={`/${meal.idMeal}`}>
{/* <Link to="/"> */}
<img src={meal.strMealThumb} alt={meal.strMeal} />
<h3>{meal.strMeal}</h3>
</Link>
</li>
))}
</ul>
</div>
);
};
export default CategoryPage;

26
src/pages/CategoryList.js Normal file
View file

@ -0,0 +1,26 @@
import React, { useEffect } from "react";
import CategoryEntry from "../components/CategoryEntry";
const CategoryListPage = props => {
const categories = props.categories.categories;
const { getCategories } = props;
useEffect(() => {
getCategories();
}, []);
return (
<div className="section">
<div className="container">
<h1>The Chef's Meal Categories</h1>
<ul>
{categories.map((category, i) => (
<CategoryEntry i={i} category={category} />
))}
</ul>
</div>
</div>
);
};
export default CategoryListPage;

15
src/pages/Home.js Normal file
View file

@ -0,0 +1,15 @@
import React from "react";
import RandomButton from "../components/RandomButton";
const HomePage = props => {
return (
<div className="section background">
<div className="container center-align">
<h1>The Chef's Meal Suggestions</h1>
<RandomButton handleClick={props.handleClick} url={props.buttonUrl} />
</div>
</div>
);
};
export default HomePage;

61
src/pages/Meal.js Normal file
View file

@ -0,0 +1,61 @@
import React, { useEffect } from "react";
import MealPresentation from "../components/MealPresentation";
import IngredientList from "../components/IngredientList";
import Recipe from "../components/Recipe";
import { useParams } from "react-router-dom";
// import PreLoader from "../components/PreLoader";
const MealPage = props => {
const meal = props.meal.meals[0];
const { getMeal } = props;
const { idMeal } = useParams();
useEffect(() => {
idMeal === null ? getMeal() : getMeal(idMeal);
}, []);
const {
strMeal,
strMealThumb,
strYoutube,
strCategory,
strArea,
strInstructions
} = meal;
const item = {
mealName: strMeal,
imgAddress: strMealThumb,
videoAddress: strYoutube,
mealCategory: strCategory,
mealArea: strArea
};
let ingredientList = [];
var i;
for (i = 1; i <= 20; i++) {
var strIng = `strIngredient${i}`;
var strMes = `strMeasure${i}`;
if (meal[strIng] !== "" && meal[strIng] !== null) {
ingredientList.push([meal[strIng], meal[strMes]]);
}
}
// const page =
// return isLoading ? <PreLoader /> : page;
return (
<div className="container">
<div className="row">
<div className="col s6">
<MealPresentation meal={item} />
</div>
<div className="col s6">
<IngredientList ingredients={ingredientList} />
<Recipe recipe={strInstructions} />
</div>
</div>
</div>
);
};
export default MealPage;

21
src/pages/NotFound.js Normal file
View file

@ -0,0 +1,21 @@
import React from "react";
import RandomButton from "../components/RandomButton";
const NotFoundPage = props => {
return (
<div className="section">
<div className="container center-align">
<h1>Wrong Way!</h1>
<img
src="https://images.otstatic.com/prod/26153735/2/large.jpg"
alt="404 not found"
/>
<div className="row">
<RandomButton handleClick={props.handleClick} />
</div>
</div>
</div>
);
};
export default NotFoundPage;

18
src/pages/Search.js Normal file
View file

@ -0,0 +1,18 @@
import React, { Component } from "react";
import SearchResultList from "../components/SearchResultList";
export default class SearchPage extends Component {
constructor(props) {
super(props);
this.initState = {};
this.state = this.initState;
}
render() {
return (
<div>
<h1>Search Results</h1>
<SearchResultList />
</div>
);
}
}

137
src/serviceWorker.js Normal file
View file

@ -0,0 +1,137 @@
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://bit.ly/CRA-PWA
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
export function register(config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://bit.ly/CRA-PWA'
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(swUrl, config) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch(error => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl, config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl, {
headers: { 'Service-Worker': 'script' }
})
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type');
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
);
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
registration.unregister();
});
}
}

5
src/setupTests.js Normal file
View file

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom/extend-expect';

View file

@ -1,19 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./components/**/*.{js,vue,ts}",
"./layouts/**/*.vue",
"./pages/**/*.vue",
"./plugins/**/*.{js,ts}",
"./app.vue",
"./error.vue",
],
theme: {
extend: {},
},
plugins: [require("@tailwindcss/typography"), require("daisyui")],
daisyui: {
themes: ["cupcake"],
logs: false,
},
};

View file

@ -1,4 +0,0 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
}

View file

@ -1,42 +0,0 @@
import { z } from "zod";
const categorySchema = z
.object({
idCategory: z.string(),
strCategory: z.string(),
strCategoryThumb: z.string().url(),
strCategoryDescription: z.string(),
})
.transform((c) => ({
identity: c.idCategory,
name: c.strCategory,
picture: c.strCategoryThumb,
description: c.strCategoryDescription,
}));
export const categoriesResponseSchema = z.object({
categories: z.array(categorySchema),
});
export type Category = z.infer<typeof categorySchema>;
export type CategoriesResponse = z.infer<typeof categoriesResponseSchema>;
export const categoryRecipeSchema = z
.object({
strMeal: z.string(),
strMealThumb: z.string().url(),
idMeal: z.string(),
})
.transform((meal) => ({
title: meal.strMeal,
pictureUrl: meal.strMealThumb,
id: meal.idMeal,
}));
export const categoryRecipesResponseSchema = z
.object({
meals: z.array(categoryRecipeSchema),
})
.transform((data) => ({
recipes: data.meals,
}));

View file

@ -1,73 +0,0 @@
import { z } from "zod";
export type Recipe = {
id: string;
title: string;
pictureUrl: string;
videoUrl: string;
category: string;
origin: string;
ingredients: { name: string; quantity: string }[];
instructions: string;
};
const mealSchema = z.object({
idMeal: z.string(),
strMeal: z.string(),
strCategory: z.string(),
strArea: z.string(),
strInstructions: z.string(),
strMealThumb: z.string().url(),
strTags: z.string().nullable(),
strYoutube: z.string(),
strIngredient1: z.string().nullish(),
strIngredient2: z.string().nullish(),
strIngredient3: z.string().nullish(),
strIngredient4: z.string().nullish(),
strIngredient5: z.string().nullish(),
strIngredient6: z.string().nullish(),
strIngredient7: z.string().nullish(),
strIngredient8: z.string().nullish(),
strIngredient9: z.string().nullish(),
strIngredient10: z.string().nullish(),
strIngredient11: z.string().nullish(),
strIngredient12: z.string().nullish(),
strIngredient13: z.string().nullish(),
strIngredient14: z.string().nullish(),
strIngredient15: z.string().nullish(),
strIngredient16: z.string().nullish(),
strIngredient17: z.string().nullish(),
strIngredient18: z.string().nullish(),
strIngredient19: z.string().nullish(),
strIngredient20: z.string().nullish(),
strMeasure1: z.string().nullish(),
strMeasure2: z.string().nullish(),
strMeasure3: z.string().nullish(),
strMeasure4: z.string().nullish(),
strMeasure5: z.string().nullish(),
strMeasure6: z.string().nullish(),
strMeasure7: z.string().nullish(),
strMeasure8: z.string().nullish(),
strMeasure9: z.string().nullish(),
strMeasure10: z.string().nullish(),
strMeasure11: z.string().nullish(),
strMeasure12: z.string().nullish(),
strMeasure13: z.string().nullish(),
strMeasure14: z.string().nullish(),
strMeasure15: z.string().nullish(),
strMeasure16: z.string().nullish(),
strMeasure17: z.string().nullish(),
strMeasure18: z.string().nullish(),
strMeasure19: z.string().nullish(),
strMeasure20: z.string().nullish(),
strSource: z.string().nullish(),
strImageSource: z.string().nullable(),
});
export const apiResponseSchema = z.object({
meals: z.array(mealSchema),
});
export type Meal = z.infer<typeof mealSchema>;
export type ApiResponse = z.infer<typeof apiResponseSchema>;

View file

@ -1,95 +0,0 @@
import { describe, expect, it } from "vitest";
import type { Recipe } from "~/types/recipe";
import { parseRecipeData } from "~/utils/recipes";
const sampleApiResponse = {
meals: [
{
idMeal: "52915",
strMeal: "French Omelette",
strDrinkAlternate: null,
strCategory: "Miscellaneous",
strArea: "French",
strInstructions:
"Get everything ready. Warm a 20cm (measured across the top) non-stick frying pan on a medium heat. Crack the eggs into a bowl and beat them with a fork so they break up and mix, but not as completely as you would for scrambled egg. With the heat on medium-hot, drop one knob of butter into the pan. It should bubble and sizzle, but not brown. Season the eggs with the Parmesan and a little salt and pepper, and pour into the pan.\r\nLet the eggs bubble slightly for a couple of seconds, then take a wooden fork or spatula and gently draw the mixture in from the sides of the pan a few times, so it gathers in folds in the centre. Leave for a few seconds, then stir again to lightly combine uncooked egg with cooked. Leave briefly again, and when partly cooked, stir a bit faster, stopping while theres some barely cooked egg left. With the pan flat on the heat, shake it back and forth a few times to settle the mixture. It should slide easily in the pan and look soft and moist on top. A quick burst of heat will brown the underside.\r\nGrip the handle underneath. Tilt the pan down away from you and let the omelette fall to the edge. Fold the side nearest to you over by a third with your fork, and keep it rolling over, so the omelette tips onto a plate or fold it in half, if thats easier. For a neat finish, cover the omelette with a piece of kitchen paper and plump it up a bit with your fingers. Rub the other knob of butter over to glaze. Serve immediately.",
strMealThumb:
"https://www.themealdb.com/images/media/meals/yvpuuy1511797244.jpg",
strTags: "Egg",
strYoutube: "https://www.youtube.com/watch?v=qXPhVYpQLPA",
strIngredient1: "Eggs",
strIngredient2: "Butter",
strIngredient3: "Parmesan",
strIngredient4: "Tarragon",
strIngredient5: "Parsley",
strIngredient6: "Chives",
strIngredient7: "Gruyère",
strIngredient8: "",
strIngredient9: "",
strIngredient10: "",
strIngredient11: "",
strIngredient12: "",
strIngredient13: "",
strIngredient14: "",
strIngredient15: "",
strIngredient16: "",
strIngredient17: "",
strIngredient18: "",
strIngredient19: "",
strIngredient20: "",
strMeasure1: "3",
strMeasure2: "2 knobs",
strMeasure3: "1 tsp",
strMeasure4: "3 chopped",
strMeasure5: "1 tbs chopped",
strMeasure6: "1 tbs chopped",
strMeasure7: "4 tbs",
strMeasure8: "",
strMeasure9: "",
strMeasure10: "",
strMeasure11: "",
strMeasure12: "",
strMeasure13: "",
strMeasure14: "",
strMeasure15: "",
strMeasure16: "",
strMeasure17: "",
strMeasure18: "",
strMeasure19: "",
strMeasure20: "",
strSource:
"https://www.bbcgoodfood.com/recipes/1669/ultimate-french-omelette",
strImageSource: null,
strCreativeCommonsConfirmed: null,
dateModified: null,
},
],
};
describe("parseRecipeData", () => {
it("should parse the API response into the Recipe type", () => {
const expectedResult: Recipe[] = [
{
title: "French Omelette",
pictureUrl:
"https://www.themealdb.com/images/media/meals/yvpuuy1511797244.jpg",
videoUrl: "https://www.youtube.com/watch?v=qXPhVYpQLPA",
category: "Miscellaneous",
origin: "French",
ingredients: [
{ name: "Eggs", quantity: "3" },
{ name: "Butter", quantity: "2 knobs" },
{ name: "Parmesan", quantity: "1 tsp" },
{ name: "Tarragon", quantity: "3 chopped" },
{ name: "Parsley", quantity: "1 tbs chopped" },
{ name: "Chives", quantity: "1 tbs chopped" },
{ name: "Gruyère", quantity: "4 tbs" },
],
instructions:
"Get everything ready. Warm a 20cm (measured across the top) non-stick frying pan on a medium heat. Crack the eggs into a bowl and beat them with a fork so they break up and mix, but not as completely as you would for scrambled egg. With the heat on medium-hot, drop one knob of butter into the pan. It should bubble and sizzle, but not brown. Season the eggs with the Parmesan and a little salt and pepper, and pour into the pan.\r\nLet the eggs bubble slightly for a couple of seconds, then take a wooden fork or spatula and gently draw the mixture in from the sides of the pan a few times, so it gathers in folds in the centre. Leave for a few seconds, then stir again to lightly combine uncooked egg with cooked. Leave briefly again, and when partly cooked, stir a bit faster, stopping while theres some barely cooked egg left. With the pan flat on the heat, shake it back and forth a few times to settle the mixture. It should slide easily in the pan and look soft and moist on top. A quick burst of heat will brown the underside.\r\nGrip the handle underneath. Tilt the pan down away from you and let the omelette fall to the edge. Fold the side nearest to you over by a third with your fork, and keep it rolling over, so the omelette tips onto a plate or fold it in half, if thats easier. For a neat finish, cover the omelette with a piece of kitchen paper and plump it up a bit with your fingers. Rub the other knob of butter over to glaze. Serve immediately.",
},
];
const result = parseRecipeData(sampleApiResponse);
expect(result).toEqual(expectedResult);
});
});

View file

@ -1,30 +0,0 @@
import type { ApiResponse, Meal, Recipe } from "~/types/recipe";
import { apiResponseSchema } from "~/types/recipe";
export function parseRecipeData(data: ApiResponse): Recipe[] {
return apiResponseSchema.parse(data).meals.map((meal: Meal) => {
const ingredients: { name: string; quantity: string }[] = [];
for (let i = 1; i <= 20; i++) {
const ingredientName = meal[`strIngredient${i}`];
const ingredientQuantity = meal[`strMeasure${i}`];
if (ingredientName?.trim() && ingredientQuantity?.trim()) {
ingredients.push({
name: ingredientName.trim(),
quantity: ingredientQuantity.trim(),
});
}
}
return {
id: meal.idMeal,
title: meal.strMeal,
pictureUrl: meal.strMealThumb,
videoUrl: meal.strYoutube,
category: meal.strCategory,
origin: meal.strArea,
ingredients,
instructions: meal.strInstructions,
};
});
}