diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..cbb7b3af2 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "liveServer.settings.port": 5502 +} \ No newline at end of file diff --git a/README.md b/README.md index 58f1a8a66..446329f50 100644 --- a/README.md +++ b/README.md @@ -1 +1,7 @@ # js-project-recipe-library + +A responsive recipe library built with HTML, CSS, and JavaScript. + +## 🌐 Live Demo +Check out the deployed version here: +👉 [Recipe Library on Netlify](https://recipelibrary-app.netlify.app) diff --git a/images/pizza.jpg b/images/pizza.jpg new file mode 100644 index 000000000..0a05c1819 Binary files /dev/null and b/images/pizza.jpg differ diff --git a/index.html b/index.html new file mode 100644 index 000000000..49deae7d9 --- /dev/null +++ b/index.html @@ -0,0 +1,165 @@ + + + + + + + Recipe Library + + + + + + +
+

Recipe Library

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

Filter on kitchen

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

Sort on time

+
+ + +
+
+ + +
+

Need inspiration?

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

Loading...

+
+ +
+ +
+
+ + + + + \ No newline at end of file diff --git a/script.js b/script.js new file mode 100644 index 000000000..c3b5da214 --- /dev/null +++ b/script.js @@ -0,0 +1,344 @@ +// ---------- GLOBAL ELEMENT REFERENCES ---------- +const favBtn = document.getElementById("favBtn") +const searchInput = document.getElementById("searchInput") +const searchBtn = document.getElementById("searchBtn") +const clearSearchBtn = document.getElementById("clearSearchBtn") +const filterButtons = document.querySelectorAll(".btn-filter") +const sortButtons = document.querySelectorAll(".btn-sort") +const randomButton = document.getElementById("randomBtn") +const recipesContainer = document.getElementById("recipeContainer") +const loadingIndicator = document.getElementById("loading") + +const API_KEY = "dd6e45be84ea4b5ca75f926ee451806c" +const URL = `https://api.spoonacular.com/recipes/complexSearch?number=25&apiKey=${API_KEY}&cuisine=Thai,Mexican,Mediterranean,Indian&addRecipeInformation=true&addRecipeInstructions=true&fillIngredients=true` + + +// ---------- GLOBAL STATE ---------- +let selectedFilters = [] +let selectedSort = null +let showFavoritesOnly = false +let allRecipes = [] + + +// ---------- UTILITY FUNCTIONS ---------- +const resetFilters = () => { + selectedFilters = [] + selectedSort = null + showFavoritesOnly = false + favBtn.classList.remove("active") + filterButtons.forEach(btn => btn.classList.remove("selected")) + sortButtons.forEach(btn => btn.classList.remove("selected")) + randomButton.classList.remove("selected") +} + + +// Helper to extract and clean ingredients +const extractIngredients = (recipe) => { + const fromExtended = recipe.extendedIngredients?.map(i => i.original.toLowerCase().trim()) || [] + const fromInstructions = recipe.analyzedInstructions?.flatMap(instr => + instr.steps?.flatMap(step => + step.ingredients?.map(i => i.name.toLowerCase().trim()) + ) + ) || [] + return [...new Set([...fromExtended, ...fromInstructions])] +} + + +// ---------- DATA FETCHING ---------- +// Fetch data from API or localStorage +const fetchData = async () => { + const lastFetch = localStorage.getItem("lastFetch") + const now = Date.now() + + // Use cached recipes if last fetch was under 24 hours ago + if (lastFetch && (now - lastFetch < 24 * 60 * 60 * 1000)) { + const cachedRecipes = localStorage.getItem("allRecipes") + if (cachedRecipes) { + try { + allRecipes = JSON.parse(cachedRecipes) + // Update the UI with cached recipes + updateRecipes() + loadingIndicator.style.display = "none" + return + } catch (e) { + // If cached data is corrupted → remove it to force fresh fetch + localStorage.removeItem("allRecipes") + localStorage.removeItem("lastFetch") + } + } + } + // Show loading spinner + loadingIndicator.style.display = "block" + try { + const response = await fetch(URL) + + //Check if API quota is reached + if (response.status === 402) { + recipesContainer.innerHTML = "

🚫 API daily quota reached. We've hit our daily request limit for recipe data. Please try again tomorrow or reload later

