diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 000000000..9ef2be26a Binary files /dev/null and b/.DS_Store differ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..6f3a2913e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "liveServer.settings.port": 5501 +} \ No newline at end of file diff --git a/README.md b/README.md index 58f1a8a66..9494db63e 100644 --- a/README.md +++ b/README.md @@ -1 +1,4 @@ # js-project-recipe-library + +Link to letlify: +https://js-project-recipe-library-site.netlify.app/ \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 000000000..eeade79fe --- /dev/null +++ b/index.html @@ -0,0 +1,83 @@ + + + + + + + Recipe Library + + +
+

Recipe Library

+
❤️ My favorites
+
+ +
+ + +
+ +
+ +
+
+
+

Filter on kitchen

+
+
+
+
    +
  • +
  • +
  • +
  • +
  • +
+
+
+ +
+
+

Sort on time

+
+
+
    +
  • +
  • +
+
+
+ +
+
+

Try Random Recipe

+
+
+
    +
  • + +
  • +
+
+
+ +
+ +
+
+ Loading... +
+

Oops... 😣 No recipes found

+
+ + + \ No newline at end of file diff --git a/script.js b/script.js new file mode 100644 index 000000000..0c0d8cabe --- /dev/null +++ b/script.js @@ -0,0 +1,329 @@ +const URL = "https://api.spoonacular.com/recipes/complexSearch?number=20&apiKey=e1711b2ca9f84dec882725da3bd3acfd&cuisine=Thai,Mexican,Mediterranean,Indian&addRecipeInformation=true&addRecipeInstructions=true&fillIngredients=true" +// const URL = "https://api.spoonacular.com/recipes/complexSearch?number=20&apiKey=5b0eb7cae7ef4a20af3202de42e39e78&cuisine=Thai,Mexican,Mediterranean,Indian&addRecipeInformation=true&addRecipeInstructions=true&fillIngredients=true" + + +const allBtn = document.getElementById("all") +const filterBtn = document.querySelectorAll(".filter-btn") +const sortBtn = document.querySelectorAll(".sort-btn") +const descBtn = document.getElementById("desc") +const ascBtn = document.getElementById("asc") +const randomBtn = document.querySelector(".random-btn") +const container = document.getElementById("recipe-container") + +allBtn.classList.add("active") //default select + +//=============================== +// like button - add recipes to favorites +//=============================== + +// save favorite recipes in localStorage +let favorites = JSON.parse(localStorage.getItem("favorites")) || []; + +const attachLikeEvents =() => { + const likeButtons = document.querySelectorAll(".like-button"); + + likeButtons.forEach(button => { + button.addEventListener("click", () => { + const card = button.closest(".recipe-card"); // find the closest recipe card + const recipeId = String(card.dataset.id); // get the recipe ID from card's data-id and turn it into string + const recipe = results.find(r => String(r.id) === recipeId); // find the recipe object from results array that has the same ID + + if (!recipe) return; // if recipe not found, exit + + // check if the recipe is already in favorites + if (favorites.some(fav => String(fav.id) === recipeId)) { + // remove recipe if it is already liked + favorites = favorites.filter(fav => String(fav.id) !== recipeId); + button.classList.remove("liked"); + } else { + // otherwise add recipe to my favorites + favorites.push(recipe); + button.classList.add("liked"); + } + + // save recipes in localStorage + localStorage.setItem("favorites", JSON.stringify(favorites)); + }); + }); +}; + +//=============================== +// show favorite recipes +//=============================== + +const favoriteBtn = document.querySelector(".favorites") + +favoriteBtn.addEventListener("click", () => { + favorites = JSON.parse(localStorage.getItem("favorites")) || []; + + if (favorites.length > 0) { + displayRecipes(favorites); + } else { + container.innerHTML = `

No favorite recipes yet ❤️

`; + } +}); + + + +//=============================== +// display recipes +//=============================== + +const displayRecipes = (recipeArray) => { + container.innerHTML = "" //reset the container + + recipeArray.forEach(recipe => { + // ingredients + let ingredients = []; + if (recipe.extendedIngredients && Array.isArray(recipe.extendedIngredients)) { + ingredients = recipe.extendedIngredients.map(ing => ing.original); + } + + container.innerHTML += ` +
+
+ + picture of ${recipe.title} +
+

${recipe.title}

+
+

Cuisine: ${recipe.cuisines && recipe.cuisines.length ? recipe.cuisines.join(", ") : "-"}

+

Time: ${recipe.readyInMinutes} minutes

+
+

Ingredients

+ + +
+ `; + }); + + attachLikeEvents(); +} + +//=============================== +// fetch recipes +//=============================== + +// global variable to store all recipes +let results = []; + +const fetchData = async () => { + try { + // fetch the data + const response = await fetch(URL); + + // check the status + if (!response.ok) { + // show 402/403/429 when it hits the limit + if (response.status === 402 || response.status === 403 || response.status === 429) { + showApiLimitMessage(); + return; + } + + // other errors treated as normal errors + throw new Error(`HTTP error! Status: ${response.status}`); + } + + // convert to JSON + const data = await response.json(); + console.log("API response:", data); + + // check the data is entered correctly + if (data.results && Array.isArray(data.results)) { + results = data.results; + displayRecipes(data.results); + searchInput.addEventListener("input", filterSearchResults); // enable search after data is loaded + } else { + container.innerHTML = `

