diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..f673a71b7 --- /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..54c9fd7c2 100644 --- a/README.md +++ b/README.md @@ -1 +1,37 @@ -# js-project-recipe-library +# 🍽️ Recipe Finder — API-driven recipe list + +![mockup](assets/img/mockup.recipe.png) + +## Live: Recipe library + +### A responsive web app that fetches recipes from an external recipe API and allows the user to filter by cuisine, sort by cooking time, and randomly generate a recipe. Built with HTML, CSS, and Vanilla JavaScript (DOM + fetch). + +🎯 Purpose + +- Practice fetch and async/await against an open API. +- Practice state management in the frontend (active filter, sort direction, last loaded recipes). +- Implement mobile-first and accessible UI logic (buttons, aria labels). + +✨ Features + +- API retrieval of recipes (title, image, time, possibly cuisine). +- Filtering by cuisine (e.g. Italian, American, Asian, Belgian). +- Sorting by time (ascending/descending). +- "Not sure?" - shows a random recipe from the current list. +- Responsive layout with clear recipe cards. +- Error handling & loading state. + +🧩 Technical overview + +- bJavaScript (ES6): fetch, async/await, event listeners, DOM update. +- State in memory: currentCuisine, currentSortDir, currentRecipes. +- Data structure: renders cards from the API response (title, image, readyInMinutes, cuisine). +- Accessibility: semantic buttons, aria on the filter section. + +🚀 Deployment (Netlify) + +- Connect repo → Netlify +- Add env var: SPOONACULAR_API_KEY +- Build command: (empty if pure static page) +- Publish directory: project root (or dist if you are building) +- Deploy → Live: https://quiet-chebakia-ed7a91.netlify.app/ \ No newline at end of file diff --git a/assets/css/style.css b/assets/css/style.css new file mode 100644 index 000000000..967a1958b --- /dev/null +++ b/assets/css/style.css @@ -0,0 +1,281 @@ + :root { + --bg: #FAFBFF; + --h1: #0018A4; + --h2: black; + --h3: #6F6F6F; + --button1: rgb(204, 255, 226); + --button2: rgb(255, 236, 234); + + /* button */ + --btn-height: 48px; + --btn-font: 18px; + --btn-font-weight: 150; + --btn-radius: 999px; + --btn-gap: 12px; + --block-gap: 16px; + + /*Recipe card */ + --recipe-width: 300px; + --recipe-height: 621px; + --recipe-radius: 8px; + --recipe-shadow: rgba(0, 0, 0, 0.1); + --recipe-shadow-hover: rgba(0, 0, 0, 0.2); + --group-width: 1000px; + --group-height: 85px; + + /* Layout */ + --container-max: 1200px; + --page-pad: 16px; +} + + * { + box-sizing: border-box; +} + +body { + background-color: var(--bg); + font-family: 'Arial', sans-serif; + margin: 0; + padding: 0; +} + +h1 { + color: var(--h1); + font-size: 65px; + font-weight: bold; + /* margin: 50px 0; */ + margin: 40px 0 24px; + align-items: center; + justify-content: center; + text-align: center; +} +.recipe-header { + display: grid; + place-items: center; +} + +/*buttonarea och filter 1*/ +.buttonarea, .filter{ + display: block; /* block på mobil */ + width: 100%; /* släpp var(--group-width) */ + /* height: auto; <-- implicit */ + box-sizing: border-box; + padding-left: max(clamp(12px, 6vw, 20px), env(safe-area-inset-left)); + padding-right: max(clamp(12px, 6vw, 20px), env(safe-area-inset-right)); + +} + +.group{ + display: grid; + grid-template-columns: 1fr; /* 1 kolumn på mobil */ + grid-template-areas: + "head-left" + "head-right" + "buttons"; + gap: 16px; + margin: 0 auto; + max-width: var(--container-max); +} + +.group h2{ + margin: 0; +} + +.group h2:first-of-type{ + grid-area: head-left; +} +.group h2:nth-of-type(2){ + grid-area: head-right; text-align: left; +} + +.btn, .btn-time, .btn-random{ + font-family: 'sans-serif'; + display: inline-flex; + align-items: center; + justify-content: center; + min-height: var(--btn-height); + height: var(--btn-height); + font-size: var(--btn-font); + padding: 0 18px; + border-radius: var(--btn-radius); + border: 2px solid transparent; + font-weight: var(--btn-font-weight); + white-space: nowrap; + margin: 0; + cursor: pointer; + /* gör att de bryter rad snyggt på mobil */ + flex: 0 1 150px; +} + +.btn { + background: var(--button1); + color: var(--h1); +} +.btn-time { + background: var(--button2); + color: var(--h1); +} + +.btn:hover, .btn-time:hover, .btn-random:hover{ + border-color: var(--h1); +} +.btn:active, .btn-time:active, .btn-random:active{ + transform: translateY(1px); + border-color: var(--h1); +} + + .button-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: center; /*centrera rader i mobil */ +} + +/* Fyll ut rad på mobil men behåll min-bredd */ +.button-row .btn, +.button-row .btn-time, +.button-row .btn-random { + flex: 0 1 150px; /* väx, krymp, min 140px */ +} + +/*recipe and cards*/ +.recipe-grid, .recipe-card-placeholder{ + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 24px; +} + +.recipe-card { + display: block; + border: 2px solid transparent; + border-radius: var(--recipe-radius); + width: 100%; + padding: 16px 16px 24px 16px; + overflow: hidden; + background: #fff; + box-shadow: 0 1px 4px var(--recipe-shadow); + transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease; +} + +.recipe-card:hover { + border-color: var(--h1); + transform: translateY(-2px); + box-shadow: 0 4px 12px var(--recipe-shadow-hover); +} + +.recipe-card img{ + width: 100%; + aspect-ratio: 16 / 10; + object-fit: cover; + display: block; + margin: 0; + border-radius: calc(var(--recipe-radius) - 2px); +} + +.recipe-card h3{ + margin: 10px 12px 4px; + font-size: 16px; + font-weight: 700; + color: #151827; +} + +.recipe-card p{ + margin: 0 12px 12px; + color: #6F6F6F; + font-size: 1em; + line-height: 1.5; +} + +/* 1) Allra minsta mobiler (≤ 399px) */ +@media (max-width: 399px) { + /* Mindre sidomarginaler */ + .buttonarea, .filter { + padding-left: 12px; + padding-right: 12px; + } + + /* Rubrik: mindre & tightare marginaler */ + h1 { + font-size: 34px; + margin: 24px 0 16px; + text-align: center; + } + + /* Grid: 1 kolumn, lite mindre gap och full bredd */ + .recipe-grid, + .recipe-card-placeholder{ + grid-template-columns: 1fr; + gap: 16px; + width: 100%; + margin: 16px auto 24px; + padding: 0 12px; + } + + /* Kort: mindre padding, inga hårda höjder (du har redan tagit bort height) */ + .recipe-card { + padding: 12px; + box-shadow: 0 1px 4px var(--recipe-shadow); + } + .recipe-card img{ + aspect-ratio: 16 / 10; + object-fit: cover; + } + .recipe-card h3{ + font-size: 15px; + margin: 8px 4px 4px; + } + .recipe-card p{ + margin: 0 4px 10px; + font-size: 1em; + line-height: 1.45; + } + + /* Knapprader: en knapp per rad på minsta mobiler */ + .button-row { + gap: 8px; + justify-content: stretch; + } + .button-row .btn, + .button-row .btn-time, + .button-row .btn-random { + flex: 1 1 100%; + min-height: 44px; + font-size: 16px; + } +} + +/* 2) Små/mellanmobiler (400–639px): två knappar i rad, 1 kolumn grid */ +@media (min-width: 400px) and (max-width: 639px) { + .recipe-grid, + .recipe-card-placeholder{ + grid-template-columns: 1fr; /* håll 1 kolumn här också */ + gap: 20px; + width: 100%; + padding: 0 16px; + } + + .button-row .btn, + .button-row .btn-time, + .button-row .btn-random { + flex: 1 1 calc(50% - 8px); /* två knappar per rad */ + } +} + +/* 3 small tablets */ +@media (min-width: 640px){ + .group{ + grid-template-columns: 1fr 1fr; + grid-template-areas: + "head-left head-right" + "buttons buttons"; + } + .recipe-grid{ grid-template-columns: repeat(2, minmax(0,1fr)); } +} + +@media (min-width: 960px){ /* laptop */ + .recipe-grid{ grid-template-columns: repeat(3, minmax(0,1fr)); } +} + +@media (min-width: 1280px){ /* desktop */ + .recipe-grid{ grid-template-columns: repeat(4, minmax(0,1fr)); } +} \ No newline at end of file diff --git a/assets/img/Burrataboard-med-persika-och-mynta-768x1024.jpg b/assets/img/Burrataboard-med-persika-och-mynta-768x1024.jpg new file mode 100644 index 000000000..6f57ce12d Binary files /dev/null and b/assets/img/Burrataboard-med-persika-och-mynta-768x1024.jpg differ diff --git a/assets/img/Margharita-740429.jpg b/assets/img/Margharita-740429.jpg new file mode 100644 index 000000000..0da21eec9 Binary files /dev/null and b/assets/img/Margharita-740429.jpg differ diff --git a/assets/img/belgiska_vafflor.jpg b/assets/img/belgiska_vafflor.jpg new file mode 100644 index 000000000..b1833d531 Binary files /dev/null and b/assets/img/belgiska_vafflor.jpg differ diff --git a/assets/img/cheeseburgers.jpg b/assets/img/cheeseburgers.jpg new file mode 100644 index 000000000..4b6e92606 Binary files /dev/null and b/assets/img/cheeseburgers.jpg differ diff --git a/assets/img/mac_and_cheese_med_panko.jpg b/assets/img/mac_and_cheese_med_panko.jpg new file mode 100644 index 000000000..98bd7b896 Binary files /dev/null and b/assets/img/mac_and_cheese_med_panko.jpg differ diff --git a/assets/img/mockup.recipe.png b/assets/img/mockup.recipe.png new file mode 100644 index 000000000..ab62c94da Binary files /dev/null and b/assets/img/mockup.recipe.png differ diff --git a/assets/img/moules_frites.jpg b/assets/img/moules_frites.jpg new file mode 100644 index 000000000..322ba59ab Binary files /dev/null and b/assets/img/moules_frites.jpg differ diff --git a/assets/img/pad_thai_.jpg b/assets/img/pad_thai_.jpg new file mode 100644 index 000000000..9d9619394 Binary files /dev/null and b/assets/img/pad_thai_.jpg differ diff --git a/assets/img/sushi-california-rolls.jpg.webp b/assets/img/sushi-california-rolls.jpg.webp new file mode 100644 index 000000000..3d3f78769 Binary files /dev/null and b/assets/img/sushi-california-rolls.jpg.webp differ diff --git a/backup.js b/backup.js new file mode 100644 index 000000000..e69de29bb diff --git a/index.html b/index.html new file mode 100644 index 000000000..6ffb0d507 --- /dev/null +++ b/index.html @@ -0,0 +1,49 @@ + + + + + + Recipe Library + + + +
+

