diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..762b5a229 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ + +.vscode/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..250032a5e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "liveServer.settings.port": 5501, + "githubPullRequests.ignoredPullRequestBranches": [ + "main" + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 58f1a8a66..b60a5c081 100644 --- a/README.md +++ b/README.md @@ -1 +1,3 @@ # js-project-recipe-library + +https://recipe-assignment-technigo.netlify.app/ diff --git a/backup-script.js b/backup-script.js new file mode 100644 index 000000000..37ee9e808 --- /dev/null +++ b/backup-script.js @@ -0,0 +1,289 @@ +// ================================================== +// Settings & Constants +// API keys, URLs, and initial setup variables +// ================================================== +document.addEventListener("DOMContentLoaded", () => { + +const myHeaders = new Headers(); +myHeaders.append('x-api-key', '0c625ab60e6a40a1a4b6cc2e4a5fe9b0'); +// Tells the API that we want the data in JSON format +myHeaders.append('Content-Type', 'application/json') + +const requestOptions = { + method: 'GET', // We want to GET (fetch) data + redirect: 'follow', + headers: myHeaders // API-key sends as a HTTP-header, more safe without the key in the URL +}; + +// The address of the API we fetch data from +const URL = 'https://api.spoonacular.com/recipes/random?number=30'; + +// === Connecting JavaScript variables to HTML elements === + +const randomBtn = document.getElementById("random-recipe"); +const cardOverlay = document.getElementById("card-overlay"); +const cardContent = document.getElementById("card-content"); + +const glutenBtn = document.getElementById("gluten-btn"); +const dairyBtn = document.getElementById("dairy-btn"); +const vegBtn = document.getElementById("veg-btn"); +const allBtn = document.getElementById("filter-btn"); +const descenBtn = document.getElementById("descending-btn"); +const ascenBtn = document.getElementById("ascending-btn"); + +// Get all buttons in the filter section into a single list +const filterButtons = document.querySelectorAll(".filter button") +const sortButtons = document.querySelectorAll(".sort button") + +// An empty list that will act as our local "database" for all recipes +let allRecipes = []; + +// ================================================== +// Functions +// ================================================== + +// A "display engine" that can take any list of recipes and show it on the screen +const displayRecipes = (recipesToShow) => { + + // Finds the container for all the recipe cards + const recipeContainer = document.getElementById("recipe-container"); + + //if (recipesToShow) is flase, undefined - length === 0, checking the list, if it contains any recipes + // || = if anyone of these two things is true - show empty-state-message + if (!recipesToShow || recipesToShow.length === 0) { + recipeContainer.innerHTML = '

Sorry, no recipes match your filter. Please try another one!

'; + return; // terminate the function otherwise it would runt map() with an empty list + } + + // Builds all the cards in memory first and then adds them to the page all at once + const allCardsHTML = recipesToShow.map(recipe => createGridCardHTML(recipe)).join(''); + recipeContainer.innerHTML = allCardsHTML; + +}; + +// A function for building the HTML code for a small recipe card in the grid +const createGridCardHTML = (recipe) => { + const imgUrl = recipe.image; + const title = recipe.title; + const time = recipe.readyInMinutes; + const diets = recipe.diets; + const ingredients = recipe.extendedIngredients || []; // Use an empty list if ingredients are missing + //Go through every ingredient in the list.. + const ingredientsHTML = ingredients.map(ingredient => { + return `
  • ${ingredient.original}
  • `; //..and make an HTML-list
  • for each item + }).join(''); //then paste them together in one string + + //Building the HTML were we paste the variables that was created above ${...} + return ` +
    + Picture of ${title} +
    +

    ${title}

    +

    Cooking Time: ${time} minutes

    +

    Diets: ${diets}

    +

    Ingredients:

    +
      + ${ingredientsHTML} +
    +
    +
    + `; +}; + +// A function for building the HTML for the large, detailed card in the popup window +const createOverlayCardHTML = (recipe) => { + const imgUrl = recipe.image; + const title = recipe.title; + const time = recipe.readyInMinutes; + const diets = recipe.diets; + + const ingredients = recipe.extendedIngredients || [];//the list with ingrediens, if its empty make an empty list to avoid errors + const instructions = recipe.instructions; + //Go through every ingredient in the list.. + const ingredientsHTML = ingredients.map(ingredient => { + return `
  • ${ingredient.original}
  • `; //..and make an HTML-list
  • for each item + }).join(''); //Then paste them together in one string + + //Building the HTML were we paste the variables that was created above ${...} + return ` +
    + Picture of ${title} +

    ${title}

    +

    Cooking Time: ${time} minutes

    +

    Diets: ${diets}

    +

    Ingredients:

    + +

    ${instructions}

    +
    + `; +}; + +// A function that runs one time at the start to handle the incoming data +const processRecipeData = (result) => { + // Checks that we actually received a valid list of recipes + if (result && result.recipes && result.recipes.length > 0) { + // Saves all recipes to our local "database" + allRecipes = result.recipes; + console.log("All Recipes:", allRecipes); + + // Uses our "display engine" to show all recipes from the start + displayRecipes(allRecipes); + + } else { + // If the data was empty or invalid + console.log("Could not process recipe data, result was empty or invalid"); + } +}; + +// Listens for clicks on the "random" button +randomBtn.addEventListener("click", () => { + // First, a safety check to make sure there are recipes in our list + // If the list doesn't exist or is empty... + if (!allRecipes || allRecipes.length === 0) { + // ...log a message to the console... + console.log("No recipes available to choose from."); + // ...and stop the function here + return; + } + + // Selects a random index from our allrecipes list + const randomIndex = Math.floor(Math.random() * allRecipes.length); + const randomRecipe = allRecipes[randomIndex]; + + // Builds the HTML for the popup window with the random recipe + const cardHTML = createOverlayCardHTML(randomRecipe); + cardContent.innerHTML = cardHTML;// Puts the HTML into the white box + cardOverlay.classList.add("visible");// Makes the entire popup window visible +}); + +// Listens for clicks on the dark background to close the popup +cardOverlay.addEventListener("click", (event) => { + // Checks if the clicked element was the background itself, and not the box inside it + if (event.target === cardOverlay) { + cardOverlay.classList.remove("visible");// Hides the popup window. + } +}); + +//activates the class "active", witch removes and ads the colors for the clicked buttons +const updateActiveButton = (clickedButton) => { + // First, remove the "active" class from all sort buttons + filterButtons.forEach(button => { + button.classList.remove("active"); + }); + // Then, add the "active" class to the one that was just clicked + clickedButton.classList.add("active"); +}; + +//activates the class "active", witch removes and ads/removes the colors for the clicked buttons +const updateActiveSortButton = (clickedButton) => { + // First, remove the "active" class from all sort buttons + sortButtons.forEach(button => { + button.classList.remove("active"); + }); + // Then, add the "active" class to the one that was just clicked + clickedButton.classList.add("active"); +}; + +// ================================================== +// Event Listeners for Filtering +// ================================================== + +// Listens for clicks on the "Gluten free" button +glutenBtn.addEventListener("click", () => { + updateActiveButton(glutenBtn); // Set this button as active + console.log("Filtering for 'gluten free'"); + // Creates a new list that only contains recipes where the 'diets' list includes 'gluten free' + const filtered = allRecipes.filter(recipe => recipe.diets.includes('gluten free')); + + // Uses our "display engine" to show the new, filtered list + displayRecipes(filtered); +}); + +// Listens for clicks on the "Dairy free" button +dairyBtn.addEventListener("click", () => { + updateActiveButton(dairyBtn); + console.log("Filtering for 'dairy free'"); + const filtered = allRecipes.filter(recipe => recipe.diets.includes('dairy free')); + displayRecipes(filtered); +}); + +// Listens for clicks on the "Vegetarian" button +vegBtn.addEventListener("click", () => { + updateActiveButton(vegBtn); + console.log("Filtering for 'vegetarian'"); + // Some recipes use 'vegetarian: true' instead of in the 'diets' list, so we check that property + const filtered = allRecipes.filter(recipe => recipe.vegetarian === true); + displayRecipes(filtered); +}); + +// Listens for clicks on the "All" button to reset the filter +allBtn.addEventListener("click", () => { + updateActiveButton(allBtn); + console.log("Showing all recipes again"); + // Calls the "display engine" with the entire, unfiltered allrecipes list + displayRecipes(allRecipes); +}); + +descenBtn.addEventListener("click", () => { + updateActiveSortButton(descenBtn); + console.log("Sorting recipes by descending cooking time"); + // Making a copy of allRecipes list and sorting it + const sortedRecipes = [...allRecipes].sort((a, b) => b.readyInMinutes - a.readyInMinutes); + displayRecipes(sortedRecipes); +}); + +ascenBtn.addEventListener("click", () => { + updateActiveSortButton(ascenBtn); + console.log("Sorting recipes by ascending cooking time"); + // Making a copy of allRecipes list and sorting it + const sortedRecipes = [...allRecipes].sort((a, b) => a.readyInMinutes - b.readyInMinutes); + displayRecipes(sortedRecipes); +}); + +// ================================================== +// Initialization +// This is the code that runs when the page loads +// ================================================== + +// Checks if there are saved recipes in the browser's memory (localStorage) +const savedRecipeJSON = localStorage.getItem("savedRecipe"); + +// IF there are saved recipes... +if (savedRecipeJSON) { + console.log("Fetching recipes from localStorage..."); + const savedRecipe = JSON.parse(savedRecipeJSON); // Converts the text back into an object + processRecipeData(savedRecipe);// Uses the saved + // ELSE (if it's the first visit or the memory has been cleared)... +} else { + console.log("Fetching new recipes from the API..."); // Make a call to the API + fetch(URL, requestOptions) + .then(response => { + // If the response did not go wel ( status 402) + if (!response.ok) { + // If the error is specifically "API limit reached" + if (response.status === 402) { + // Error message + throw new Error('API daily limit reached. Please try again tomorrow.'); + } + // Other errors + throw new Error('Could not fetch recipes from the server.'); + } + // If everyhting went well + return response.json(); // Converts the response to a JavaScript object + }) + .then(result => { + // When we have the data: + // 1. Save it to the browser's memory for next time + localStorage.setItem("savedRecipes", JSON.stringify(result)); + // 2. Use the data to build the page + processRecipeData(result); + }) + .catch(error => { + // Catch the error created above and show it to the user + console.error("error", error); + document.getElementById("recipe-container").innerHTML = `

    ${error.message}

    `; + }); // Catches any errors during the API call +} +}); diff --git a/index.html b/index.html new file mode 100644 index 000000000..c59f767c0 --- /dev/null +++ b/index.html @@ -0,0 +1,71 @@ + + + + + + Recipe Library + + + + +
    + + +
    +

    Recipe Library

    + + +
    + +
    +

    Filter on diets

    + + + + +
    + + +
    +

    Sort on time

    + + +
    + + +
    +

    Random Recipe

    + +
    + +
    +
    + + + +
    + + + +
    + +
    +
    + +
    + + + + + + \ No newline at end of file diff --git a/style.css b/style.css new file mode 100644 index 000000000..1f3ef4dee --- /dev/null +++ b/style.css @@ -0,0 +1,267 @@ +/* ================================================== + 1. Global Styles & Variables + ================================================== */ + +/* :root is a global settings panel for my CSS */ +:root { + /* Colors */ + --clr-primary: rgba(0, 24, 164, 1); + --clr-primary-1: rgba(204, 255, 226, 1); + --clr-secondary: rgba(255, 101, 137, 1); + --clr-secondary-1: rgba(255, 236, 234, 1); + + /* Font */ + --ff-: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + font-weight: 700; +} + +/* '*' selects ALL elements on the page for a basic reset */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +/* Basic styling for the entire page */ +body { + min-height: 100vh; + font-family: var(--ff-); + display: flex; + justify-content: center; + flex-wrap: wrap; + overflow-y: scroll; + /* Always show scrollbar to prevent layout shifts */ +} + +/* ================================================== + 2. Main Layout & Header + ================================================== */ + +.container { + max-width: 1300px; + width: 100%; +} + +header { + margin-left: 2.5rem; + margin-top: 1rem; +} + +h1 { + font-style: bold; + color: var(--clr-primary); +} + +/* ================================================== + 3. Filter & Sort Section + ================================================== */ + +.select-section { + margin-top: 1rem; +} + +.select-section h2 { + margin-top: 0.5rem; + margin-bottom: 0.5rem; + font-size: 1.1rem; +} + +/* General styling for all buttons in the filter/sort section */ +.select-section button { + font-weight: 500; + font-style: medium; + padding: 0.65rem 1rem; + border-radius: 19px; + font-family: var(--ff-); + border: 2px solid transparent; + /* Transparent border prevents jumping on hover */ +} + +.filter{ + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +/* Forces the h2 to take up the whole width */ +.filter h2{ + flex-basis: 100%; +} + +.filter button { + background-color: var(--clr-primary-1); + color: var(--clr-primary); +} + +.sort button { + background-color: var(--clr-secondary-1); + color: var(--clr-primary); +} + +/* States for buttons (hover, active) */ +button:hover { + border: 2px solid var(--clr-primary); +} + +.filter button.active { + background-color: var(--clr-primary); + color: white; +} + +.sort button.active { + background-color: var(--clr-secondary); + color: white; +} + +/* ================================================== + 4. Recipe Grid & Cards + ================================================== */ + +.recipe-container { + display: flex; + justify-content: center; + flex-wrap: wrap; +} + +.recipe-card { + margin-top: 1rem; + width: 300px; + min-height: 621px; + border-radius: 16px; + padding: 1rem; + display: flex; + flex-direction: column; + border: 1px solid rgb(179, 179, 179); +} + +.recipe-card:hover { + transform: translateY(-1px); + box-shadow: 0px 0px 30px 0px rgba(0, 24, 164, 0.2); + border: 2px solid var(--clr-primary); +} + +/* Content inside the recipe cards */ +.recipe-card h2 { + padding-top: 1rem; +} + +.recipe-title { + padding-bottom: 0.5rem; + border-bottom: 1px solid rgba(0, 0, 0, 0.473); +} + +.recipe-time { + padding-bottom: 0.5rem; + padding-top: 0.5rem; +} + +.diet-answer, +.time-answer { + font-weight: 400; +} + +.recipe-card h3, +.overlay-card-content h3 { + border-top: 1px solid rgba(0, 0, 0, 0.527); + padding-top: 0.5rem; + margin-top: 0.5rem; +} + +.recipe-ingredients { + margin-top: 0.5rem; + list-style-type: none; +} + +.recipe-ingredients li { + font-weight: 500; + font-style: medium; + font-size: 1rem; +} + +/* ================================================== + 5. Overlay / Modal + ================================================== */ + +.card-overlay { + display: none; + /* Hidden by default */ + justify-content: center; + align-items: center; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(5px); +} + +.card-overlay.visible { + display: flex; + /* Made visible with JavaScript */ +} + +.card-content { + background-color: rgb(243, 236, 236); + padding: 1.5rem; + border-radius: 10px; + width: 90%; + height: 80%; + overflow-y: auto; + display: flex; + flex-direction: column; + align-items: center; +} + +/* Content inside the overlay */ +.overlay-card-content { + width: 100%; + max-width: 500px; +} + +.overlay-card-content img { + width: 100%; + height: 300px; + object-fit: cover; +} + +.recipe-instructions{ + margin-top: 1rem; +} + +/* ================================================== + 6. Responsive Styles (Media Queries) + ================================================== */ + +@media (min-width: 600px) { + + header { + margin-left: 1.9rem; + } + + h1 { + font-size: 54px; + margin: 0; + } + + .select-section { + display: flex; + gap: 1rem; + margin-left: 0.2rem; + align-items: flex-end; + /* Aligns filter/sort/random sections along the bottom */ + } + + .filter{ + flex-basis: 390px; + flex-grow: 0; + } + + .recipe-container { + gap: 0.8rem; + } + + .card-content { + width: 800px; + } +} \ No newline at end of file