No recipes found 🥲 Check your API key or quota.

`; + } + + } catch (error) { + console.error("Error fetching data:", error); + showErrorMessage(); + } +}; + +// API error message +const showApiLimitMessage = () => { + container.innerHTML = ` +

+ ⚠️ API limit reached for today ⚠️
+ Please try again tomorrow. +

+ `; +}; + +// normal error message +const showErrorMessage = () => { + container.innerHTML = ` +

+ Something went wrong while fetching recipes 🥲
+ Please try again later. +

+ `; +}; + + +fetchData(); + + +//=============================== +// filter + sort recipes +//=============================== + +// save the selected filters here +let activeFilters = []; +let currentSort = null; // asc or desc + + +filterBtn.forEach((button) => { + button.addEventListener("click", () => { + const cuisine = button.id.toLowerCase(); + + // when all button is clicked + if (cuisine === "all") { + filterBtn.forEach(btn => btn.classList.remove("active")); + allBtn.classList.add("active"); + + activeFilters = []; // reset filters + + applyKitchenFilters(); // show all recipes + return; + } + + // other filter buttons + if (button.classList.contains("active")) { + button.classList.remove("active"); + activeFilters = activeFilters.filter(item => item !== cuisine); + } else { + button.classList.add("active"); + activeFilters.push(cuisine); + } + + const anyActive = Array.from(filterBtn).some( + btn => btn.id !== "all" && btn.classList.contains("active") + ); + + // remove active from all if there are other active filter buttons + if (anyActive) { + allBtn.classList.remove("active"); + } else { + // all becomes active when no other active filters + allBtn.classList.add("active"); + activeFilters = []; //reset filters + } + + applyKitchenFilters(); + }); +}); + +// sort buttons +descBtn.addEventListener("click", () => { + currentSort = "desc"; + applyKitchenFilters(); +}); + +ascBtn.addEventListener("click", () => { + currentSort = "asc"; + applyKitchenFilters(); +}) + +// only one button can be selected +sortBtn.forEach(sortButton => { + sortButton.addEventListener("click", () => { + sortBtn.forEach(btn => btn.classList.remove("active")); + sortButton.classList.add("active"); + }); +}); + +// main function : filter + sort +const applyKitchenFilters = () => { + let filtered = [...results]; // make a copy of all recipes + + //apply filters + if (activeFilters.length > 0) { + filtered = filtered.filter(recipe => { + if (Array.isArray(recipe.cuisines) && recipe.cuisines.length > 0) { + return recipe.cuisines.some(c => activeFilters.includes(c.toLowerCase())); + } + return false; + }); + } + + // apply sorting + if (currentSort === "desc") { + filtered.sort((a,b) => b.readyInMinutes - a.readyInMinutes); + } else if (currentSort === "asc") { + filtered.sort((a,b) => a.readyInMinutes - b.readyInMinutes); + } + + // show empty message + if (filtered.length > 0) { + displayRecipes(filtered); + } else { + container.innerHTML = `

No recipes found

