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
+
+
+
+
+
+
+
+
+
+
+
+
Filter on kitchen
+
+
+
+
+
+
+
+
+
+
+
+
Sort on time
+
+
+
+
+
+
+
+
+
Need inspiration?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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}
+
+
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