diff --git a/README.md b/README.md index 58f1a8a66..5c3ea5176 100644 --- a/README.md +++ b/README.md @@ -1 +1,2 @@ # js-project-recipe-library +Link to Netlify: https://js-recipe-library.netlify.app/ \ No newline at end of file diff --git a/cross-white.svg b/cross-white.svg new file mode 100644 index 000000000..eacf48c91 --- /dev/null +++ b/cross-white.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 000000000..3e890f3d4 --- /dev/null +++ b/index.html @@ -0,0 +1,89 @@ + + + + + + Recipe Library + + + + +
+ + +
+

Recipe Library

+
+
+

Filter on kitchen

+
+ + + + + + + + +
+
+
+
+

Sort on cooking time

+
+ + +
+
+
+

Can't decide?

+
+ +
+
+
+
+ +
+
+ + + \ No newline at end of file diff --git a/script.js b/script.js new file mode 100644 index 000000000..50c1d15c6 --- /dev/null +++ b/script.js @@ -0,0 +1,395 @@ +// global variables +const apiKey = "cf8d763903c74744a4d13c68cc9aa6c8"; +const url = `https://api.spoonacular.com/recipes/random?number=25&apiKey=${apiKey}`; +let activeFilters = []; +let favoriteRecipes = JSON.parse(localStorage.getItem("favoriteRecipes")) || []; +let allRecipes = []; + +// DOM elements +const allButtons = document.querySelectorAll(".btn"); +const filterButtons = document.querySelectorAll(".filter-container .btn"); +const sortButtons = document.querySelectorAll(".sort-container .btn"); +const randomButton = document.getElementById("random-button"); +const favoriteButton = document.getElementById("favorite-button"); +const favoriteButtonHeart = document.getElementById("favorite-button-heart"); +const searchButton = document.getElementById("search-button"); +const cardContainer = document.getElementById("card-container"); +const favoriteRecipeHearts = document.querySelectorAll(".card-container .fa-heart"); +const modalOverlay = document.getElementById("modal-overlay"); +const modalContent = document.getElementById("modal-content"); +const modalCrossIcon = document.getElementById("cross-icon"); + + +// fetch data from API +const fetchData = async () => { + // create a variable with data from local storage. Set fallback to empty array so value is never null + const storedRecipes = JSON.parse(localStorage.getItem("recipes")) || []; + + // show loading state when fetching data + let fetchLoadingState = true; + + if (fetchLoadingState === true){ + cardContainer.innerHTML = `

Loading...

`; + } + + try { + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + + const data = await response.json(); + + const fetchedRecipes = data.recipes; + + // turn off loading state + fetchLoadingState = false; + + // if fetch is successful, save it to local storage and update allRecipes + if(fetchedRecipes && fetchedRecipes.length > 0 ) { + localStorage.setItem("recipes", JSON.stringify(fetchedRecipes)); + allRecipes = fetchedRecipes; + // if the fetch has no data (empty array), update allRecipes with data from local storage + } else { + allRecipes = storedRecipes; + } + + if (allRecipes.length > 0) { + showRecipeCards(allRecipes); + } else { + cardContainer.innerHTML = `

No recipes found locally or from API.

`; + } + } + // if fetch fails + catch(error) { + console.error('Fetch error:', error); + // update allRecipes with data from local storage + allRecipes = storedRecipes; + + if (allRecipes.length > 0) { + showRecipeCards(allRecipes); + } else { + cardContainer.innerHTML = `

No recipes found locally or from API.

`; + } + } +}; + + +const showRecipeCards = (recipeArray) => { + + //reset card container before filling it + cardContainer.innerHTML = ""; + + if(!recipeArray || recipeArray.length === 0) { + cardContainer.innerHTML = `

Oh no!🥲 Unfortunately there are no recipes matching your current filter. Try another one!

`; + } + + recipeArray.forEach((recipe) => { + + const card = document.createElement("div"); + card.classList.add("card"); + card.dataset.id = recipe.id; + + // check if the recipes id match any id of the recipes in favoriteRecipes. If true, set the variable to the class of active state for the heart icon + const heartIconClass = favoriteRecipes.some(favoriteRecipe => favoriteRecipe.id === recipe.id) ? "fas" : "far"; + + + // create elements in each card with content from each recipe + card.innerHTML += ` +
+
+ +
+ ${recipe.title} +

${recipe.title}

+
+
+

Cuisine: ${ + recipe.cuisines && recipe.cuisines.length > 0 + ? recipe.cuisines.join(", ") + : "Not specified" + }

+

Time: ${recipe.readyInMinutes} min

+

Servings: ${recipe.servings}

+
+
+
+

