diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 00000000..054fcd17 Binary files /dev/null and b/.DS_Store differ diff --git a/README.md b/README.md index 58f1a8a6..384e1747 100644 --- a/README.md +++ b/README.md @@ -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. \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 00000000..d6e4a5dd --- /dev/null +++ b/index.html @@ -0,0 +1,137 @@ + + + + + + + + + + Project 2 - Recipe Library + + + +
+ +

Recipe Library

+
+
+ +
+ + +
+
+

Filter on Diet

+ + + + + +
+ + +
+

Sort by Time

+ + +
+
+ + +
+

In need of Inspo?

+ +
+ + +

+ + +
+
+

+

Hang on! We are loading some super yummy recipes just for you ✨

+
+
+ + +
+
+ +
+ + + + + + + + \ No newline at end of file diff --git a/js/.DS_Store b/js/.DS_Store new file mode 100644 index 00000000..5008ddfc Binary files /dev/null and b/js/.DS_Store differ diff --git a/js/script.js b/js/script.js new file mode 100644 index 00000000..bba75c0e --- /dev/null +++ b/js/script.js @@ -0,0 +1,176 @@ +// This is where I store recipes globally +let allRecipes = [] +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 = ` +

Uh oh! You've reached the daily limit of recipe requests. ✨

+

Here are some saved recipes for you, but come back tomorrow if you want to get more!

` + 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 = ` +

Uh oh! There is a problem when loading recipes. Come back tomorrow. ✨

+

${error.message}

` + } + }) + .finally(() => { + // Hide loading message when request finish so its not showing all the time + loadingMessage.style.display = 'none' + }) +} + + +//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 += ` +
+
+ Recipe image +
+

${recipe.title}

+
+
+

Diet:

+

${dietLabels.length > 0 ? dietLabels.join(", ") : "No specific diet"}

+
+
+

Time:

+

${recipe.readyInMinutes} min

+
+
+
+

Ingredient list:

+ +
+ + +
+ 🔗 View Full Recipe +
` + }) +} + +// 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 = `

❌ No ${filterType} recipes found. Why don't you pick another one?✨

` + } 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() \ No newline at end of file diff --git a/style/style.css b/style/style.css new file mode 100644 index 00000000..f210add7 --- /dev/null +++ b/style/style.css @@ -0,0 +1,287 @@ +* { + box-sizing: border-box; +} + +body { + font-family: 'Futura', 'Trebuchet MS', Arial, sans-serif; + margin: 0; +} + +header { + border-bottom: 1px solid #E9E9E9; +} + +/* max-width makes the content look good on larger screens */ +main { + max-width: 1400px; + margin: 0 auto; + padding: 16px 18px; +} + +/* Filter/sorting buttons */ +form { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 12px; + padding-bottom: 16px; +} + +form p { + margin-bottom: 8px; +} + +.form { + padding: 8px 6px; +} + +/* hide the "no recipe found" message */ +#displayMessage { + display: none; +} + +/* hide the instructions in the card from the start*/ +.instructionList { + display: none; + padding: 5px; +} + +/* pop up loading message */ +#loadingMessage { + display: none; + position: fixed; + width: 250px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(255, 255, 255, 0.9); + padding: 20px; + border-radius: 10px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); +} + +#loadingText { + display: flex; + align-items: center; + justify-content: center; + gap: 20px; +} + +/* animation on loading message emoji*/ +.animation { + width: 30px; + height: 30px; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +/* hide the input thingy inside my label */ +label input { + display: none; +} + +/* button/radio input styling */ +.button { + display: inline-block; + padding: 8px 16px; + border-radius: 50px; + box-shadow: 0 2px 6px 1px rgba(0, 0, 0, 0.25); + margin: 8px; + cursor: pointer; + border: 2px solid; + transition: background-color 0.3s, color 0.3s, border 0.3s; +} + +.btn-filter { + background-color: #CCFFE2; + color: #0018A4; + border: 2px solid #CCFFE2; +} + +.btn-filter:has(:checked) { + background-color: #0018A4; + color: #FFFFFF; + border: 2px solid #0018A4; +} + +.btn-filter:hover { + border: 2px solid #0018A4; +} + +.btn-sort { + background-color: #FFECEA; + color: #0018A4; + border: 2px solid #FFECEA; +} + +.btn-sort:hover { + background-color: #FF6589; + color: #FFFFFF; + border: 2px solid #0018A4; +} + +.btn-sort:has(:checked) { + background-color: #FF6589; + color: #FFFFFF; + border: 2px solid #FF6589; +} + +.btn-random { + background-color: #FF6589; + color: #FFFFFF; + border: 2px solid #FF6589; + margin: 16px 6px; +} + +.btn-random:hover { + background-color: #FFECEA; + color: #FF6589; + border: 2px solid #FF6589; +} + +/* animation on random button */ + +@keyframes pulse { + 0% { + transform: scale(1); + } + + 50% { + transform: scale(1.1); + } + + 100% { + transform: scale(1); + } +} + +.animationBtn { + animation: pulse 1.2s infinite ease-in-out; +} + +.instructionsBtn { + padding: 8px 16px; +} + +/* Cards styling */ +.card-parent { + display: grid; + justify-content: space-evenly; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(260px, 290px)); + padding: 24px 0px; +} + +.card { + border: 2px solid #E9E9E9; + border-radius: 16px; + padding: 16px; +} + +.card:hover { + box-shadow: 4px 1px 10px 1px rgba(0, 0, 0, 0.25); + border: 2px solid #0018A4; +} + +.card-image { + width: 100%; + height: 200px; + overflow: hidden; + border-radius: 12px; +} + +.card-image img { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* sections inside the cards */ +.instructions { + display: flex; + align-items: center; + padding: 5px 5px; +} + +.ingredients { + display: flex; + flex-direction: column; +} + +/* Headings/text styling */ +h1 { + text-align: center; + color: #0018A4; + font-size: 42px; +} + +a { + color: #0018A4; + margin: 10px 5px; + text-decoration: none; +} + +.card a:hover { + border-bottom: 2px solid #0018A4; +} + +h2 { + padding: 16px 0; +} + +.title { + font-size: 18px; + font-weight: bold; + margin-right: 8px; + padding: 4px 0; +} + +p, +button { + font-size: 16px; +} + +h2, +h3, +h4, +p { + margin: 0; +} + +hr { + border: 1px solid #E9E9E9; +} + +ul { + list-style: none; + padding-left: 0; +} + +footer { + border-top: 1px solid #E9E9E9; + padding: 32px; +} + +.icons { + display: flex; + justify-content: center; + gap: 10px; +} + +i { + color: #000000; + font-size: 25px; +} + +i:hover { + opacity: 0.6; +} \ No newline at end of file