Compare commits
169 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83cc273e52 | |||
| 4ab8d98e2c | |||
| bfd83a367b | |||
| 3e34609fcf | |||
| 35af888724 | |||
| fc022e024d | |||
| 29db641392 | |||
| b7aca38912 | |||
| 3a66b00c74 | |||
| 32be4bb0df | |||
| 0d93ff37e4 | |||
| 7decbe7cbe | |||
| 5bd9f2c382 | |||
| b19cb02763 | |||
| a1bc45941d | |||
| 534a98b36f | |||
| 55d857e658 | |||
| 7c4f6419cd | |||
| 34a1f62813 | |||
| 4329d61c43 | |||
| c756a129a2 | |||
| 8e7316dd81 | |||
| 77ea021b59 | |||
| 27c7ffacf6 | |||
| ba78374f81 | |||
| fb84b4bfc6 | |||
| 2010270bcf | |||
| 296b2048e9 | |||
| ed3d1bc853 | |||
| d75d292b2a | |||
| 85481b5f31 | |||
| eb296fe761 | |||
| e042e06b19 | |||
| 2872907a98 | |||
| 6dffeb5766 | |||
| 6f5f66d02e | |||
| 72824daf63 | |||
| 2d9fdd07c2 | |||
| a5d328a133 | |||
| 2dee5123a4 | |||
| e85f98a532 | |||
| 4d1f1f7486 | |||
| 76624f206b | |||
| 9b2fed6716 | |||
| 045d118b3f | |||
| e5ede6f01a | |||
| fbf840d448 | |||
| a75ee69b94 | |||
| 821a48dfe4 | |||
| 3c4d4db1be | |||
| df158d3ff4 | |||
| a9f4df0071 | |||
| 767471f2bb | |||
| ae63d45fe6 | |||
| f19874e4c7 | |||
| 7c3bfb0ea8 | |||
| 884095b0e0 | |||
| 1539a03084 | |||
| 277ede1ad3 | |||
| b2680e7d22 | |||
| 11d0e4682b | |||
| a567a4c41a | |||
| 98888fd814 | |||
| 6d7c856ef9 | |||
| e80b11ef17 | |||
| a89c2fcd24 | |||
| a146bea1ba | |||
| b17abd0cd4 | |||
| df4c28235c | |||
| 22a19315ec | |||
| a7554b9801 | |||
| e63805539b | |||
| d35253be03 | |||
| 7708d7dfe0 | |||
| 0e944d614f | |||
| d9af585209 | |||
|
|
7c7c3f0114 | ||
| 5fbd50075c | |||
|
|
df60890a42 | ||
| dcb3aa48dc | |||
|
|
5165f11263 | ||
|
|
2d2357e925 | ||
|
|
d6616db8d2 | ||
|
|
2211b05eac | ||
|
|
90441f97f2 | ||
|
|
3e33f956a7 | ||
|
|
9e30d1e221 | ||
|
|
96108820a6 | ||
|
|
d4e0ced0ae | ||
|
|
e5b1158ed5 | ||
|
|
e5cabb2b4e | ||
|
|
9451c420c3 | ||
|
|
04ea2c46b8 | ||
|
|
a80aa5d677 | ||
|
|
05f198ca74 | ||
|
|
ac48bc0fe7 | ||
|
|
61515e72c7 | ||
|
|
3fa6801129 | ||
|
|
9d2af60aa0 | ||
|
|
cbc7eefe1b | ||
|
|
9bcabb2584 | ||
|
|
82c523857e | ||
|
|
1819754d7d | ||
|
|
3773e79185 | ||
|
|
75fb6726f6 | ||
|
|
ea9b38cf76 | ||
|
|
0ce8b1cfc2 | ||
|
|
09b69aeccb | ||
|
|
c3df2ba713 | ||
|
|
b873a0b93e | ||
|
|
f0ff25ef5e | ||
|
|
fecfc95982 | ||
|
|
2ac94f24f1 | ||
|
|
16757b2218 | ||
|
|
e8ac939fc9 | ||
|
|
7cde13f071 | ||
|
|
cb101b22ec | ||
|
|
0d8cc9c9b3 | ||
|
|
8409d09373 | ||
|
|
0e3b0a7cee | ||
| 0b5a0a991c | |||
| 0531ee0d39 | |||
|
|
500c6a1b73 | ||
|
|
00e603df0a | ||
|
|
c5942e8d47 | ||
|
|
32daef87c3 | ||
|
|
963f2217e1 | ||
| a1605dadc6 | |||
| a07c91a332 | |||
| c4212eeb54 | |||
| b36b005f47 | |||
| 81f92227c2 | |||
| 7fd605fd5d | |||
| e664753691 | |||
| 93b76aecdb | |||
| 99a7226d40 | |||
| 29087ca71f | |||
| 6132ed34ca | |||
| b5706c3a2e | |||
| 2a6f843d36 | |||
| 47c9d5f4d9 | |||
| 0967f5f2ad | |||
| 5b2cc29195 | |||
| 90f9bda6f4 | |||
| f4833c4214 | |||
| c20a7330c8 | |||
| df86b94dd6 | |||
| abd510457c | |||
| b9ad1db190 | |||
| 0c1e426669 | |||
| 99e61fdd74 | |||
| bce9407986 | |||
| 4c8ffd52f3 | |||
| f329f6733a | |||
| a649006d87 | |||
| 12fdc42bd5 | |||
| fd3362a169 | |||
| df80472fd7 | |||
| df6c0e01bb | |||
| 24b6c29d7f | |||
| 257c14fa90 | |||
| 85f6f9efc1 | |||
| 162857407d | |||
| b624122f13 | |||
| b9067c85bd | |||
| c23636c249 | |||
| cb9f6e3ed8 | |||
| 0a9a00dbc3 | |||
| a1462ed9e0 |
2
.env.example
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
NUXT_API_URL=https://www.themealdb.com/api/json/v1/1/
|
||||||
|
NUXT_PUBLIC_SENTRY_DSN=
|
||||||
40
.gitignore
vendored
|
|
@ -1,23 +1,27 @@
|
||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# Nuxt dev/build outputs
|
||||||
|
.output
|
||||||
|
.data
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
dist
|
||||||
|
|
||||||
# dependencies
|
# Node dependencies
|
||||||
/node_modules
|
node_modules
|
||||||
/.pnp
|
|
||||||
.pnp.js
|
|
||||||
|
|
||||||
# testing
|
# Logs
|
||||||
/coverage
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
# production
|
# Misc
|
||||||
/build
|
|
||||||
|
|
||||||
# misc
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.env.local
|
.fleet
|
||||||
.env.development.local
|
.idea
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
|
|
||||||
npm-debug.log*
|
# Local env files
|
||||||
yarn-debug.log*
|
.env
|
||||||
yarn-error.log*
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.aider*
|
||||||
|
# Sentry Config File
|
||||||
|
.env.sentry-build-plugin
|
||||||
|
|
|
||||||
1
.prettierignore
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
src/
|
||||||
15
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# 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
|
||||||
92
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
# 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/
|
||||||
21
LICENSE.md
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# 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.
|
||||||
87
README.md
|
|
@ -1,13 +1,34 @@
|
||||||
# Chef's Meal Planner
|
# Chef's Meal Planner
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
Free meal planner for cooks short on ideas! (like me …)
|
Free meal planner for cooks short on ideas! (like me …)
|
||||||
|
|
||||||
|
## Demo
|
||||||
|
|
||||||
|
[🚀 App live at this address!](https://mood2food.netlify.app/)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Screenshots
|
||||||
|
|
||||||
|
#### Home page
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### Meal page
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Random meal suggestion
|
- Random meal suggestion ✓
|
||||||
- Search by name: you're look for a recipe? Ours are easy to make and Yummy!
|
- Search by name: you 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
|
- What's in the fridge ? Choose your main ingredient and get a meal suggestion
|
||||||
- Choose by category:
|
- Choose by a category: ✓
|
||||||
- Beef
|
- Beef
|
||||||
- Breakfast
|
- Breakfast
|
||||||
- Chicken
|
- Chicken
|
||||||
|
|
@ -48,33 +69,59 @@ Free meal planner for cooks short on ideas! (like me …)
|
||||||
- Unknown
|
- Unknown
|
||||||
- Vietnamese
|
- Vietnamese
|
||||||
- Cocktail selection
|
- 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
|
- Notation system: know what are the most loved meals
|
||||||
|
- Share recipe with your friends and family
|
||||||
- Suggestions based on what your personal taste
|
- Suggestions based on what your personal taste
|
||||||
- Recipes in Video
|
- 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
|
||||||
|
|
||||||
## Supports
|
## Supports
|
||||||
|
|
||||||
- Web
|
- Web ✓
|
||||||
- Progressive Web App
|
- Progressive Web App ✓
|
||||||
- Mobile
|
- Mobile
|
||||||
|
|
||||||
## Technical Stack
|
## Deployment
|
||||||
|
|
||||||
- `React` client on the front-end
|
The application is hosted on [Netlify](https://netlify.com/) at the following
|
||||||
- [Materialize](https://materializecss.com) CSS librairy for styling
|
address: [link](https://mood2food.netlify.app/).
|
||||||
- Public API: [TheMealDb](https://www.themealdb.com/api.php) and [TheCocktailDb](https://www.thecocktaildb.com/api.php)
|
|
||||||
- Hosting: anywhere
|
|
||||||
|
|
||||||
## Versions
|
## Built With
|
||||||
|
|
||||||
### Features in V.1
|
- [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
|
||||||
|
|
||||||
- WebApp
|
## Contributing
|
||||||
- Random meal suggestion
|
|
||||||
- List of meals by categories
|
|
||||||
- Search by name: you're looking for a recipe? Ours are easy to make and yummy!
|
|
||||||
|
|
||||||
## TO DO
|
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.
|
||||||
|
|
||||||
- put a preloader
|
## 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
|
||||||
|
|
|
||||||
18
TODO.md
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# 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
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<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>
|
||||||
3
assets/css/main.css
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
32
components/app/footer.vue
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
<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 © {{ 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>
|
||||||
60
components/app/navbar.vue
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<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>
|
||||||
40
components/recipe/card.vue
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<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>
|
||||||
26
components/recipe/ingredients.vue
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<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>
|
||||||
57
components/recipe/search.vue
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
<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>
|
||||||
93
components/recipe/view.vue
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
<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>
|
||||||
4
composables/useCategories.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export function useCategories() {
|
||||||
|
const { $client } = useNuxtApp();
|
||||||
|
return $client.listCategories.useQuery();
|
||||||
|
}
|
||||||
4
composables/useCategoryRecipes.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export function useCategoryRecipes(category: string) {
|
||||||
|
const { $client } = useNuxtApp();
|
||||||
|
return $client.recipesByCategory.useQuery(category);
|
||||||
|
}
|
||||||
4
composables/useRecipeById.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export default function useRecipeById(id: number) {
|
||||||
|
const { $client } = useNuxtApp();
|
||||||
|
return $client.recipeGet.useQuery(id);
|
||||||
|
}
|
||||||
4
composables/useRecipeRandom.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export default function useRecipeRandom() {
|
||||||
|
const { $client } = useNuxtApp();
|
||||||
|
return $client.recipeRandom.useQuery();
|
||||||
|
}
|
||||||
4
composables/useRecipeSearch.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export default function useRecipeSearch(query: string) {
|
||||||
|
const { $client } = useNuxtApp();
|
||||||
|
return $client.recipeSearch.useQuery(query);
|
||||||
|
}
|
||||||
BIN
docs/homepage.png
Normal file
|
After Width: | Height: | Size: 259 KiB |
BIN
docs/mealpage.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
docs/short_clip.gif
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
45
error.vue
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
<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>
|
||||||
6
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
// @ts-check
|
||||||
|
import withNuxt from "./.nuxt/eslint.config.mjs";
|
||||||
|
|
||||||
|
export default withNuxt({
|
||||||
|
ignores: ["**/src/*"],
|
||||||
|
});
|
||||||
83
nuxt.config.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
// 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
65
package.json
|
|
@ -1,36 +1,43 @@
|
||||||
{
|
{
|
||||||
"name": "meal-planner",
|
"name": "chefs",
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"type": "module",
|
||||||
"@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"
|
|
||||||
},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"build": "nuxt build",
|
||||||
"build": "react-scripts build",
|
"dev": "nuxt dev --port=3009",
|
||||||
"test": "react-scripts test",
|
"generate": "nuxt generate",
|
||||||
"eject": "react-scripts eject"
|
"preview": "nuxt preview",
|
||||||
|
"postinstall": "nuxt prepare",
|
||||||
|
"format": "bun prettier . --write",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"dependencies": {
|
||||||
"extends": "react-app"
|
"@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"
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"devDependencies": {
|
||||||
"production": [
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
">0.2%",
|
"autoprefixer": "^10.4.22",
|
||||||
"not dead",
|
"daisyui": "^4.12.24",
|
||||||
"not op_mini all"
|
"nuxt-delay-hydration": "^1.3.8",
|
||||||
],
|
"postcss": "^8.5.6",
|
||||||
"development": [
|
"prettier": "3.4.2",
|
||||||
"last 1 chrome version",
|
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||||
"last 1 firefox version",
|
"tailwindcss": "^3.4.18"
|
||||||
"last 1 safari version"
|
},
|
||||||
]
|
"overrides": {
|
||||||
|
"@vercel/nft": "^0.27.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
52
pages/[id].vue
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<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>
|
||||||
88
pages/categories/[name].vue
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
<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>
|
||||||
73
pages/categories/index.vue
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
<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>
|
||||||
31
pages/cookbook.vue
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<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>
|
||||||
38
pages/index.vue
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<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>
|
||||||
90
pages/search.vue
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
<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>
|
||||||
30
plugins/client.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
1
public/chef.svg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 15 KiB |
|
|
@ -1,40 +0,0 @@
|
||||||
<!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>
|
|
||||||
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 47 KiB |
|
|
@ -1,10 +1,10 @@
|
||||||
{
|
{
|
||||||
"short_name": "Meal Planner",
|
"short_name": "Chef's",
|
||||||
"name": "Chef's Meal Planner",
|
"name": "Chef's | Meal Planner",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "favicon.ico",
|
"src": "favicon.ico",
|
||||||
"sizes": "64x64 32x32 24x24 16x16",
|
"sizes": "64x64 48x48 32x32 24x24 16x16",
|
||||||
"type": "image/x-icon"
|
"type": "image/x-icon"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -16,10 +16,15 @@
|
||||||
"src": "logo512.png",
|
"src": "logo512.png",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"sizes": "512x512"
|
"sizes": "512x512"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "apple-touch-icon.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "180x180"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"start_url": ".",
|
"start_url": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"theme_color": "#000000",
|
"theme_color": "#ff6d00",
|
||||||
"background_color": "#ffffff"
|
"background_color": "#ffffff"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
# https://www.robotstxt.org/robotstxt.html
|
|
||||||
User-agent: *
|
|
||||||
24
sentry.client.config.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
9
sentry.server.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
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,
|
||||||
|
});
|
||||||
8
server/api/trpc/[trpc].ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { createNuxtApiHandler } from "trpc-nuxt";
|
||||||
|
import { appRouter } from "~/server/trpc/routers";
|
||||||
|
import { createContext } from "~/server/trpc/context";
|
||||||
|
|
||||||
|
export default createNuxtApiHandler({
|
||||||
|
router: appRouter,
|
||||||
|
createContext,
|
||||||
|
});
|
||||||
21
server/trpc/context.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
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>;
|
||||||
8
server/trpc/routers/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
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>;
|
||||||
105
server/trpc/routers/recipes.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
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));
|
||||||
|
}),
|
||||||
|
});
|
||||||
26
server/trpc/trpc.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
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
|
|
@ -1,183 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
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();
|
|
||||||
});
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
const CopyrightText = () => {
|
|
||||||
return (
|
|
||||||
<span>
|
|
||||||
© 2020 - Chef's - Made with{" "}
|
|
||||||
<span role="img" aria-label="heart">
|
|
||||||
❤️
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CopyrightText;
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
const Recipe = props => {
|
|
||||||
return (
|
|
||||||
<div className="recipe">
|
|
||||||
<h3>Instructions</h3>
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: props.recipe }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
export default Recipe;
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
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;
|
|
||||||
|
Before Width: | Height: | Size: 6.4 MiB |
|
Before Width: | Height: | Size: 556 KiB |
|
|
@ -1,28 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
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();
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
// 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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
// 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';
|
|
||||||
19
tailwind.config.js
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
/** @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,
|
||||||
|
},
|
||||||
|
};
|
||||||
4
tsconfig.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
// https://nuxt.com/docs/guide/concepts/typescript
|
||||||
|
"extends": "./.nuxt/tsconfig.json"
|
||||||
|
}
|
||||||
42
types/category.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
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,
|
||||||
|
}));
|
||||||
73
types/recipe.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
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>;
|
||||||
95
utils/recipes.test.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
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 there’s 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 that’s 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 there’s 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 that’s 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
30
utils/recipes.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||