" + return + } + if (!response.ok) throw new Error(`HTTP ${response.status}`) + + const data = await response.json() + + allRecipes = data.results.map(recipe => ({ + id: recipe.id, + title: recipe.title, + cuisine: ((recipe.cuisines?.[0] || "Unknown").charAt(0).toUpperCase() + + (recipe.cuisines?.[0] || "Unknown").slice(1).toLowerCase()), + readyInMinutes: recipe.readyInMinutes, + image: recipe.image, + sourceUrl: recipe.sourceUrl, + ingredients: extractIngredients(recipe), // Extract ingredients via helper function + isFavorite: false + })) + // Save recipes to localStorage + localStorage.setItem("allRecipes", JSON.stringify(allRecipes)) + localStorage.setItem("lastFetch", now.toString()) + updateRecipes() + + } catch (err) { + // Error handling: use cached data if API fetch fails + const cachedRecipes = localStorage.getItem("allRecipes") + if (cachedRecipes) { + try { + allRecipes = JSON.parse(cachedRecipes) + updateRecipes() + } catch (e) { + // If cached data is corrupted → clear it and show error message + recipesContainer.innerHTML = "Could not load recipes 😢" + localStorage.removeItem("allRecipes") + localStorage.removeItem("lastFetch") + } + } else { + // No cached data available → show error message + recipesContainer.innerHTML = "Could not load recipes 😢" + } + } finally { + loadingIndicator.style.display = "none" + } +} + + +// ---------- CORE RENDERING FUNCTIONS ---------- +const showRecipes = (recipesArray) => { + // Add animation: fade out and scale down before updating content + recipesContainer.style.opacity = "0" + recipesContainer.style.transform = "scale(0.95)" + + setTimeout(() => { + recipesContainer.innerHTML = "" // Clear existing recipes + + if (recipesArray.length === 0) { + recipesContainer.innerHTML = `

😢 No recipes match your filter, try choosing another one

` + } else { + recipesArray.forEach(recipe => { + recipesContainer.innerHTML += ` +
+ ${recipe.title} +

${recipe.title}

+
+

Cuisine: ${recipe.cuisine}
+ Time: ${recipe.readyInMinutes} minutes

+
+

