diff --git a/README.md b/README.md index 58f1a8a66..9f395807e 100644 --- a/README.md +++ b/README.md @@ -1 +1,25 @@ # js-project-recipe-library +Basic structure HTML, Style in CSS, Add functionalities in js. + + https://mysecondprojectlibrary.netlify.app/ + + Week 1 + Start to build a Recipe Library + HTML structure: + Input fields for filters and sorting options + A placeholder recipe card + Writing JavaScript functions to handle user selections + Sorting options + + Week 2 + Arrays, object and loops to show recipes + using selections, be able to display all of the recioes, feature a button that selects a random recipe, hav an empty state, responsive from 32npx to 1600px + +Week 3 + Fetch real recipe data from Spoonacular's API + Display dynamic recipe cards based on the API data + Adapt filtering & sorting to match the API response format + Show a useful message to the user in case the daily quota has been reached + + The goal was to practice working with arrays, objects, and functions in JavaScript and to fetch and display real data from an external API + Styled by using CSS variables for what I call Technigo colors (blue, pink, and aqua) to make it look as much as the Figma demo. \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 000000000..507424b08 --- /dev/null +++ b/index.html @@ -0,0 +1,92 @@ + + +
+ + +Loading recipes...
`; + msgEl.textContent = "Fetching data..."; + + // Build query + let url = `${BASE_URL}?number=12&addRecipeInformation=true&apiKey=${API_KEY}`; + + if (filters.kitchen !== "all") url += `&cuisine=${filters.kitchen}`; + if (filters.diet !== "all") url += `&diet=${filters.diet}`; + + try { + const res = await fetch(url); + + if (res.status === 402) { + recipesEl.innerHTML = `Daily API quota exceeded. Please try again tomorrow.
`; + msgEl.textContent = "API limit reached."; + throw new Error("Quota reached"); + } + + const data = await res.json(); + console.log("Fetched data:", data); + + if (!data.results || data.results.length === 0) { + recipesEl.innerHTML = `No recipes found for your selected filters.
`; + msgEl.textContent = "No results found."; + return; + } + + // Normalize data + recipes = data.results.map(recipe => ({ + id: recipe.id, + title: recipe.title || "Unknown title", + image: recipe.image || "https://via.placeholder.com/300x200?text=No+Image", + kitchen: + recipe.cuisines && recipe.cuisines.length > 0 + ? recipe.cuisines[0] + : "Various", + diet: + recipe.diets && recipe.diets.length > 0 + ? recipe.diets[0] + : "General", + time: recipe.readyInMinutes || 0, + ingredients: recipe.extendedIngredients + ? recipe.extendedIngredients.length + : 0, + popularity: recipe.aggregateLikes || 0, + })); + + showRecipes(recipes); + msgEl.textContent = `Fetched ${recipes.length} recipes (${filters.kitchen}, ${filters.diet})`; + } catch (err) { + console.error("Fetch error:", err); + recipesEl.innerHTML = `Something went wrong. Please try again later.
`; + msgEl.textContent = "Failed to load recipes."; + } +} + +// --- FILTER FUNCTION --- +const filterRecipes = list => + list.filter(r => { + // Time filters + if (filters.time === "under15" && !(r.time < 15)) return false; + if (filters.time === "15to30" && !(r.time >= 15 && r.time <= 30)) return false; + if (filters.time === "30to60" && !(r.time >= 30 && r.time <= 60)) return false; + if (filters.time === "over60" && !(r.time > 60)) return false; + + // Ingredient filters + if (filters.ingredients === "under5" && !(r.ingredients < 5)) return false; + if (filters.ingredients === "6to10" && !(r.ingredients >= 6 && r.ingredients <= 10)) return false; + if (filters.ingredients === "11to15" && !(r.ingredients >= 11 && r.ingredients <= 15)) return false; + if (filters.ingredients === "over16" && !(r.ingredients > 16)) return false; + + return true; + }); + +// --- SORT FUNCTION --- +const sortRecipes = list => { + const key = filters.sort; + const sorted = [...list]; + sorted.sort((a, b) => b[key] - a[key]); // highest first + return sorted; +}; + +// --- SHOW RECIPES --- +const showRecipes = list => { + const filtered = filterRecipes(list); + const sorted = sortRecipes(filtered); + + recipesEl.innerHTML = sorted.length + ? sorted + .map( + r => ` +Cuisine: ${r.kitchen}
+Diet: ${r.diet}
+Time: ${r.time} min
+Ingredients: ${r.ingredients}
+Popularity: ${r.popularity}
+No recipes match your filters.
`; + + msgEl.textContent = `Results: ${sorted.length} | Kitchen: ${filters.kitchen} | Diet: ${filters.diet} | Sort: ${filters.sort}`; +}; + +// --- EVENT LISTENERS --- +pills.forEach(btn => { + btn.addEventListener("click", () => { + const group = btn.parentElement.querySelectorAll(".pill"); + group.forEach(b => b.classList.remove("active")); + btn.classList.add("active"); + + if (btn.dataset.kitchen) filters.kitchen = btn.dataset.kitchen; + if (btn.dataset.diet) filters.diet = btn.dataset.diet; + if (btn.dataset.time) filters.time = btn.dataset.time; + if (btn.dataset.ingredients) filters.ingredients = btn.dataset.ingredients; + if (btn.dataset.sort) filters.sort = btn.dataset.sort; + + if (btn.dataset.kitchen || btn.dataset.diet) { + fetchData(); // refetch from API + } else { + showRecipes(recipes); // filter locally + } + }); +}); + +// --- RANDOM RECIPE BUTTON --- +randomBtn.addEventListener("click", () => { + const random = recipes[Math.floor(Math.random() * recipes.length)]; + showRecipes([random]); +}); + +// --- INITIAL FETCH --- +fetchData(); + diff --git a/styles.css b/styles.css new file mode 100644 index 000000000..9f4d8d812 --- /dev/null +++ b/styles.css @@ -0,0 +1,222 @@ + +/* this applies to the whole website, like a color parent*/ +:root { + --blue: #0018A4; /* Technigo blue */ + --pink: #FF6589; /* Technigo pink */ + --aqua: #CCFFE2; /* Technigo light aqua */ + --gray-dark: #333; + --gray-light: #E5E7EB; + --bg: #f9fafc; +} + +body { + font-family: 'Nunito', sans-serif; + background-color: var(--bg); + color: var(--gray-dark); + font-size: 16px; + line-height: 24px; + margin: 0; + padding: 32px; +} + + +h1, h2, h3, h4, h5, h6, p, ul, li { + margin: 0 0 16px 0; + line-height: 24px; +} + +.wrapper { + width: 100%; + margin: 0 auto; + padding: 0; +} + +/* The title style */ +.page-title { + font-family: 'Futura', sans-serif; + font-weight: 800; + color: var(--blue); + font-size: 64px; + line-height: 64px; /* extra luft för stor rubrik */ + letter-spacing: 0.5px; + margin-bottom: 32px; +} + +/* div-class = controlls*/ +.controls { + display: flex; + justify-content: space-between; + align-items: flex-start; + flex-wrap: wrap; + gap: 32px; + margin-bottom: 48px; +} + +.control-group { + display: flex; + flex-direction: column; + gap: 12px; +} + +.control-title { + font-size: 19px; + font-weight: 700; + color: var(--gray-dark); + + +} + +.pill-group { /* container for pillsm wrapps around several .pill (buttons) */ + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +/* Design for the pills it controls how each small rounded button looks like*/ +.pill { + padding: 10px 21px; + border-radius: 9999px; + border: 2px solid transparent; + background-color: var(--aqua); + color: var(--blue); + font-size: 15px; + font-weight: 700; + cursor: pointer; + transition: all 0.25s ease; + box-shadow: 0 1px 2px rgba(0,0,0,0.05); + line-height: 24px; /* konsekvent med text */ + +} + +.pill:hover { + border-color: var(--blue); + transform: translateY(-1px); +} + +/* when active state — Kitchen, Diet, Ingredients, and Cooking Time */ +.pill.active[data-kitchen], +.pill.active[data-diet], +.pill.active[data-ingredients], +.pill.active[data-time] { + background-color: var(--blue); + color: #fff; + box-shadow: 0 2px 6px rgba(0, 24, 164, 0.35); + transform: translateY(-1px); +} + +/* Make the sorting row buttons line up nicely */ +.sort-row { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; +} + +/* Style the random recipe button to stand out */ +#random-btn, +.pink-btn { + background-color: var(--pink); + color: #fff; + border: none; + border-radius: 9999px; + padding: 10px 24px; + font-size: 15px; + font-weight: 700; + cursor: pointer; + transition: background-color 0.25s ease, transform 0.25s ease; + box-shadow: 0 2px 6px rgba(255, 102, 137, 0.35); +} + +#random-btn:hover, +.pink-btn:hover { + background-color: var(--blue); + transform: translateY(-1px); +} + + +/* The recipes grid */ +.recipes { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 32px; + width: 100%; +} + +.card { + background: #fff; + border-radius: 18px; + overflow: hidden; + border: 1px solid var(--gray-light); + box-shadow: 0 3px 6px rgba(0,0,0,0.06); + transition: all 0.3s ease; +} + +.card:hover { + border-color: var(--blue); + box-shadow: 0 6px 20px rgba(0, 24, 164, 0.2); + transform: translateY(-2px); +} + +.card-img { + width: 100%; + height: 200px; + object-fit: cover; +} + +/* card-body the main content section inside a card, put things like text, headings, and details. + like the “inside of the box” under the picture.*/ +.card-body { + padding: 19px 22px; + line-height: 24px; +} + +.card-body h3 { + font-size: 19px; + font-weight: 700; + color: var(--gray-dark); + margin-bottom: 8px; + line-height: 24px; +} + +.card-body p { + font-size: 15px; + margin-bottom: 8px; + line-height: 24px; +} + +.card-body strong { + font-weight: 700; + color: var(--blue); +} + +.card-body h4 { + margin-top: 16px; + margin-bottom: 8px; + font-size: 16px; + font-weight: 700; + color: var(--gray-dark); + line-height: 24px; +} + +.card-body ul { + padding-left: 19px; + font-size: 14px; + color: #444; + line-height: 24px; +} + +/* a message that shows up when no recipes match the user’s filters or search.*/ +/*.empty { + text-align: center; + padding: 32px; + font-size: 16px; + color: #666; + line-height: 24px; +} + +.note { + font-size: 14px; + margin-bottom: 24px; + color: #666; + line-height: 24px; +}