+ Recipe Library +

+ + +
+
+
+
+

Filter on kitchen

+ + + + + +
+ + +
+

Sort on time

+ + + +
+
+
+
+ + + +
+
+
+
+ +
+ + \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 000000000..e15beb457 --- /dev/null +++ b/index.js @@ -0,0 +1,200 @@ + /*Global variables + URLEN +*/ +const API_KEY = "f07978112d8d44b597e5e071796142fb"; +const BASE = "https://api.spoonacular.com/recipes/complexSearch"; + +/* Mappning av egna knappvärden -> Spoonacular */ +function mapCuisine(value){ + const v = String(value).toLowerCase().trim(); + const map = { + all: 'all', + italian: 'italian', + american: 'american', + asia: 'asian', + belgia: 'belgian' + }; + return map[v] ?? 'all'; //Fallback +} + +/* +Bygg URL enligt state +In short: the function builds a ready-made URL (web address) +to Spoonacular's search API based on what you want – +cuisine, sort direction, and how many recipes – and returns it as a string. +*/ +function buildUrl({ cuisine = 'all', sortDir = 'asc', number = 14 } = {}) { + const params = new URLSearchParams({ + apiKey: API_KEY, + number: String(number), + addRecipeInformation: "true", + instructionsRequired: "true", + sort: "time", + sortDirection: sortDir, + }); + const c = mapCuisine(cuisine); + if (c !== 'all') params.set('cuisine', c); + return `${BASE}?${params.toString()}`; +} + +/* + STATE + Variables that tell us what the user has selected + (kitchen and sorting) and which recipes were most recently retrieved. + When you click the buttons, these values ​​are updated – and we retrieve new recipes. +*/ +let currentCuisine = 'all'; +let currentSortDir = 'asc'; +let currentRecipes = []; + +/* + DOM + grid: the container where the cards will be printed + filterBtns: all buttons that filter by cuisine + sortBtns: buttons that set up/down by time + randomBtn: the button that shows a random recipe +*/ +const grid = document.querySelector('.recipe-grid'); +const filterBtns = document.querySelectorAll('.btn[data-cuisine]'); +const sortBtns = document.querySelectorAll('.btn-time[data-sort]'); +const randomBtn = document.querySelector('.btn-random'); + +/* + HÄMTA & RENDERA + Builds a URL based on the current state (kitchen + sorting). + fetch(url) calls the API and waits for a response. + If successful: data.results is added to currentRecipes and sent to renderRecipes. + If unsuccessful: we display a simple error message in the grid. +*/ +async function fetchRecipes() { + try { + const url = buildUrl({ cuisine: currentCuisine, sortDir: currentSortDir, number: 14 }); + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + currentRecipes = data.results || []; + + + //function for sorting ascending and descending + currentRecipes.sort(function (a, b) { + const am = Number.isFinite(a.readyInMinutes) ? a.readyInMinutes : Infinity; + const bm = Number.isFinite(b.readyInMinutes) ? b.readyInMinutes : Infinity; + return currentSortDir === 'asc' ? (am - bm) : (bm - am); + }); + + renderRecipes(currentRecipes); + } catch (e) { + console.error(e); + grid.innerHTML = ` +
+

Oh, something went wrong.

+

Kunde inte hämta recept just nu.

+
`; + } +} + +/* + Rendera listan + If the list is empty -> show “No recipes found”. + Otherwise: create HTML for each recipe with recipeCardHTML() and insert into grid. +*/ +function renderRecipes(list) { + if (!grid) return; + if (!list || list.length === 0) { + grid.innerHTML = `

Inga recept hittades.

`; + return; + } + grid.innerHTML = list.map(r => recipeCardHTML(r)).join(''); +} + +/* + SKAPAR ETT RECEPTKORT + Chooses “safe” fallback values ​​if something is missing (e.g. title/image/time). + summary is cleaned from HTML and shortened to 160 characters. + Image has onerror="this.style.display='none'" → if the image is broken, disappears so the layout looks good. + Link to the recipe page on Spoonacular is built via title-slug + id +*/ +function recipeCardHTML(r) { + const title = r.title ?? 'Untitled'; + const img = r.image ?? ''; + const minutes = Number.isFinite(r.readyInMinutes) ? r.readyInMinutes : null; + const cuisines = (Array.isArray(r.cuisines) && r.cuisines.length) ? r.cuisines.join(', ') : + (currentCuisine === 'all' ? '—' : cap(mapCuisine(currentCuisine))); + // const ingredients = + const summary = stripHtml(r.summary || '').slice(0, 160) + (r.summary && r.summary.length > 160 ? '…' : ''); + + return ` +
+ ${esc(title)} +

${esc(title)}

+

${esc(cuisines)}

+

${minutes ? `Time | ${minutes} mins` : 'Time | —'}

+ ${summary ? `

${esc(summary)}

` : ''} + Open recipe +
+ `; +} + +/* + HJÄLPARE + stripHtml: removes HTML tags from a text (to avoid strange content in summary). + esc: “escaps” text so that special characters do not break HTML. + slug: makes “nice” URL-friendly strings (for the link). + cap: capitalizes the first letter (to display “Asian” instead of “asian”). +*/ +function stripHtml(html){ + const el = document.createElement('div'); + el.innerHTML = html || ''; + return el.textContent || el.innerText || ''; +} +function esc(s){ return String(s).replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); } +function slug(s){ return String(s).toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/(^-|-$)/g,''); } +function cap(s){ return String(s).charAt(0).toUpperCase() + String(s).slice(1); } + +/* + INTERAKTION + Filter buttons: updates currentCuisine and fetches new recipes based on that selection. + Sort buttons: changes currentSortDir (asc/desc) and fetches again. + Random: takes a random recipe from the last fetch and renders only that card. +*/ +filterBtns.forEach(btn => { + btn.addEventListener('click', () => { + currentCuisine = btn.dataset.cuisine || 'all'; + setActive(filterBtns, btn); + fetchRecipes(); + }); +}); + +sortBtns.forEach(btn => { + btn.addEventListener('click', () => { + currentSortDir = (btn.dataset.sort === 'desc') ? 'desc' : 'asc'; + console.log('[SORT CLICK]', { dataset: btn.dataset.sort, currentSortDir }); + const testUrl = buildUrl({ cuisine: currentCuisine, sortDir: currentSortDir, number: 14 }); + console.log('[URL]', testUrl); + setActive(sortBtns, btn); + fetchRecipes(); + }); +}); + +randomBtn?.addEventListener('click', () => { + if (!currentRecipes.length) return; + const rand = currentRecipes[Math.floor(Math.random() * currentRecipes.length)]; + renderRecipes([rand]); +}); + +/* visuellt aktiv */ +function setActive(nodeList, activeEl){ + nodeList.forEach(b => b.classList.remove('active')); + activeEl.classList.add('active'); +} + +/* + INIT + Visually sets “All” + “Ascending” as the starting selection. + Run fetchRecipes() immediately so the page is populated with recipes when it loads. +*/ +(function init(){ + document.querySelector('.btn[data-cuisine="all"]')?.classList.add('active'); + document.querySelector('.btn-time[data-sort="asc"]')?.classList.add('active'); + fetchRecipes(); +})();