diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..155422b0 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "liveServer.settings.port": 5503 +} \ No newline at end of file diff --git a/README.md b/README.md index 58f1a8a6..070a42ee 100644 --- a/README.md +++ b/README.md @@ -1 +1,8 @@ # js-project-recipe-library + +To do: +[] Put filter and sort in a form +[]Show a message if the API quota is reached. +[] Use local storage caching to reduce API requests. +[] Show a loading state while fetching data. +[] Fix the css \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 00000000..3ddb6adc --- /dev/null +++ b/index.html @@ -0,0 +1,61 @@ + + + + + + Recipe Library + + + + + + + +
+

Recipe Library

+
+ +
+
+
+
+

Filter on diet

+ +
+ +
+

Sort on ingredients

+
+ + +
+
+ +
+

Sort on time

+
+ + +
+
+ +
+

What should I eat today?

+ +
+
+
+ +
+
+
+ + + + + + \ No newline at end of file diff --git a/script.js b/script.js new file mode 100644 index 00000000..5ca0fe43 --- /dev/null +++ b/script.js @@ -0,0 +1,228 @@ +const recipesContainer = document.getElementById('recipes-container') +const recipeForm = document.getElementById('recipe-filter-form') +const dietFilter = document.getElementById('diet-filter') +const btnAscendingIngredients = document.getElementById('btn-ascending-ingredients') +const btnDescendingIngredients = document.getElementById('btn-descending-ingredients') +const btnDescending = document.getElementById('btn-descending') +const btnAscending = document.getElementById('btn-ascending') +const btnRandom = document.getElementById('btn-random') +const CACHE_KEY = 'spoonacular_recipes_cache' +const CACHE_EXPIRY = 24 * 60 * 60 * 1000 + +const baseUrl = "https://api.spoonacular.com/recipes/random" +const apiKey = "110e75fc870c4091a4fd4bf706e6efc8" +const numRecipes = 50 +const url = `${baseUrl}?apiKey=${apiKey}&number=${numRecipes}` + +let allRecipes = [] +let currentRecipes = [] +let lastSortAction = [] + +const displayError = (message) => { + recipesContainer.innerHTML = `

${message}

` +} + +const displayLoading = () => { + recipesContainer.innerHTML = `

Loading recipes... Please wait.

` +} + +const createRecipeCard = (recipe) => { + const ingredientListItems = recipe.extendedIngredients + .map(ingredient => `
  • ${ingredient.original}
  • `) + .join('') + return ` +
    + ${recipe.title} +

    ${recipe.title}

    +
    +

    Time: ${recipe.readyInMinutes} minutes

    +
    +
    +
    + +
      ${ingredientListItems}
    +
    + +
    +
    ` +} + +const displayRecipes = (recipes) => { + recipesContainer.innerHTML = recipes.map(createRecipeCard).join('') +} + +const toggleIngredients = (ingredientsId) => { + const ingredientsList = document.getElementById(ingredientsId) + if (ingredientsList.style.display === 'none' || ingredientsList.style.display === '') { + ingredientsList.style.display = 'block' + } else { + ingredientsList.style.display = 'none' + } +} + +const showInstructions = (recipeId) => { + const recipe = allRecipes.find(r => r.id === recipeId) + + if (!recipe) { + displayError('Instructions not found. Please try again.') + return + } + const recipeCard = document.getElementById(recipeId) + const backButton = `` + + if (recipe.instructions) { + recipeCard.innerHTML = `

    Instructions:

    ${recipe.instructions}

    ${backButton}` + } else { + recipeCard.innerHTML = `

    Instructions not available.

    ${backButton}` + } +} + +const filterRecipesByDiet = (diet) => { + if (!diet) { + return allRecipes + } + return allRecipes.filter(recipe => recipe.hasOwnProperty(diet) && recipe[diet] === true) +} + +const sortRecipes = (recipes, key, order = 'asc') => { + if (!recipes || recipes.length === 0) { + return [] + } + const sortedRecipes = [...recipes] + + sortedRecipes.sort((a, b) => { + const valueA = key === 'extendedIngredients' ? a[key].length : a[key] + const valueB = key === 'extendedIngredients' ? b[key].length : b[key] + + if (order === 'asc') { + return valueA - valueB + } else { + return valueB - valueA + } + }) + return sortedRecipes +} + +const showRandomRecipe = () => { + if(allRecipes.length === 0){ + displayError('No recipes found. Please try again.') + return + } + const randomIndex = Math.floor(Math.random() * allRecipes.length) + const randomRecipe = allRecipes[randomIndex] + currentRecipes = [randomRecipe] + displayRecipes(currentRecipes) +} + +const applyFilterAndSort = () => { + const diet = dietFilter.value + + if (!diet) { + currentRecipes = [...allRecipes] + } + + let filteredRecipes = filterRecipesByDiet(diet) + + if (filteredRecipes.length === 0) { + displayError('No recipes found matching the selected diet. Please try again.') + currentRecipes = [] + return + } + if (lastSortAction === 'ingredients-asc') { + filteredRecipes = sortRecipes(filteredRecipes, 'extendedIngredients', 'asc') + } else if (lastSortAction === 'ingredients-desc') { + filteredRecipes = sortRecipes(filteredRecipes, 'extendedIngredients', 'desc') + } else if (lastSortAction === 'time-asc') { + filteredRecipes = sortRecipes(filteredRecipes, 'readyInMinutes', 'asc') + } else if (lastSortAction === 'time-desc') { + filteredRecipes = sortRecipes(filteredRecipes, 'readyInMinutes', 'desc') + } + currentRecipes = filteredRecipes + displayRecipes(currentRecipes) +} + +const saveToCache = (data) => { + const cacheData = { + timestamp: new Date().getTime(), + recipes: data.recipes + } + localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData)) +} + +const getFromCache = () => { + const cachedData = localStorage.getItem(CACHE_KEY) + if (!cachedData) return null + + const parsedData = JSON.parse(cachedData) + const now = new Date().getTime() + + if (now - parsedData.timestamp > CACHE_EXPIRY) { + localStorage.removeItem(CACHE_KEY) + return null + } + + return parsedData +} + + +dietFilter.addEventListener('change', applyFilterAndSort) + +btnAscendingIngredients.addEventListener('click', () => { + lastSortAction = 'ingredients-asc' + applyFilterAndSort() +}) + +btnDescendingIngredients.addEventListener('click', () => { + lastSortAction = 'ingredients-desc' + applyFilterAndSort() +}) + +btnAscending.addEventListener('click', () => { + lastSortAction = 'time-asc' + applyFilterAndSort() +}) + +btnDescending.addEventListener('click', () => { + lastSortAction = 'time-desc' + applyFilterAndSort() +}) + +btnRandom.addEventListener('click', showRandomRecipe) + +const fetchRecipes = () => { + + displayLoading () + + const cachedData = getFromCache() + if (cachedData) { + allRecipes = cachedData.recipes + currentRecipes = cachedData.recipes + displayRecipes(currentRecipes) + return + } + + fetch (url) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! Status: ${error.message}`) + } + return response.json() + }) + .then ((data) => { + allRecipes = data.recipes + currentRecipes = data.recipes + displayRecipes(currentRecipes) + + saveToCache(data) + + }) + .catch(error => { + if (error.message.includes('quota')) { + displayError(`${error.message} You've reached the limit for new recipes today.`) + } else { + displayError(`Error fetching data: ${error.message}`) + } + }) +} + +fetchRecipes() \ No newline at end of file diff --git a/style.css b/style.css new file mode 100644 index 00000000..f6904029 --- /dev/null +++ b/style.css @@ -0,0 +1,178 @@ +*, +*::before, +*::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: "Montserrat", sans-serif; + background-color: #fafbff; + margin: 10px; +} + +header { + margin: 30px 0; +} + +h1 { + color: #0018A4; + font-size: 64px; + font-weight: 700; + margin-left: 10px; +} + +h2 { + font-size: 18px; + font-weight: 700; + margin: 20px 0; +} + +p { + font-size: 16px; +} + + +.recipe-filter-form { + display: flex; + flex-wrap: wrap; + gap: 20px; + margin-bottom: 30px; + margin-left: 10px; +} + +.recipes-container { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); + gap: 16px; +} + +.recipe-card { + display: flex; + flex-direction: column; + background-color: white; + border: 1px solid grey; + border-radius: 10px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); + padding: 16px; + transition: ease-in-out 0.3s; +} + +.recipe-card:hover { + transform: translateY(-5px); +} + +.recipe-img { + width: 100%; + height: 200px; + object-fit: cover; +} + +.ingredient-btn, .instructions-btn{ + width: 100%; + background-color: #FFD6A5; +} +.card-buttons{ + display: flex; + flex-direction: column; + gap: 10px; + margin-top: auto; +} + +button { + padding: 8px 16px; + border: 2px solid transparent; + border-radius: 50px; + font-weight: 500; + font-size: 16px; + color: #0018A4; + cursor: pointer; + transition: all 0.3s ease; +} + +button:hover { + border: 2px solid #0018A4; + } + +.sort-btn { + background-color: #FFECEA; + } + +.random-btn { + background-color: #CCFFE2; +} + +hr { + margin-bottom: 10px; +} + +.cuisine-info { + margin-top: 5px; + margin-bottom: 5px; +} + +.time-info { + margin-bottom: 5px; +} + +.bold { + font-weight: 700; +} + +.ingredients{ + font-weight: 700; + margin-top: 5px; + padding: 10px; +} + +ul { + list-style: none; + padding: 10px; + margin-bottom: 5px; + margin-top: 5px; +} + +li { + line-height: 25px; +} + +select { + padding: 8px 16px; + border: none; + border-radius: 50px; + border: 2px solid transparent; + font-weight: 500; + font-size: 16px; + color: #0018A4; + background-color: rgb(209, 243, 255); + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + } + + select:focus { + outline: none; + } + + select:hover { + outline: none; + border: 2px solid #0018A4; + } + + .hidden-ingredients { + display: none; +} + +.back-button { +margin-top: auto; +background-color: #0018A4; +color: white; +border: none; +} + +ol { + padding: 10px; + margin-bottom: 5px; + margin-top: 5px; +} \ No newline at end of file