Ingredients
+ ${recipe.ingredients.join("
")}

+ +
+ ` + }) + } + + // Fade in again after new content is added + recipesContainer.style.opacity = "1" + recipesContainer.style.transform = "scale(1)" + }, 150) +} + + +const updateRecipes = () => { + let filteredRecipes = [...allRecipes] + // Apply cuisine filters if selected + if (selectedFilters.length > 0) { + filteredRecipes = filteredRecipes.filter(recipe => + selectedFilters.includes(recipe.cuisine.toLowerCase()) + ) + } + + if (showFavoritesOnly === true) { + filteredRecipes = filteredRecipes.filter(recipe => recipe.isFavorite === true) + } + + filteredRecipes = sortRecipes(filteredRecipes) + showRecipes(filteredRecipes) +} + + +const sortRecipes = (recipesArray) => { + const sorted = [...recipesArray] + if (selectedSort === "ascending") { + return sorted.sort((a, b) => a.readyInMinutes - b.readyInMinutes) + } + if (selectedSort === "descending") { + return sorted.sort((a, b) => b.readyInMinutes - a.readyInMinutes) + } + return sorted +} + + +// ---------- EVENT LISTENERS ---------- +favBtn.addEventListener("click", () => { + if (showFavoritesOnly === false) { + showFavoritesOnly = true + favBtn.classList.add("active") + } else { + showFavoritesOnly = false + favBtn.classList.remove("active") + } + updateRecipes() +}) + + +searchBtn.addEventListener("click", () => { + searchInput.classList.toggle("active") + searchInput.value = "" + clearSearchBtn.classList.remove("active") + searchInput.focus() +}) + + +clearSearchBtn.addEventListener("click", () => { + searchInput.value = "" + clearSearchBtn.classList.remove("active") + if (window.innerWidth < 668) { + searchInput.classList.remove("active") + } + updateRecipes() +}) + + +searchInput.addEventListener("input", () => { + const query = searchInput.value.toLowerCase().trim() + + if (query !== "") { + clearSearchBtn.classList.add("active") + } else { + clearSearchBtn.classList.remove("active") + if (window.innerWidth < 668) { + searchInput.classList.remove("active") + } + updateRecipes() + return + } + const searchedRecipes = allRecipes.filter(recipe => + recipe.title.toLowerCase().includes(query) || + recipe.ingredients.join(", ").toLowerCase().includes(query) + ) + showRecipes(searchedRecipes); +}) + + +filterButtons.forEach(button => { + button.addEventListener("click", () => { + const value = button.dataset.value + randomButton.classList.remove("selected") + if (value === "all") { + resetFilters() + button.classList.add("selected") + updateRecipes() + return + + } else { + //Remove "All" if any other filter is chosen + const allButton = document.querySelector('.btn-filter[data-value="all"]') + allButton.classList.remove("selected") + + button.classList.toggle("selected") + + // Maintain order of clicks + if (button.classList.contains("selected")) { + + // Add filter if it's not already chosen + if (selectedFilters.includes(value) === false) { + selectedFilters.push(value); + } + + } else { + // Remove filter when unclicked + selectedFilters = selectedFilters.filter(f => f !== value); + } + } + updateRecipes() + }) +}) + + +sortButtons.forEach(button => { + button.addEventListener("click", () => { + randomButton.classList.remove("selected") + + if (selectedSort === button.dataset.value) { + selectedSort = null + button.classList.remove("selected") + + } else { + selectedSort = button.dataset.value + sortButtons.forEach(btn => btn.classList.remove("selected")) + button.classList.add("selected") + } + updateRecipes() + }) +}) + + +randomButton.addEventListener("click", () => { + if (randomButton.classList.contains("selected")) { + randomButton.classList.remove("selected") + resetFilters() + updateRecipes() + + } else { + resetFilters() + randomButton.classList.add("selected") + const randomRecipe = allRecipes[Math.floor(Math.random() * allRecipes.length)] + showRecipes([randomRecipe]) + } +}) + + +recipesContainer.addEventListener("click", (event) => { + const favButton = event.target.closest(".btn-fav") + if (favButton) { + event.stopPropagation() + const recipeId = parseInt(favButton.dataset.id) + const recipe = allRecipes.find(r => r.id === recipeId) + if (!recipe) return + recipe.isFavorite = !recipe.isFavorite + favButton.classList.toggle("active", recipe.isFavorite) + localStorage.setItem("allRecipes", JSON.stringify(allRecipes)) + if (showFavoritesOnly) updateRecipes() + return + } + + const recipeCard = event.target.closest(".recipe-card") + if (recipeCard) { + const url = recipeCard.dataset.url + if (url) window.open(url, "_blank", "noopener noreferrer") + } +}) + +fetchData() + + + + + diff --git a/style.css b/style.css new file mode 100644 index 000000000..43c79cd30 --- /dev/null +++ b/style.css @@ -0,0 +1,464 @@ +* { + box-sizing: border-box; +} + +body { + font-family: "Jost", sans-serif; + padding: 0; + margin: 0; +} + +/* =========================================== + Header + =========================================== */ +h1 { + color: #0018A4; + font-weight: 700; + font-size: 34px; +} + +header { + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 16px 14px; + background-color: #fff; + gap: 10px; + width: 100%; +} + +.header-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + width: 100%; +} + +/* Favorite recipes and btn */ +.favorites { + display: flex; + align-items: center; +} + +.btn-favorites { + display: flex; + align-items: center; + gap: 6px; + background: none; + border: none; + cursor: pointer; + font-size: 1rem; +} + +.btn-favorites svg { + vertical-align: middle; +} + +.heart-icon path { + fill: none; + stroke: black; + stroke-width: 2; + transition: fill 0.3s ease; +} + +.btn-favorites.active .heart-icon path { + fill: red; + stroke: red; +} + +.btn-favorites span { + display: none; +} + +/* Search field */ +.search-container { + display: flex; + align-items: center; + border: 1px solid #ccc; + border-radius: 50px; + padding: 5px; + background: #fff; + transition: all 0.3s ease; + overflow: hidden; + flex-shrink: 1; + min-width: 0; + max-width: 50%; +} + +#searchInput { + border: none; + outline: none; + padding: 5px; + font-size: 12px; + flex: 1; + display: none; + width: 0; + opacity: 0; + transition: width 0.3s ease, opacity 0.3s ease; +} + +#searchInput.active { + display: block; + width: 100%; + opacity: 1; + max-width: 300px; +} + +.btn-search { + background: none; + border: none; + cursor: pointer; + font-size: 18px; +} + +.btn-clear-search { + display: none; + background: none; + border: none; + cursor: pointer; + font-size: 18px; + margin-left: 4px; +} + +.btn-clear-search.active { + display: block; +} + +/* =========================================== + Options container + =========================================== */ +h2 { + font-weight: 700; + font-size: 24px; +} + +.options-container { + display: flex; + flex-direction: column; + padding: 10px 14px; + gap: 20px; + margin: 0 auto; +} + +.filter-buttons, +.sorting-buttons, +.random-buttons { + display: flex; + flex-direction: row; + flex-wrap: wrap; + margin: 0; + gap: 12px; +} + +/* Kitchen filter buttons */ +.btn-filter { + background-color: #CCFFE2; + color: #0018A4; + border-radius: 50px; + padding: 8px 16px; + gap: 10px; + text-align: center; + font-size: 18px; + border: 2px solid transparent; + transition: all 0.2s ease; +} + +.btn-filter:hover { + border: 2px solid #0018A4; + cursor: pointer; + transform: scale(1.05); +} + +.btn-filter.selected { + background-color: #0018A4; + color: white; +} + +/* Sort filter buttons */ +.btn-sort { + background-color: #FFECEA; + color: #0018A4; + border-radius: 50px; + padding: 8px 16px; + gap: 10px; + text-align: center; + font-size: 18px; + border: 2px solid transparent; + transition: all 0.2s ease; +} + +.btn-sort:hover { + background-color: #FF6589; + color: white; + border: 2px solid #0018A4; + cursor: pointer; + transform: scale(1.05); +} + +.btn-sort.selected { + background-color: #FF6589; + color: white; + border: 2px solid #FF6589; +} + +/* Surprise me button */ +.btn-random { + background-image: linear-gradient(135deg, #ae88ff 0%, #7f9fff 51%, #aa88ff 100%); + color: yellow; + font-weight: 500; + font-size: 18px; + padding: 8px 16px; + border-radius: 50px; + margin-bottom: 12px; + border: 2px solid transparent; + cursor: pointer; + transition: all 0.2s ease; +} + +.btn-random:hover { + transform: scale(1.05); + border: 2px solid #0018A4; +} + +.btn-random.selected { + background-image: none; + background-color: #6a529c; + color: white; +} + +/* =========================================== + Recipe container + =========================================== */ +#recipeContainer { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + justify-content: center; + padding: 12px 14px; + gap: 20px; + margin-top: 40px; + transition: grid-template-columns 0.3s ease, opacity 0.3s ease, transform 0.3s ease; +} + +/* Recipe cards */ +.recipe-card { + border: 2px solid #e9e9e9; + background: white; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + position: relative; + padding: 12px; + border-radius: 16px; + transition: all 0.3s ease-in-out; +} + +.recipe-card:hover { + border: 2px solid #0018A4; + box-shadow: 0px 0px 30px 0px rgba(0, 24, 164, 0.2); +} + +.btn-fav { + background: none; + border: none; + cursor: pointer; + padding: 0; + margin: 0; + position: absolute; + bottom: 12px; + right: 12px; +} + +.btn-fav svg path { + fill: none; + stroke: black; + stroke-width: 2; + transition: fill 0.3s ease; +} + +.btn-fav:hover path { + fill: rgba(255, 0, 0, 0.3); +} + +.btn-fav.active svg path { + fill: red; + stroke: red; +} + +hr.solid { + border: none; + border-top: 2px solid #E9E9E9; + height: 1px; + width: 100%; + margin: 10px 0; + align-self: stretch; +} + +.ingredients-title { + display: inline-block; + margin-bottom: 5px; +} + +.card-image { + display: block; + margin: 0 auto; + width: 100%; + border-radius: 12px; +} + +/* Loading for recipes */ +#loading { + display: none; + text-align: center; + font-size: 1.5rem; + padding: 40px; + color: #0018A4; +} + +.spinner { + margin: 0 auto 12px; + border: 5px solid #f3f3f3; + border-top: 5px solid #0018A4; + border-radius: 50%; + width: 50px; + height: 50px; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +/* =========================================== + Media queries + =========================================== */ +@media (min-width: 668px) { + header { + flex-direction: row; + justify-content: space-between; + align-items: center; + } + + h1 { + font-size: 40px; + padding-left: 20px; + } + + .header-actions { + flex-wrap: nowrap; + flex: 1; + justify-content: flex-end; + gap: 20px; + } + + .search-container { + width: auto; + } + + .btn-favorites span { + display: inline; + line-height: 1; + } + + #searchInput { + display: block; + font-size: 16px; + width: 200px; + opacity: 1; + } + + h2 { + font-size: 26px; + } + + header, + .options-container, + #recipeContainer { + margin: 0; + } + + .options-container { + margin-bottom: 40px; + padding-left: 40px; + } + + #recipeContainer { + padding: 0 40px 40px; + } + + .placeholder-output { + font-size: 20px; + } + + .recipe-card { + gap: 16px; + } + + .recipe-card h2 { + font-size: 24px; + margin: 2px 0; + } + + .recipe-card p { + font-size: 18px; + margin: 0; + } + + hr.solid { + margin: 0; + } +} + +@media (min-width: 900px) { + .options-container { + flex-direction: row; + align-items: flex-start; + justify-content: space-between; + gap: 100px; + } +} + +@media (min-width: 1200px) { + h1 { + font-size: 64px; + } + + h2 { + font-size: 34px; + } + + #recipeContainer { + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + } +} + +@media (min-width: 1600px) { + + header, + .options-container { + max-width: 1400px; + } + + #recipeContainer { + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + } +} + +@media (min-width: 2000px) { + + header, + .options-container { + max-width: 1800px; + } + + #recipeContainer { + grid-template-columns: repeat(auto-fill, minmax(460px, 1fr)); + } +} \ No newline at end of file