${recipe.summary.split(". ").slice(0, 2).join(". ") + "."}

+
+ + +
+ + ` + + // create a
  • for every ingredient in each recipe and append it to the ingredient list in the card + recipe.extendedIngredients.forEach((ing) => { + const eachIngredient = document.createElement("li"); + eachIngredient.textContent = ing.original; + const ingredientList = card.querySelector(".ingredient-list"); + ingredientList.appendChild(eachIngredient); + }); + + // append the card to the card container + cardContainer.appendChild(card); + }); +}; + + +function openCloseModal() { + modalOverlay.classList.toggle("hidden"); +}; + + +function showCardInModal(cardButton) { + // find the card which button was clicked + const clickedCard = cardButton.closest(".card"); + + // copy its inner HTML into the modal content + modalContent.innerHTML = clickedCard.innerHTML; + + // remove some intial elements from the card + modalContent.querySelector(".card-button").remove(); + modalContent.querySelector(".heart-icon-container").remove(); + modalContent.querySelector(".recipe-content .recipe-summary").classList.toggle("hidden"); + + // make the recipe's instructions and ingredients visible + modalContent.querySelector(".recipe-content .recipe-instruction").classList.toggle("hidden"); + modalContent.querySelector(".recipe-content .ingredients").classList.toggle("hidden"); +}; + + +const filterCardsOnKitchen = activeFilters => { + // if there are no active filters (all button is active), or if undefined + if (!activeFilters || activeFilters.length === 0) { + const filteredCards = []; + showRecipeCards(allRecipes); + } else { + filteredCards = allRecipes.filter(recipe => + recipe.cuisines && recipe.cuisines.some(cuisine => + activeFilters.includes(cuisine) + )); + showRecipeCards(filteredCards); + } +}; + + +const sortCardsOnCookingTime = buttonText => { + // create an array of the cards + const sortedCardArray = [...document.querySelectorAll(".card")]; + // get the cooking time of each card + const getTime = (card) => { + const text = card.querySelector(".time").textContent; + // extract and return the number from it + const number = text.match(/\d+/); + return number ? parseInt(number[0], 10) : 0; + }; + + sortedCardArray.sort((a, b) => { + if (buttonText === "Descending") { + return getTime(b) - getTime(a); + } else if (buttonText === "Ascending") { + return getTime(a) - getTime(b); + } + }); + + // re-append the cards in sorted order + const cardContainer = document.getElementById("card-container"); + sortedCardArray.forEach((card) => + cardContainer.appendChild(card) + ); +}; + + +const randomizeCard = () => { + const randomIndex = Math.floor(Math.random() * allRecipes.length); + const randomRecipe = allRecipes[randomIndex]; + showRecipeCards([randomRecipe]); +} + + +const updateActiveFilters = (buttonText, buttonIsActive) => { + if(buttonText === "All") { + activeFilters = []; + } else if(buttonIsActive) { + activeFilters.push(buttonText); + } else { + activeFilters = activeFilters.filter(activeFilter => activeFilter !== buttonText); + } + return activeFilters; +} + + +const updateFavoriteRecipes = (recipeId, recipeIsLiked) => { + // find the corresponding recipe (in allRecipes) for the clicked card by comparing their recipe ID's + clickedRecipe = allRecipes.find(recipe => recipe.id === recipeId); + if (!clickedRecipe) return; + + if (recipeIsLiked === true) { + // add that recipe to favoriteRecipes, if not already there + const isAlreadyFavorite = favoriteRecipes.some(favoriteRecipe => favoriteRecipe.id === recipeId); + if (!isAlreadyFavorite) favoriteRecipes.push(clickedRecipe); + } else { + // remove that recipe from favorites + favoriteRecipes = favoriteRecipes.filter(favoriteRecipe => favoriteRecipe.id !== recipeId); + } + + addFavoriteRecipesToLocalStorage(); +}; + + +// to make sure there are no recipes in favoriteRecipes that no longer exist in allrecipes + +const addFavoriteRecipesToLocalStorage = () => { +// create a set of valid recipe IDs from allRecipes +const validRecipeIds = new Set(allRecipes.map(recipe => recipe.id)); + +// filter favoriteRecipes so only recipes that exist in allRecipes are left +favoriteRecipes = favoriteRecipes.filter(favoriteRecipe => validRecipeIds.has(favoriteRecipe.id)); + +// save cleaned favoriteRecipes to localStorage +localStorage.setItem("favoriteRecipes", JSON.stringify(favoriteRecipes)); +} + + + +const filterCardsOnSearch = liveInputText => { + if (liveInputText && liveInputText.length > 0) { + const filteredCards = allRecipes.filter(recipe => (recipe.title.toLowerCase()).includes(liveInputText.toLowerCase())); + showRecipeCards(filteredCards); + } else { + showRecipeCards(allRecipes); + } +}; + + +// event listeners + +document.addEventListener("DOMContentLoaded", () => { + fetchData(); +}); + + + +filterButtons.forEach(filterButton => { + filterButton.addEventListener("click", () => { + const buttonText = filterButton.innerText; + // if the clicked button is "All", remove the class "active" from all the other buttons + if(buttonText === "All") { + allButtons.forEach((button) => button.classList.remove("active")); + filterButton.classList.add("active"); + // if the clicked button is any other, remove the class "active" from the filterAllButton and toggle the class on the clicked button + } else if(buttonText !== "All") { + const filterAllButton = document.getElementById("filter-all-button"); + filterAllButton.classList.remove("active"); + filterButton.classList.toggle("active"); + // remove "active" from sorting buttons or random button, in case they are active + sortButtons.forEach((sortButton) => sortButton.classList.remove("active")); + randomButton.classList.remove("active"); + } + + // create a variable to know if button is currently active + const buttonIsActive = filterButton.classList.contains("active"); + + updateActiveFilters(buttonText, buttonIsActive); + filterCardsOnKitchen(activeFilters); + }); +}); + + +sortButtons.forEach(sortButton => { + sortButton.addEventListener("click", () => { + // remove active state from all sort buttons + sortButtons.forEach((sortButton) => sortButton.classList.remove("active")); + + sortButton.classList.add("active"); + + const buttonText = sortButton.innerText; + // if button is clicked to active + if (sortButton.classList.contains("active")) { + sortCardsOnCookingTime(buttonText); + } + }); +}); + + +randomButton.addEventListener("click", () => { + // remove the active state from all buttons + allButtons.forEach((button) => button.classList.remove("active")); + + randomButton.classList.toggle("active"); + + if(randomButton.classList.contains("active")) { + randomizeCard(); + } else { + showRecipeCards(allRecipes); + } +}); + + +// set click listener on cardContainer that always exist in the DOM, since the card and heart icons are added dynamically +cardContainer.addEventListener("click", (e) => { + // the closest heart of clicked element (e.target is the DOM element that was clicked) + const heartIcon = e.target.closest(".far, .fas"); + // the recipe id of the closest card of the clicked element + const recipeId = parseInt(e.target.closest(".card").dataset.id); + + // exit if the click is not the heart icon + if (!heartIcon) return; + + // toggle active state of the heart icon + heartIcon.classList.toggle("far"); + heartIcon.classList.toggle("fas"); + + const recipeIsLiked = heartIcon.classList.contains("fas"); + + updateFavoriteRecipes(recipeId, recipeIsLiked); +}); + + + +// set click listener on cardContainer that always exist in the DOM, since the card and its buttons are added dynamically +cardContainer.addEventListener("click", (e) => { + // e.target is the DOM element that was clicked. + const cardButton = e.target.closest(".card-button"); + + // exit if the click is not the card button + if (!cardButton) return; + + openCloseModal(); + showCardInModal(cardButton); +}); + + + +modalCrossIcon.addEventListener("click", openCloseModal); + + +favoriteButton.addEventListener("click", () => { + const favoriteButtonIsActive = favoriteButton.classList.toggle("active"); + + // remove active state from all other buttons + allButtons.forEach((button) => { + if(button !== favoriteButton) button.classList.remove("active"); + }); + + showRecipeCards(favoriteButtonIsActive ? favoriteRecipes: allRecipes); + +}); + + +searchButton.addEventListener("input", (e) =>{ + const liveInputText = e.target.value; + filterCardsOnSearch(liveInputText); +}); \ No newline at end of file diff --git a/style.css b/style.css new file mode 100644 index 000000000..7e601f486 --- /dev/null +++ b/style.css @@ -0,0 +1,339 @@ +body { + box-sizing: border-box; + font-family: "Futura", sans-serif; + margin: 15px 30px 70px; + background-color: #FAFBFF; +} + +.hidden { + display: none !important; + /* marking it important to make classList toggle in JS to work */ +} + +.top-container { + display: flex; + justify-content: flex-end; + gap: 20px; +} + +.favorite-button { + padding: 8px 16px; + font-size: 16px; +} + +.search-button { + padding: 8px 16px; + border: none; + border-bottom: 1px solid rgb(171, 171, 171); +} + +.favorite-button .fas.fa-heart { + color: #FF6589; +} + +.favorite-button.active { + background-color: #0018A4; + color: white; +} + +.title { + color: #0018A4; + font-weight: 700; + font-style: bold; + font-size: 64px; + margin-bottom: 52px 0; +} + +.controls-container { + display: flex; + flex-direction: column; + margin-bottom: 62px; +} + +.sort-random-container { + display: flex; + gap: 46px; +} + +.filter-container { + margin-bottom: 24px; +} + +.filter-buttons-container { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.btn { + border-radius: 50px; + padding: 8px 16px; + border: none; +} + +.filter-container .btn { + background-color: #CCFFE2; + color: #0018A4; +} + +.sort-container .btn { + background-color: #FFECEA; + color: #0018A4; +} + +.random-container .btn { + background: linear-gradient(90deg,rgba(204, 255, 226, 1) 0%, rgba(255, 236, 234, 1) 80%); + color: #0018A4; +} + +.btn:hover { + outline: 2px solid #0018A4; +} + +.filter-container .active, +.card-button:hover { + background-color: #0018A4; + color: white; +} + +.random-container .active { + background: #0018A4; + color: white; +} + +.sort-container .active { + background-color: #FF6589; + color: white; +} + +.card-container { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); + grid-gap: 16px; +} + +.card { + background-color: white; + border: 1px solid #E9E9E9; + border-radius: 16px; + padding: 12px; + font-weight: 500; + font-style: medium; + font-size: 15px; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.heart-icon-container { + display: flex; + justify-content: flex-end; + margin-bottom: 10px; +} + +.far.fa-heart { + color: grey; +} + +.fas.fa-heart { + color: #FF6589; +} + +.card-container .far.fa-heart:hover { + color: #FF6589;; +} + +.card img { + width: 100%; + height: 200px; + object-fit: cover; + border-radius: 12px; +} + +.recipe-content { + flex: 1; +} + +.recipe-information { + font-size: 14px; + margin-bottom: 24px; +} + +.ingredients ul { + list-style: disc inside; + padding: 0; + column-count: 2; + column-gap: 1rem; +} + +.card-button { + max-width: 200px; + margin: 20px; + align-self: center; + outline: 2px solid #0018A4; + background-color: transparent; + color: #0018A4; +} + +.card:hover { + border: 2px solid #0018A4; + box-shadow: 0px 0px 30px 0px rgba(0, 24, 164, 0.2); +} + +.modal-overlay { + display: flex; + flex-direction: column; + position: fixed; /* stay in place */ + z-index: 1; /* sit on top */ + left: 0; + top: 0; + width: 100vw; /* full width */ + height: 100vh; /* full height */ + background-color: rgba(0,0,0,0.4); +} + +.modal-overlay .cross-icon { + width: 60px; + align-self: end; + margin-right: 24px; +} + +.modal { + display: flex; + flex-direction: column; + align-items: center; + position: fixed; /* stay in place */ + z-index: 1; /* sit on top */ + left: 10%; + right: 10%; + height: 80vh; + top: 0; + margin: 70px auto; + overflow: auto; /* enable scroll if needed */ + border-radius: 5px; + background-color: white; + padding: 20px; + max-width: 700px; + border-radius: 16px; +} + +.modal img { + width: 80%; + border-radius: 12px; + box-shadow: 0 0 2px grey; + align-self: center; + margin-bottom: 20px; +} + +.modal-content { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 20px; + padding: 12px 20px 20px 20px; +} + +.modal .recipe-content { + display: flex; + flex-direction: column; +} + +.modal .recipe-instruction{ + margin-bottom: 20px; +} + +.modal .recipe-information{ + margin-bottom: 0px; +} + +.modal ol li { + margin-bottom: 10px;; +} + +.modal hr.solid { + width: 100%; + border-top: 1px solid #888; +} + +.filter-error-message { + background-color: #ffffff; + box-shadow: 0 0 5px rgb(193, 193, 193); + padding: 20px; + justify-content: center; + align-items: center; +} + + + +/* for small screens */ +@media only screen and (max-width: 650px) { + +.title { + text-align: center; + font-size: 52px; +} + +.top-container{ + flex-direction: column; + justify-content: center; + max-width: 250px; + margin: 0 auto; + +} + +.controls-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0; +} + +.sort-random-container { + flex-direction: column; + align-items: center; + gap: 24px; +} + +.filter-container, +.sort-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 7px; +} + +.controls-container .btn { + margin-bottom: 0; +} + +.filter-buttons-container { + justify-content: center; +} + +.card-container { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 420px)); + grid-gap: 16px; + justify-content: space-evenly; +} + +.modal .ingredients ul { + column-count: 1; +} + +.modal .ingredients ul li { + margin-bottom: 10px; +} + +} + +/* for wide screens */ + @media only screen and (min-width: 1350px) { + + .controls-container{ + flex-direction: row; + gap: 82px; + } +} \ No newline at end of file