diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..aef844305 --- /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..2b9a0cb8f 100644 --- a/README.md +++ b/README.md @@ -1 +1,3 @@ # js-project-recipe-library + +netlify link: https://carolinas-recipe-library.netlify.app/ diff --git a/img/French-toast.jpg b/img/French-toast.jpg new file mode 100644 index 000000000..60879608e Binary files /dev/null and b/img/French-toast.jpg differ diff --git a/img/beef-stew.jpg b/img/beef-stew.jpg new file mode 100644 index 000000000..62f667c70 Binary files /dev/null and b/img/beef-stew.jpg differ diff --git a/img/lentil-soup.jpg b/img/lentil-soup.jpg new file mode 100644 index 000000000..86c2e0e49 Binary files /dev/null and b/img/lentil-soup.jpg differ diff --git a/img/pasta-pesto.jpg b/img/pasta-pesto.jpg new file mode 100644 index 000000000..7e0bd362c Binary files /dev/null and b/img/pasta-pesto.jpg differ diff --git a/index.html b/index.html new file mode 100644 index 000000000..21b47f36d --- /dev/null +++ b/index.html @@ -0,0 +1,105 @@ + + + + + + + + Carolinas recipe library + + + +

Recipe library

+ +
+
+

Meals

+
+ + + + + +
+ +
+
+

Cooking time

+
+ + +
+
+ +
+

Random recipe

+ +
+ +
+

Search for recipes:

+
+ + +
+
+
+ +
+

Loading recipes... 🍳

+
+ +
+ +
+ + + + \ No newline at end of file diff --git a/script.js b/script.js new file mode 100644 index 000000000..46bfa4813 --- /dev/null +++ b/script.js @@ -0,0 +1,215 @@ +// ====== DOM SELECTORS ====== +const btnAll = document.getElementById("btnAll") +const btnBreakfast = document.getElementById("btnBreakfast") +const btnLunch = document.getElementById("btnLunch") +const btnFika = document.getElementById("btnFika") +const btnDinner = document.getElementById("btnDinner") +const btnAscending = document.getElementById("btnAscending") +const btnDescending = document.getElementById("btnDescending") +const mealBtns = document.querySelectorAll(".meal-btn") +const timeBtns = document.querySelectorAll(".time-btn") +const randomBtn = document.getElementById("btnRandom") +const recipeContainer = document.getElementById("recipeContainer") +const noResultsContainer = document.getElementById("noResultsContainer") +const loading = document.getElementById("loading") +const searchInput = document.getElementById("searchBar") +const searchBtn = document.getElementById("searchBtn") + +// ====== API SETTINGS ====== +const URL = `https://api.spoonacular.com/recipes/random?number=15&apiKey=4d3e2b2a43464de48c4a0aac3abf2c52` + +// ====== GLOBAL STATE ====== +let recipeInfo = [] +let activeFilters = { + mealType: "All", + sortOrder: null +} + +// ====== DISPLAY FUNCTIONS ====== +const showRecipes = (recipesToShow) => { + recipeContainer.innerHTML = `` + + recipesToShow.forEach(recipe => { + recipeContainer.innerHTML += ` +
+ ${recipe.title} +

${recipe.title}

+
+

+ Meal: + ${recipe.dishTypes?.join(", ") || "N/A"}
+ Cooking time: + ${recipe.readyInMinutes} min +

+
+

Ingredients

+

