-
Notifications
You must be signed in to change notification settings - Fork 33
Sofie's recipe library #23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a13dbee
318f4e8
7716179
6844c9f
d68ecfb
d82ff01
1a3514f
a814e7f
15320d6
c4bd32f
27482ce
6f3a06b
c1605a1
e3d5a3b
f617f05
5b870f7
28e5f9c
eb65232
503512d
59128a5
187bc7d
1c6360f
6a7c7f5
d6e68e0
7ce27df
a7c3b5f
6e1489a
6487bc4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,7 @@ | ||
# js-project-recipe-library | ||
|
||
Link to netlify: https://stirring-yeot-236514.netlify.app/ | ||
|
||
My second project in the JS bootcamp. | ||
|
||
The Recipe Library App is a web app that helps users find recipes based on different filters and sorting options. It pulls real recipe data from the Spoonacular API and dynamically updates the display as users make selections. The app includes a random recipe option, messages when no results are found and a fully responsive design. To improve performance and user experience, I added features like multi-filter functionality, local storage caching, a loading state and interactive recipe instructions. | ||
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,137 @@ | ||
<!DOCTYPE html> | ||
<html lang="en"> | ||
|
||
<head> | ||
<meta charset="UTF-8"> | ||
<meta | ||
name="viewport" | ||
content="width=device-width, initial-scale=1.0" | ||
> | ||
<!-- Using some fontawesome icons --> | ||
<script | ||
src="https://kit.fontawesome.com/beb1bc6f21.js" | ||
crossorigin="anonymous" | ||
></script> | ||
<link | ||
rel="stylesheet" | ||
href="./style/style.css" | ||
> | ||
<title>Project 2 - Recipe Library</title> | ||
</head> | ||
|
||
<body> | ||
<header> | ||
<a href="./index.html"> | ||
<h1>Recipe Library</h1> | ||
</a> | ||
</header> | ||
|
||
<main> | ||
|
||
<!-- Diet Filter Form --> | ||
<form id="dietForm"> | ||
<div class="form"> | ||
<p>Filter on Diet</p> | ||
<label class="button btn-filter"> | ||
<input | ||
type="radio" | ||
name="diet" | ||
value="all" | ||
> 🍽️ All | ||
</label> | ||
<label class="button btn-filter"> | ||
<input | ||
type="radio" | ||
name="diet" | ||
value="vegan" | ||
> 🌱 Vegan | ||
</label> | ||
<label class="button btn-filter"> | ||
<input | ||
type="radio" | ||
name="diet" | ||
value="vegetarian" | ||
> 🥕 Vegetarian | ||
</label> | ||
<label class="button btn-filter"> | ||
<input | ||
type="radio" | ||
name="diet" | ||
value="glutenFree" | ||
> 🌾 Gluten-Free | ||
</label> | ||
<label class="button btn-filter"> | ||
<input | ||
type="radio" | ||
name="diet" | ||
value="dairyFree" | ||
> 🧀 Dairy-Free | ||
</label> | ||
</div> | ||
|
||
<!-- Sort Filter Form --> | ||
<div class="form"> | ||
<p>Sort by Time</p> | ||
<label class="button btn-sort"> | ||
<input | ||
type="radio" | ||
name="timeFilter" | ||
value="ascending" | ||
> 🕑 A quick one | ||
</label> | ||
<label class="button btn-sort"> | ||
<input | ||
type="radio" | ||
name="timeFilter" | ||
value="descending" | ||
> ⏳ I got time! | ||
</label> | ||
</div> | ||
</form> | ||
|
||
<!-- Random recipe button--> | ||
<div class="form"> | ||
<p>In need of Inspo?</p> | ||
<button | ||
class="button btn-random animationBtn" | ||
id="randomBtn" | ||
>✨ Surprise me!</button> | ||
</div> | ||
|
||
<!-- If an error happens, the message shows here --> | ||
<p | ||
id="errorMessage" | ||
class="form" | ||
></p> | ||
|
||
<!-- Loading message showing while fetching for recipes with som animation --> | ||
<div id="loadingMessage"> | ||
<div id="loadingText"> | ||
<p class="animation">⏳</p> | ||
<h4>Hang on! We are loading some super yummy recipes just for you ✨</h4> | ||
</div> | ||
</div> | ||
|
||
<!-- One <section> per recipe displays here --> | ||
<section | ||
id="cards" | ||
class="card-parent" | ||
> | ||
</section> | ||
|
||
</main> | ||
|
||
<!-- Icons and links--> | ||
<footer> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fun to have a footer, looks cute. |
||
<div class="icons"> | ||
<p>Project by: | ||
<a href="https://github.com/ssofiejohansson"><i class="fa-brands fa-github"></i></a> | ||
<a href="https://www.linkedin.com/in/sofie-j-011155310/"><i class="fa-brands fa-linkedin"></i></a> | ||
</p> | ||
</div> | ||
</footer> | ||
|
||
<script src="./js/script.js"></script> | ||
</body> | ||
|
||
</html> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,176 @@ | ||
// This is where I store recipes globally | ||
let allRecipes = [] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. General comment for the javascript: I think the code is well structured and the naming of functions etc makes it easy to understand what the different parts of the code do. |
||
let filteredRecipes = [] | ||
|
||
// Fetch from API | ||
const fetchRecipes = () => { | ||
|
||
const URL = 'https://api.spoonacular.com/recipes/random?number=24&apiKey=427f448f971e4dcea73ae654c0850b2a' | ||
|
||
const loadingMessage = document.querySelector('#loadingMessage') | ||
const errorMessage = document.getElementById('errorMessage') | ||
|
||
// Show loading message while fetching | ||
loadingMessage.style.display = 'block' | ||
|
||
errorMessage.innerHTML = '' | ||
|
||
fetch(URL) | ||
.then(response => response.json()) | ||
.then(data => { | ||
// If API limit is reached, show error message | ||
if (data.code === 402) { | ||
errorMessage.innerHTML = ` | ||
<h2>Uh oh! You've reached the daily limit of recipe requests. ✨</h2> | ||
<p>Here are some saved recipes for you, but come back tomorrow if you want to get more!</p>` | ||
throw new Error("You've reached the daily limit of recipe requests.") | ||
} | ||
|
||
allRecipes = data.recipes | ||
filteredRecipes = [...allRecipes] | ||
|
||
// Save the recipes in local storage | ||
localStorage.setItem('recipes', JSON.stringify(allRecipes)) | ||
|
||
displayRecipes(filteredRecipes) | ||
}) | ||
.catch((error) => { | ||
console.error(error.message) | ||
|
||
// If API runs out, try loading from local storage | ||
const cachedRecipes = localStorage.getItem('recipes') | ||
if (cachedRecipes) { | ||
allRecipes = JSON.parse(cachedRecipes) | ||
filteredRecipes = [...allRecipes] | ||
displayRecipes(filteredRecipes) | ||
} else { | ||
// Show error message when no cached recipes exist | ||
errorMessage.innerHTML = ` | ||
<h2>Uh oh! There is a problem when loading recipes. Come back tomorrow. ✨</h2> | ||
<p>${error.message}</p>` | ||
} | ||
}) | ||
.finally(() => { | ||
// Hide loading message when request finish so its not showing all the time | ||
loadingMessage.style.display = 'none' | ||
}) | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice to have some data to fall back on when the daily quota is reached. Looks clean too. |
||
|
||
|
||
//Display fetched recipes in my card container | ||
const displayRecipes = (recipesToDisplay) => { | ||
const cardContainer = document.querySelector('#cards') | ||
cardContainer.innerHTML = '' | ||
|
||
recipesToDisplay.forEach(recipe => { | ||
|
||
let dietLabels = [] | ||
|
||
//checks what diet the recipe is and adds it to the dietLabels array above | ||
if (recipe.vegan) dietLabels.push("Vegan") | ||
if (recipe.vegetarian) dietLabels.push("Vegetarian") | ||
if (recipe.glutenFree) dietLabels.push("Gluten Free") | ||
if (recipe.dairyFree) dietLabels.push("Dairy Free") | ||
|
||
cardContainer.innerHTML += ` | ||
<article class="card"> | ||
<div class="card-image"> | ||
<img | ||
src="${recipe.image}" | ||
alt="Recipe image" | ||
/> | ||
</div> | ||
<h2>${recipe.title}</h2> | ||
<hr> | ||
<div class="instructions"> | ||
<h3 class="title">Diet:</h3> | ||
<p>${dietLabels.length > 0 ? dietLabels.join(", ") : "No specific diet"}</p> | ||
</div> | ||
<div class="instructions"> | ||
<h3 class="title">Time:</h3> | ||
<p>${recipe.readyInMinutes} min</p> | ||
</div> | ||
<hr> | ||
<div class="ingredients"> | ||
<h4 class="title">Ingredient list:</h4> | ||
<ul> | ||
${recipe.extendedIngredients.map(ingredient => | ||
`<li>${ingredient.original}</li>`).join("")} | ||
</ul> | ||
</div> | ||
<button class="button btn-random instructionsBtn">⬇️ Recipe instructions</button> | ||
<div class="instructionList" style="display: none;"> | ||
<p>${recipe.instructions}</p> | ||
</div> | ||
<hr> | ||
<a | ||
href="${recipe.sourceUrl}" | ||
target="_blank" | ||
class="recipe-link" | ||
>🔗 View Full Recipe</a> | ||
</article>` | ||
}) | ||
} | ||
|
||
// Function to filter recipes based on diet | ||
const filterByDiet = (filterType) => { | ||
if (filterType === "all") { | ||
filteredRecipes = [...allRecipes] | ||
} else { | ||
filteredRecipes = allRecipes.filter(recipe => recipe[filterType] === true) | ||
} | ||
|
||
if (filteredRecipes.length === 0) { | ||
document.querySelector('#cards').innerHTML = `<p>❌ No ${filterType} recipes found. Why don't you pick another one?✨ </p>` | ||
} else { | ||
displayRecipes(filteredRecipes) | ||
} | ||
} | ||
|
||
// Function to sort recipes by readyInMinutes | ||
const sortByReadyTime = (order) => { | ||
if (order === 'ascending') { | ||
filteredRecipes.sort((a, b) => a.readyInMinutes - b.readyInMinutes) | ||
} else { | ||
filteredRecipes.sort((a, b) => b.readyInMinutes - a.readyInMinutes) | ||
} | ||
|
||
displayRecipes(filteredRecipes) | ||
} | ||
|
||
//click button to show instructions for recipes | ||
document.addEventListener('click', (event) => { | ||
if (event.target.classList.contains('instructionsBtn')) { | ||
const instructionDiv = event.target.nextElementSibling | ||
|
||
if (instructionDiv && instructionDiv.classList.contains('instructionList')) { | ||
instructionDiv.style.display = instructionDiv.style.display === 'none' ? 'block' : 'none' | ||
} | ||
} | ||
}) | ||
|
||
// Function to display a random recipe | ||
const surpriseMe = () => { | ||
if (allRecipes.length > 0) { | ||
const randomRecipe = allRecipes[Math.floor(Math.random() * allRecipes.length)] | ||
displayRecipes([randomRecipe]) | ||
} | ||
} | ||
|
||
// All event listeners | ||
document.querySelectorAll('.btn-filter input').forEach(button => { | ||
button.addEventListener("change", (event) => { | ||
filterByDiet(event.target.value) | ||
}) | ||
}) | ||
|
||
document.querySelectorAll('.btn-sort input').forEach(button => { | ||
button.addEventListener("change", (event) => { | ||
sortByReadyTime(event.target.value) | ||
}) | ||
}) | ||
|
||
document.querySelector('#randomBtn').addEventListener("click", surpriseMe) | ||
|
||
// Display recipes from the start | ||
fetchRecipes() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's great that you included a brief description!