`; + } +}; + +//=============================== +// pick a random recipe +//=============================== + +// change the color of the button when clicked +randomBtn.addEventListener("click", () => { + randomBtn.classList.toggle("active"); +}) + +randomBtn.addEventListener("click", () => { + const randomIndex = Math.floor(Math.random() * results.length); + const randomRecipe = results[randomIndex]; + displayRecipes([randomRecipe]) +}) + + +//=============================== +// search recipes +//=============================== + +const searchInput = document.getElementById("text-input"); +const noResultsMessage = document.getElementById("no-results"); + +// show and hide search results +const showSearchResult = (target) => target.style.display = ""; +const hideSearchResult = (target) => target.style.display = "none"; + +// search recipes by keyword +const filterSearchResults = () => { + const keyword = searchInput.value.trim().toLowerCase(); + const searchTargets = document.querySelectorAll('.recipe-card'); + let matchFound = false; + + searchTargets.forEach((target) => { + const text = target.textContent.toLowerCase(); + // check if the keyword is in the text content and show/hide the recipe card + if (text.includes(keyword)) { + showSearchResult(target); + matchFound = true; + } else { + hideSearchResult(target); + } + }); + + // show no results message if no hits + if (!matchFound && keyword.length > 0) { + noResultsMessage.style.display = "block"; + } else { + noResultsMessage.style.display = "none"; + } +}; + +// add event listener to the search input +searchInput.addEventListener("input", filterSearchResults); + diff --git a/style.css b/style.css new file mode 100644 index 000000000..f6ae248e5 --- /dev/null +++ b/style.css @@ -0,0 +1,325 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: "Futura", sans-serif; + background-color: #fafbff; +} + +h1 { + color: #0018a4; + height: 85px; + top: 64px; + left: 64px; + font-size: 3rem; + margin: 50px 10px; +} + +h3 { + color: #000000; + font-size: 22px; + margin: 10px 0 10px 16px; +} + +h4 { + color: #000000; +} + +p { + color: #000000; +} + +img { + width: 100%; + height: 200px; + border-radius: 12px; + object-fit: cover; +} + +ul { + padding: 0; +} + +li { + list-style: none; +} + +a { + text-decoration: none; + color: initial; +} + +/* ====================================== +favorite recipes button +======================================= */ + +.favorites { + color: #555; + text-align: center; + margin-left: 10px; + margin-bottom: 50px; + background-color: lightpink; + width: 150px; + padding: 10px; + border-radius: 10px; + cursor: pointer; +} + +.favorites:hover { + border: 2px solid #0018a4; + box-shadow: 0 0 8px 2px rgba(0, 0, 0, 0.1); +} + +/* ====================================== +search form +======================================= */ + +.search-form { + display: flex; + justify-content: space-between; + align-items: center; + width: 50%; + margin-left: 10px; + margin-bottom: 20px; + overflow: hidden; + border: 1px solid #777777; + border-radius: 50px; +} + +.search-form:hover { + border: 2px solid #777777; +} + +#text-input { + width: 100%; + height: 45px; + padding: 5px 15px; + border: none; + box-sizing: border-box; + font-size: 1em; + outline: none; +} + +#text-input::placeholder{ + font-size: 1em; + color: #777777; +} + +.search-button { + background-color: skyblue; + display: flex; + justify-content: center; + align-items: center; + width: 50px; + height: 45px; + border: none; + cursor: pointer; +} + +.search-emoji { + font-size: 1.5rem; +} + +/* ====================================== +style for the buttons +======================================= */ + +.button-container { + display: flex; + flex-direction: row; + margin-bottom: 20px; + gap: 30px; +} + +.filter-container, +.sort-container { + display: flex; + flex-direction: column; +} + +.filter, +.sort, +.random { + display: flex; + flex-wrap: wrap; + flex-direction: row; +} + +.filter-btn { + text-align: center; + background-color: #ccffe2; + color: #0018a4; + font-size: 18px; + border: none; + width: auto; + height: 40px; + border-radius: 50px; + padding: 8px 16px; + margin: 5px; + cursor: pointer; +} + +.sort-btn { + text-align: center; + background-color: #ffecea; + color: #0018a4; + font-size: 18px; + border: none; + width: auto; + height: 40px; + border-radius: 50px; + padding: 8px 16px; + margin: 5px; + cursor: pointer; +} + +.random-btn { + text-align: center; + background-color: beige; + color: #0018a4; + font-size: 18px; + border: none; + width: auto; + height: 40px; + border-radius: 50px; + padding: 8px 16px; + margin: 5px; + cursor: pointer; +} + +.filter-btn:hover, +.sort-btn:hover, +.random-btn:hover { + border: 2px solid #0018a4; + box-shadow: 0 0 8px 2px rgba(0, 0, 0, 0.1); +} + +.filter-btn.active { + background-color: #0018a4; + color: #fff; +} + +.sort-btn.active { + background-color: #ff6589; + color: #fff; +} + +.random-btn.active { + background-color: orange; + color: #fff; +} + +/* ====================================== +style for recipe cards +======================================= */ + +#recipe-container { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 10px; + margin-bottom: 50px; +} + +.placeholder p { + opacity: 1; + margin: 8px 0; +} + +.recipe-image { + position: relative; +} + +.like-button { + position: absolute; + top: 10px; + right: 10px; + width: 40px; + height: 40px; + font-size: 24px; + background-color: white; + border-radius: 50%; + border: none; + margin: 0 0 0 auto;cursor: pointer; +} + +.like-button.liked { + color: red; +} + +.line { + width: auto; + height: 1px; + margin: 20px 0; +} + +.view-recipe-button { + background-color: #ff6589; + font-size: large; + border: none; + border-radius: 10px; + width: 100%; + padding: 10px; + cursor: pointer; + text-decoration: none; + margin-top: auto; + text-align: center; + align-items: flex-end; +} + +.view-recipe-button a { + color: white; +} + +.recipe-card { + display: flex; + flex-direction: column; + max-width: 300px; + height: 100%; + border: 1px solid #e9e9e9; + border-radius: 16px; + margin: 10px; + padding: 16px 16px 24px 16px; + background-color: #fff; +} + +/* to place view recipe button in the bottom of the card */ +.recipe-card > *:not(.recipe-image):not(.view-recipe-button) { + margin-top: 12px; +} + +.recipe-card:hover { + box-shadow: 0 0 10px 2px rgba(0, 0, 0, 0.1); +} + +.no-results { + text-align: center; + margin-top: 100px; + font-size: 2rem; + color: #555; +} + +#no-results { + text-align: center; + margin-top: 100px; + font-size: 2rem; + color: #555; + display: none; +} + +/* ====================================== +responsive design +======================================= */ + +@media (max-width: 1023px) { + .button-container { + display: flex; + flex-direction: column ; + } + + .search-form { + width: 90%; + } +}