${recipe.extendedIngredients + ? recipe.extendedIngredients.map(ing => `${ing.name}`).join("
") + : "No ingredients listed" + }

+
` + }) +} + +// ====== FILTERING & SORTING ====== +const filterAndSorting = () => { + let filtered = [...recipeInfo] + + // Filter by meal type + if (activeFilters.mealType && activeFilters.mealType !== "All") { + filtered = filtered.filter(recipe => recipe.dishTypes?.includes(activeFilters.mealType)) + } + + // Sort by time (ascending or descending) + if (activeFilters.sortOrder === "asc") { + filtered.sort((a, b) => a.readyInMinutes - b.readyInMinutes) + } else if (activeFilters.sortOrder === "desc") { + filtered.sort((a, b) => b.readyInMinutes - a.readyInMinutes) + } + + // Display filtered results or show a message if none found + if (filtered.length > 0) { + noResultsContainer.innerHTML = `` + showRecipes(filtered) + } else { + recipeContainer.innerHTML = `` + noResultsContainer.innerHTML = ` +
+

No recipes found for "${activeFilters.mealType}"

+

Try another filter

+
` + } +} + +// ====== SEARCH ====== +const searchRecipes = () => { + const query = searchInput.value.trim().toLowerCase() + if (!query) { + filterAndSorting() + return + } + + let filtered = [...recipeInfo] + + if (activeFilters.mealType && activeFilters.mealType !== "All") { + filtered = filtered.filter(recipe => + recipe.dishTypes?.includes(activeFilters.mealType) + ) + } + + filtered = filtered.filter(recipe => { + const titleMatch = recipe.title.toLowerCase().includes(query) + const ingredientsMatch = recipe.extendedIngredients?.some(ing => + ing.name.toLowerCase().includes(query) + ) + return titleMatch || ingredientsMatch + }) + + if (filtered.length > 0) { + noResultsContainer.innerHTML = `` + showRecipes(filtered) + } else { + recipeContainer.innerHTML = `` + noResultsContainer.innerHTML = ` +
+

No recipes found matching "${query}"

+

Try another search term or combination of filters.

+
` + } +} + +// ====== RANDOM RECIPE FEATURE ====== +const getRandomRecipe = () => { + const randomIndex = Math.floor(Math.random() * recipeInfo.length) + const randomRecipe = recipeInfo[randomIndex] + showRecipes([randomRecipe]) +} + +// ====== DATA FETCHING (with caching + error handling) ====== +const fetchData = async (URL) => { + loading.style.display = "block" + recipeContainer.innerHTML = `` + noResultsContainer.innerHTML = `` + + try { + const cachedData = localStorage.getItem("cachedRecipes") + const cacheTime = localStorage.getItem("cacheTime") + + const now = Date.now() + const sixHours = 6 * 60 * 60 * 1000 // cache duration + + // Use cache if still valid + if (cachedData && cacheTime && now - cacheTime < sixHours) { + recipeInfo = JSON.parse(cachedData) + loading.style.display = "none" + filterAndSorting() + } else { + // Otherwise, fetch new data + const response = await fetch(URL) + + // Handle API quota errors + if (!response.ok) { + if (response.status === 402 || response.status === 429) { + loading.style.display = "none" + recipeContainer.innerHTML = `` + noResultsContainer.innerHTML = ` +
+

Daily API quota reached

+

Spoonacular limits the number of requests per day. + Please try again later or use cached results if available.

+
` + return + } else { + throw new Error(`HTTP error ${response ? response.status : "unknown"}`) + } + } + + const data = await response.json() + recipeInfo = data.recipes + + // Save to cache + localStorage.setItem("cachedRecipes", JSON.stringify(recipeInfo)) + localStorage.setItem("cacheTime", Date.now()) + + loading.style.display = "none" + filterAndSorting() + } + } catch (err) { + loading.style.display = "none" + noResultsContainer.innerHTML = ` +
+

Something went wrong

+

${err.message || "Unable to load recipes right now. Please try again later."}

+
` + } +} + +// ====== EVENT LISTENERS ====== +btnAll.addEventListener("click", () => { activeFilters.mealType = "All"; filterAndSorting() }) +btnBreakfast.addEventListener("click", () => { activeFilters.mealType = "breakfast"; filterAndSorting() }) +btnLunch.addEventListener("click", () => { activeFilters.mealType = "lunch"; filterAndSorting() }) +btnFika.addEventListener("click", () => { activeFilters.mealType = "fika"; filterAndSorting() }) +btnDinner.addEventListener("click", () => { activeFilters.mealType = "dinner"; filterAndSorting() }) +btnAscending.addEventListener("click", () => { activeFilters.sortOrder = "asc"; filterAndSorting() }) +btnDescending.addEventListener("click", () => { activeFilters.sortOrder = "desc"; filterAndSorting() }) +randomBtn.addEventListener("click", getRandomRecipe) +searchBtn.addEventListener("click", searchRecipes) +searchInput.addEventListener("keypress", (e) => { if (e.key === "Enter") searchRecipes() }) + +// ====== INITIAL FETCH ====== +fetchData(URL) + +// ====== BUTTON STATE TOGGLE ====== +mealBtns.forEach(clickedButton => { + clickedButton.addEventListener("click", () => { + mealBtns.forEach(currentButton => currentButton.classList.remove("active")) + clickedButton.classList.add("active") + }) +}) + +timeBtns.forEach(clickedButton => { + clickedButton.addEventListener("click", () => { + timeBtns.forEach(currentButton => currentButton.classList.remove("active")) + clickedButton.classList.add("active") + }) +}) \ No newline at end of file diff --git a/style.css b/style.css new file mode 100644 index 000000000..2229e12cb --- /dev/null +++ b/style.css @@ -0,0 +1,203 @@ +body { + font-family: "futura"; + background-color: #FAFBFF; + margin: 16px 8px; +} + +h1 { + color: #0018A4; + font-size: 64px; + font-weight: 700; +} + +h2 { + font-size: 22px; + font-weight: 700; + color: black; +} + + +.filter-row { + display: flex; + flex-direction: row; + gap: 25px; + flex-wrap: wrap; +} + +.meal-buttons-container, +.time-buttons-container { + display: flex; + flex-wrap: wrap; + gap: 18px; +} + +button { + font-family: "futura"; + font-size: 18px; + font-weight: 500; + border: 2px solid transparent; + transition: border 0.3s ease; +} + +.meal-btn { + background: #CCFFE2; + border-radius: 50px; + padding: 8px 16px; +} + +.time-btn { + background: #FFECEA; + border-radius: 50px; + padding: 8px 16px; +} + + +.time-btn:hover { + background: #FF6589; + color: white; + border: 2px solid #0018A4; +} + +.meal-btn:hover { + border: 2px solid #0018A4; +} + +.random-btn:hover { + border: 2px solid #0018A4; +} + +.meal-btn.active { + background: #0018A4; + color: white; +} + +.time-btn.active { + background: #FF6589; + color: white; + border: 2px solid transparent; +} + +.random-btn { + background: #ffe065; + border-radius: 50px; + padding: 8px 16px; +} + +.random-btn.active { + background: #b2ff65; +} + +.search-bar-container { + display: flex; + align-items: center; + border: 1px solid #0018A4; + border-radius: 6px; + margin-left: 5px; +} + +.search-bar { + width: 200px; + height: 38px; + border-radius: 6px 0 0 6px; + padding: 8px; + font-size: 16px; + box-sizing: border-box; + border: none; +} + +.search-button { + height: 38px; + box-sizing: border-box; + background: #ccd3fa; + border-radius: 0 6px 6px 0; + width: 45px; + transition: .3s ease; +} + +.search-button:hover { + background: #8594f4; +} + +.loading p { + font-size: 20px; + margin-top: 50px; + text-align: center; +} + +.recipe-card-container { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 25px; +} + +.recipe-card { + display: flex; + flex-direction: column; + border-radius: 16px; + width: 300px; + height: fit-content; + background: white; + border: 1px solid #E9E9E9; + margin-top: 30px; + padding: 16px 16px 24px 16px; + gap: 16px; + box-sizing: border-box; + box-shadow: 0 0 5px 5px #0019a405; +} + +.recipe-card:hover { + border: 2px solid #0018A4; +} + +img { + border-radius: 12px; + height: 200px; + object-fit: cover; +} + +.recipe-card h3 { + font-size: 22px; + font-weight: 700; + margin: 0; +} + +hr { + border: 1px solid #E9E9E9; + height: 0; + width: 268px; + margin: 0; +} + +.MealLabel { + font-size: 18px; + font-weight: 700; + margin: 0; +} + +.value { + font-size: 16px; + font-weight: 500; + margin: 0; +} + +h4 { + font-size: 18px; + font-weight: 700; + margin: 0; +} + +p { + font-size: 16px; + font-weight: 500; + margin: 0; +} + +.no-results-container { + font-size: 16px; + max-width: 700px; + margin: 20px 0px; + display: flex; + flex-direction: column; + align-items: center; +} \ No newline at end of file