Project shows: The JavaScript is broken down into many *View.js files, a model.js, a controller.js, a config.js(constants) and a helper.js (timeout and getJSON functions).

The html file ‘index.html’ is altered by the View class’s render(data) and update(data) functions. These functions are called through the ‘controller.js’ event handler functions discussed below.

The model.js holds and exports the main ‘state’ object which includes the current recipe selected from the search results. The model functions are loadSearchResults(query) (async), loadRecipe(id) (async), getSearchResultsPage(page = state.search.page) and updateServings(newServings).

The controller.js has an init() function which attaches event handlers to the relavent buttons used to search and display recipe data from the external API. The event handlers are controlRecipes (async), controlSearchResults (async), controlPagination(goToPage) and controlServings(newServings).

When the user searches for a new recipe, the controller calls the handler attached to the search button, which calls down to the appropriate *View.js file to update() and/or render() using the searches new model.state.search/recipe data. The render/update functions call the appropriate ‘*View.js’ file _generateMarkup() which constructs and returns new html.

To review, the controller creates event handler functions which are attached and called by button clicks. The event handlers pass model data (external API/ user input) to *View classes, which use the data to generate new html through their render/update functions.

Class objects may contain other objects, and/or arrays, query strings, recipe.data, etc.

<!DOCTYPE html>
<html lang=“en”>
  <head>
    <meta charset=“UTF-8” />
    <meta name=“viewport” content=“width=device-width, initial-scale=1.0” />
    <meta http-equiv=“X-UA-Compatible” content=“ie=edge” />
    <link
      href=“https://fonts.googleapis.com/css?family=Nunito+Sans:400,600,700”
      rel=“stylesheet”
    />
    <link rel=“shortcut icon” href=“src/img/favicon.png” type=“image/x-icon” />

    <link rel=“stylesheet” href=“src/sass/main.scss” />
    <script defer type=“module” src=“src/js/controller.js”></script>

    <title>forkify // Search over 1,000,000 recipes</title>
  </head>

  <body>
    <div class=“container”>
      <header class=“header”>
        <img src=“src/img/logo.png” alt=“Logo” class=“header__logo” />
        <form class=“search”>
          <input
            type=“text”
            class=“search__field”
            placeholder=“Search over 1,000,000 recipes…”
          />
          <button class=“btn search__btn”>
            <svg class=“search__icon”>
              <use href=“src/img/icons.svg#icon-search”></use>
            </svg>
            <span>Search</span>
          </button>
        </form>

        <nav class=“nav”>
          <ul class=“nav__list”>
            <li class=“nav__item”>
              <button class=“nav__btn nav__btn–add-recipe”>
                <svg class=“nav__icon”>
                  <use href=“src/img/icons.svg#icon-edit”></use>
                </svg>
                <span>Add recipe</span>
              </button>
            </li>
            <li class=“nav__item”>
              <button class=“nav__btn nav__btn–bookmarks”>
                <svg class=“nav__icon”>
                  <use href=“src/img/icons.svg#icon-bookmark”></use>
                </svg>
                <span>Bookmarks</span>
              </button>
              <div class=“bookmarks”>
                <ul class=“bookmarks__list”>
                  <div class=“message”>
                    <div>
                      <svg>
                        <use href=“src/img/icons.svg#icon-smile”></use>
                      </svg>
                    </div>
                    <p>
                      No bookmarks yet. Find a nice recipe and bookmark it 🙂
                    </p>
                  </div>

                  <!– <li class=”preview”>
                    <a class=”preview__link” href=”#23456″>
                      <figure class=”preview__fig”>
                        <img src=”src/img/test-1.jpg” alt=”Test” />
                      </figure>
                      <div class=”preview__data”>
                        <h4 class=”preview__name”>
                          Pasta with Tomato Cream …
                        </h4>
                        <p class=”preview__author”>The Pioneer Woman</p>
                      </div>
                    </a>
                  </li> –>
                </ul>
              </div>
            </li>
          </ul>
        </nav>
      </header>

      <div class=“search-results”>
        <ul class=“results”>
          <!–
          <li class=”preview”>
            <a class=”preview__link preview__link–active” href=”#23456″>
              <figure class=”preview__fig”>
                <img src=”src/img/test-1.jpg” alt=”Test” />
              </figure>
              <div class=”preview__data”>
                <h4 class=”preview__title”>Pasta with Tomato Cream …</h4>
                <p class=”preview__publisher”>The Pioneer Woman</p>
                <div class=”preview__user-generated”>
                  <svg>
                    <use href=”src/img/icons.svg#icon-user”></use>
                  </svg>
                </div>
              </div>
            </a>
          </li>
           –>
        </ul>

        <div class=“pagination”>
          <!– <button class=”btn–inline pagination__btn–prev”>
            <svg class=”search__icon”>
              <use href=”src/img/icons.svg#icon-arrow-left”></use>
            </svg>
            <span>Page 1</span>
          </button>
          <button class=”btn–inline pagination__btn–next”>
            <span>Page 3</span>
            <svg class=”search__icon”>
              <use href=”src/img/icons.svg#icon-arrow-right”></use>
            </svg>
          </button> –>
        </div>

        <p class=“copyright”>
          &copy; Copyright by
          <a
            class=“twitter-link”
            target=“_blank”
            href=“https://twitter.com/jonasschmedtman”
            >Jonas Schmedtmann</a
          >. Use for learning or your portfolio. Don’t use to teach. Don’t claim
          as your own.
        </p>
      </div>

      <div class=“recipe”>
        <div class=“message”>
          <div>
            <svg>
              <use href=“src/img/icons.svg#icon-smile”></use>
            </svg>
          </div>
          <p>Start by searching for a recipe or an ingredient. Have fun!</p>
        </div>

        <!– <div class=”spinner”>
          <svg>
            <use href=”src/img/icons.svg#icon-loader”></use>
          </svg>
        </div> –>

        <!– <div class=”error”>
            <div>
              <svg>
                <use href=”src/img/icons.svg#icon-alert-triangle”></use>
              </svg>
            </div>
            <p>No recipes found for your query. Please try again!</p>
          </div> –>

        <!–
        <figure class=”recipe__fig”>
          <img src=”src/img/test-1.jpg” alt=”Tomato” class=”recipe__img” />
          <h1 class=”recipe__title”>
            <span>Pasta with tomato cream sauce</span>
          </h1>
        </figure>

        <div class=”recipe__details”>
          <div class=”recipe__info”>
            <svg class=”recipe__info-icon”>
              <use href=”src/img/icons.svg#icon-clock”></use>
            </svg>
            <span class=”recipe__info-data recipe__info-data–minutes”>45</span>
            <span class=”recipe__info-text”>minutes</span>
          </div>
          <div class=”recipe__info”>
            <svg class=”recipe__info-icon”>
              <use href=”src/img/icons.svg#icon-users”></use>
            </svg>
            <span class=”recipe__info-data recipe__info-data–people”>4</span>
            <span class=”recipe__info-text”>servings</span>

            <div class=”recipe__info-buttons”>
              <button class=”btn–tiny btn–increase-servings”>
                <svg>
                  <use href=”src/img/icons.svg#icon-minus-circle”></use>
                </svg>
              </button>
              <button class=”btn–tiny btn–increase-servings”>
                <svg>
                  <use href=”src/img/icons.svg#icon-plus-circle”></use>
                </svg>
              </button>
            </div>
          </div>

          <div class=”recipe__user-generated”>
            <svg>
              <use href=”src/img/icons.svg#icon-user”></use>
            </svg>
          </div>
          <button class=”btn–round”>
            <svg class=””>
              <use href=”src/img/icons.svg#icon-bookmark-fill”></use>
            </svg>
          </button>
        </div>

        <div class=”recipe__ingredients”>
          <h2 class=”heading–2″>Recipe ingredients</h2>
          <ul class=”recipe__ingredient-list”>
            <li class=”recipe__ingredient”>
              <svg class=”recipe__icon”>
                <use href=”src/img/icons.svg#icon-check”></use>
              </svg>
              <div class=”recipe__quantity”>1000</div>
              <div class=”recipe__description”>
                <span class=”recipe__unit”>g</span>
                pasta
              </div>
            </li>

            <li class=”recipe__ingredient”>
              <svg class=”recipe__icon”>
                <use href=”src/img/icons.svg#icon-check”></use>
              </svg>
              <div class=”recipe__quantity”>0.5</div>
              <div class=”recipe__description”>
                <span class=”recipe__unit”>cup</span>
                ricotta cheese
              </div>
            </li>
          </ul>
        </div>

        <div class=”recipe__directions”>
          <h2 class=”heading–2″>How to cook it</h2>
          <p class=”recipe__directions-text”>
            This recipe was carefully designed and tested by
            <span class=”recipe__publisher”>The Pioneer Woman</span>. Please check out
            directions at their website.
          </p>
          <a
            class=”btn–small recipe__btn”
            href=”http://thepioneerwoman.com/cooking/pasta-with-tomato-cream-sauce/”
            target=”_blank”
          >
            <span>Directions</span>
            <svg class=”search__icon”>
              <use href=”src/img/icons.svg#icon-arrow-right”></use>
            </svg>
          </a>
        </div>
        –>
      </div>
    </div>

    <div class=“overlay hidden”></div>
    <div class=“add-recipe-window hidden”>
      <button class=“btn–close-modal”>&times;</button>
      <form class=“upload”>
        <div class=“upload__column”>
          <h3 class=“upload__heading”>Recipe data</h3>
          <label>Title</label>
          <input value=“TEST23” required name=“title” type=“text” />
          <label>URL</label>
          <input value=“TEST23” required name=“sourceUrl” type=“text” />
          <label>Image URL</label>
          <input value=“TEST23” required name=“image” type=“text” />
          <label>Publisher</label>
          <input value=“TEST23” required name=“publisher” type=“text” />
          <label>Prep time</label>
          <input value=“23” required name=“cookingTime” type=“number” />
          <label>Servings</label>
          <input value=“23” required name=“servings” type=“number” />
        </div>

        <div class=“upload__column”>
          <h3 class=“upload__heading”>Ingredients</h3>
          <label>Ingredient 1</label>
          <input
            value=“0.5,kg,Rice”
            type=“text”
            required
            name=“ingredient-1”
            placeholder=“Format: ‘Quantity,Unit,Description'”
          />
          <label>Ingredient 2</label>
          <input
            value=“1,,Avocado”
            type=“text”
            name=“ingredient-2”
            placeholder=“Format: ‘Quantity,Unit,Description'”
          />
          <label>Ingredient 3</label>
          <input
            value=“,,salt”
            type=“text”
            name=“ingredient-3”
            placeholder=“Format: ‘Quantity,Unit,Description'”
          />
          <label>Ingredient 4</label>
          <input
            type=“text”
            name=“ingredient-4”
            placeholder=“Format: ‘Quantity,Unit,Description'”
          />
          <label>Ingredient 5</label>
          <input
            type=“text”
            name=“ingredient-5”
            placeholder=“Format: ‘Quantity,Unit,Description'”
          />
          <label>Ingredient 6</label>
          <input
            type=“text”
            name=“ingredient-6”
            placeholder=“Format: ‘Quantity,Unit,Description'”
          />
        </div>

        <button class=“btn upload__btn”>
          <svg>
            <use href=“src/img/icons.svg#icon-upload-cloud”></use>
          </svg>
          <span>Upload</span>
        </button>
      </form>
    </div>
  </body>
</html>
* {
  margin: 0;
  padding: 0;
}

*, :before, :after {
  box-sizing: inherit;
}

html {
  box-sizing: border-box;
  font-size: 62.5%;
}

@media only screen and (width <= 61.25em) {
  html {
    font-size: 50%;
  }
}

body {
  color: #615551;
  background-image: linear-gradient(to bottom right, #fbdb89, #f48982);
  background-repeat: no-repeat;
  background-size: cover;
  min-height: calc(100vh 8vw);
  font-family: Nunito Sans, sans-serif;
  font-weight: 400;
  line-height: 1.6;
}

.container {
  background-color: #fff;
  border-radius: 9px;
  grid-template: “head head” 10rem
                 “list recipe” minmax(100rem, auto)
                 / 1fr 2fr;
  max-width: 120rem;
  min-height: 117rem;
  margin: 4vw auto;
  display: grid;
  overflow: hidden;
  box-shadow: 0 2rem 6rem .5rem #61555133;
}

@media only screen and (width <= 78.15em) {
  .container {
    border-radius: 0;
    max-width: 100%;
    margin: 0;
  }
}

.btn–small, .btn–small:link, .btn–small:visited, .btn {
  text-transform: uppercase;
  color: #fff;
  cursor: pointer;
  background-image: linear-gradient(to bottom right, #fbdb89, #f48982);
  border: none;
  border-radius: 10rem;
  align-items: center;
  transition: all .2s;
  display: flex;
}

.btn–small:hover, .btn:hover {
  transform: scale(1.05);
}

.btn–small:focus, .btn:focus {
  outline: none;
}

.btn–small > :first-child, .btn > :first-child {
  margin-right: 1rem;
}

.btn {
  padding: 1.5rem 4rem;
  font-size: 1.5rem;
  font-weight: 600;
}

.btn svg {
  fill: currentColor;
  width: 2.25rem;
  height: 2.25rem;
}

.btn–small, .btn–small:link, .btn–small:visited {
  padding: 1.25rem 2.25rem;
  font-size: 1.4rem;
  font-weight: 600;
  text-decoration: none;
}

.btn–small svg, .btn–small:link svg, .btn–small:visited svg {
  fill: currentColor;
  width: 1.75rem;
  height: 1.75rem;
}

.btn–inline {
  color: #f38e82;
  cursor: pointer;
  background-color: #f9f5f3;
  border: none;
  border-radius: 10rem;
  align-items: center;
  padding: .8rem 1.2rem;
  font-size: 1.3rem;
  font-weight: 600;
  transition: all .2s;
  display: flex;
}

.btn–inline svg {
  fill: currentColor;
  width: 1.6rem;
  height: 1.6rem;
  margin: 0 .2rem;
}

.btn–inline span {
  margin: 0 .4rem;
}

.btn–inline:hover {
  color: #f48982;
  background-color: #f2efee;
}

.btn–inline:focus {
  outline: none;
}

.btn–round {
  cursor: pointer;
  background-image: linear-gradient(to bottom right, #fbdb89, #f48982);
  border: none;
  border-radius: 50%;
  justify-content: center;
  align-items: center;
  width: 4.5rem;
  height: 4.5rem;
  transition: all .2s;
  display: flex;
}

.btn–round:hover {
  transform: scale(1.07);
}

.btn–round:focus {
  outline: none;
}

.btn–round svg {
  fill: #fff;
  width: 2.5rem;
  height: 2.5rem;
}

.btn–tiny {
  cursor: pointer;
  background: none;
  border: none;
  width: 2rem;
  height: 2rem;
}

.btn–tiny svg {
  fill: #f38e82;
  width: 100%;
  height: 100%;
  transition: all .3s;
}

.btn–tiny:focus {
  outline: none;
}

.btn–tiny:hover svg {
  fill: #f48982;
  transform: translateY(-1px);
}

.btn–tiny:active svg {
  fill: #f48982;
  transform: translateY(0);
}

.btn–tiny:not(:last-child) {
  margin-right: .3rem;
}

.heading–2 {
  color: #f38e82;
  text-transform: uppercase;
  text-align: center;
  margin-bottom: 2.5rem;
  font-size: 2rem;
  font-weight: 700;
}

.link:link, .link:visited {
  color: #918581;
}

.spinner {
  text-align: center;
  margin: 5rem auto;
}

.spinner svg {
  fill: #f38e82;
  width: 6rem;
  height: 6rem;
  animation: 2s linear infinite rotate;
}

@keyframes rotate {
  0% {
    transform: rotate(0);
  }

  100% {
    transform: rotate(360deg);
  }
}

.message, .error {
  max-width: 40rem;
  margin: 0 auto;
  padding: 5rem 4rem;
  display: flex;
}

.message svg, .error svg {
  fill: #f38e82;
  width: 3rem;
  height: 3rem;
  transform: translateY(-.3rem);
}

.message p, .error p {
  margin-left: 1.5rem;
  font-size: 1.8rem;
  font-weight: 600;
  line-height: 1.5;
}

.header {
  background-color: #f9f5f3;
  grid-area: head;
  justify-content: space-between;
  align-items: center;
  display: flex;
}

.header__logo {
  height: 4.6rem;
  margin-left: 4rem;
  display: block;
}

.search {
  background-color: #fff;
  border-radius: 10rem;
  align-items: center;
  padding-left: 3rem;
  transition: all .3s;
  display: flex;
}

.search:focus-within {
  transform: translateY(-2px);
  box-shadow: 0 .7rem 3rem #61555114;
}

.search__field {
  color: inherit;
  background: none;
  border: none;
  width: 30rem;
  font-family: inherit;
  font-size: 1.7rem;
}

.search__field:focus {
  outline: none;
}

.search__field::placeholder {
  color: #d3c7c3;
}

@media only screen and (width <= 61.25em) {
  .search__field {
    width: auto;
  }

  .search__field::placeholder {
    color: #fff;
  }
}

.search__btn {
  font-family: inherit;
  font-weight: 600;
}

.nav {
  align-self: stretch;
  margin-right: 2.5rem;
}

.nav__list {
  height: 100%;
  list-style: none;
  display: flex;
}

.nav__item {
  position: relative;
}

.nav__btn {
  color: inherit;
  text-transform: uppercase;
  cursor: pointer;
  background: none;
  border: none;
  align-items: center;
  height: 100%;
  padding: 0 1.5rem;
  font-family: inherit;
  font-size: 1.4rem;
  font-weight: 700;
  transition: all .3s;
  display: flex;
}

.nav__btn svg {
  fill: #f38e82;
  width: 2.4rem;
  height: 2.4rem;
  margin-right: .7rem;
  transform: translateY(-1px);
}

.nav__btn:focus {
  outline: none;
}

.nav__btn:hover {
  background-color: #f2efee;
}

.bookmarks {
  z-index: 10;
  visibility: hidden;
  opacity: 0;
  background-color: #fff;
  width: 40rem;
  padding: 1rem 0;
  transition: all .5s .2s;
  position: absolute;
  right: -2.5rem;
  box-shadow: 0 .8rem 5rem 2rem #6155511a;
}

.bookmarks__list {
  list-style: none;
}

.bookmarks__field {
  cursor: pointer;
  align-items: center;
  height: 100%;
  padding: 0 4rem;
  transition: all .3s;
  display: flex;
}

.bookmarks__field:hover {
  background-color: #f2efee;
}

.bookmarks:hover, .nav__btn–bookmarks:hover + .bookmarks {
  visibility: visible;
  opacity: 1;
}

.preview__link:link, .preview__link:visited {
  border-right: 1px solid #fff;
  align-items: center;
  padding: 1.5rem 3.25rem;
  text-decoration: none;
  transition: all .3s;
  display: flex;
}

.preview__link:hover {
  background-color: #f9f5f3;
  transform: translateY(-2px);
}

.preview__link–active {
  background-color: #f9f5f3;
}

.preview__fig {
  backface-visibility: hidden;
  border-radius: 50%;
  flex: 0 0 5.8rem;
  height: 5.8rem;
  margin-right: 2rem;
  position: relative;
  overflow: hidden;
}

.preview__fig:before {
  content: “”;
  opacity: .4;
  background-image: linear-gradient(to bottom right, #fbdb89, #f48982);
  width: 100%;
  height: 100%;
  display: block;
  position: absolute;
  top: 0;
  left: 0;
}

.preview__fig img {
  object-fit: cover;
  width: 100%;
  height: 100%;
  transition: all .3s;
  display: block;
}

.preview__data {
  grid-template-columns: 1fr 2rem;
  align-items: center;
  row-gap: .1rem;
  width: 100%;
  display: grid;
}

.preview__title {
  color: #f38e82;
  text-transform: uppercase;
  text-overflow: ellipsis;
  white-space: nowrap;
  grid-column: 1 / -1;
  max-width: 25rem;
  font-size: 1.45rem;
  font-weight: 600;
  overflow: hidden;
}

.preview__publisher {
  color: #918581;
  text-transform: uppercase;
  font-size: 1.15rem;
  font-weight: 600;
}

.preview__user-generated {
  background-color: #eeeae8;
  border-radius: 10rem;
  justify-content: center;
  align-items: center;
  width: 2rem;
  height: 2rem;
  margin-left: auto;
  margin-right: 1.75rem;
  display: flex;
}

.preview__user-generated svg {
  fill: #f38e82;
  width: 1.2rem;
  height: 1.2rem;
}

.search-results {
  flex-direction: column;
  padding: 3rem 0;
  display: flex;
}

.results {
  margin-bottom: 2rem;
  list-style: none;
}

.pagination {
  margin-top: auto;
  padding: 0 3.5rem;
}

.pagination:after {
  content: “”;
  clear: both;
  display: table;
}

.pagination__btn–prev {
  float: left;
}

.pagination__btn–next {
  float: right;
}

.copyright {
  color: #918581;
  margin-top: 4rem;
  padding: 0 3.5rem;
  font-size: 1.2rem;
}

.copyright .twitter-link:link, .copyright .twitter-link:visited {
  color: #918581;
}

.recipe {
  background-color: #f9f5f3;
}

.recipe__fig {
  transform-origin: top;
  height: 32rem;
  position: relative;
}

.recipe__fig:before {
  content: “”;
  opacity: .6;
  background-image: linear-gradient(to bottom right, #fbdb89, #f48982);
  width: 100%;
  height: 100%;
  display: block;
  position: absolute;
  top: 0;
  left: 0;
}

.recipe__img {
  object-fit: cover;
  width: 100%;
  height: 100%;
  display: block;
}

.recipe__title {
  color: #fff;
  text-transform: uppercase;
  text-align: center;
  width: 50%;
  font-size: 3.25rem;
  font-weight: 700;
  line-height: 1.95;
  position: absolute;
  bottom: 0;
  left: 50%;
  transform: translate(-50%, 20%)skewY(-6deg);
}

.recipe__title span {
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
  background-image: linear-gradient(to bottom right, #fbdb89, #f48982);
  padding: 1.3rem 2rem;
}

@media only screen and (width <= 61.25em) {
  .recipe__title {
    width: 70%;
  }
}

.recipe__details {
  align-items: center;
  padding: 7.5rem 8rem 3.5rem;
  display: flex;
}

.recipe__info {
  text-transform: uppercase;
  align-items: center;
  font-size: 1.65rem;
  display: flex;
}

.recipe__info:not(:last-child) {
  margin-right: 4.5rem;
}

.recipe__info-icon {
  fill: #f38e82;
  width: 2.35rem;
  height: 2.35rem;
  margin-right: 1.15rem;
}

.recipe__info-data {
  margin-right: .5rem;
  font-weight: 700;
}

.recipe__info-buttons {
  margin-left: 1.6rem;
  display: flex;
  transform: translateY(-1px);
}

.recipe__user-generated {
  background-color: #eeeae8;
  border-radius: 10rem;
  justify-content: center;
  align-items: center;
  width: 4rem;
  height: 4rem;
  margin-left: auto;
  margin-right: 1.75rem;
  display: flex;
}

.recipe__user-generated svg {
  fill: #f38e82;
  width: 2.25rem;
  height: 2.25rem;
}

.recipe__ingredients {
  background-color: #f2efee;
  flex-direction: column;
  align-items: center;
  padding: 5rem 8rem;
  font-size: 1.6rem;
  line-height: 1.4;
  display: flex;
}

.recipe__ingredient-list {
  grid-template-columns: 1fr 1fr;
  gap: 2.5rem 3rem;
  list-style: none;
  display: grid;
}

.recipe__ingredient {
  display: flex;
}

.recipe__icon {
  fill: #f38e82;
  flex: none;
  width: 2rem;
  height: 2rem;
  margin-top: .1rem;
  margin-right: 1.1rem;
}

.recipe__quantity {
  flex: none;
  margin-right: .5rem;
}

.recipe__directions {
  flex-direction: column;
  align-items: center;
  padding: 5rem 10rem;
  display: flex;
}

.recipe__directions-text {
  text-align: center;
  color: #918581;
  margin-bottom: 3.5rem;
  font-size: 1.7rem;
}

.recipe__publisher {
  font-weight: 700;
}

.add-recipe-window {
  z-index: 1000;
  background-color: #fff;
  border-radius: 9px;
  width: 100rem;
  padding: 5rem 6rem;
  transition: all .5s;
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  box-shadow: 0 4rem 6rem #00000040;
}

.add-recipe-window .btn–close-modal {
  color: inherit;
  cursor: pointer;
  background: none;
  border: none;
  font-family: inherit;
  font-size: 3.5rem;
  position: absolute;
  top: .5rem;
  right: 1.6rem;
}

.overlay {
  backdrop-filter: blur(4px);
  z-index: 100;
  background-color: #0006;
  width: 100%;
  height: 100%;
  transition: all .5s;
  position: fixed;
  top: 0;
  left: 0;
}

.hidden {
  visibility: hidden;
  opacity: 0;
}

.upload {
  grid-template-columns: 1fr 1fr;
  gap: 4rem 6rem;
  display: grid;
}

.upload__column {
  grid-template-columns: 1fr 2.8fr;
  align-items: center;
  gap: 1.5rem;
  display: grid;
}

.upload__column label {
  color: inherit;
  font-size: 1.5rem;
  font-weight: 600;
}

.upload__column input {
  border: 1px solid #ddd;
  border-radius: .5rem;
  padding: .8rem 1rem;
  font-size: 1.5rem;
  transition: all .2s;
}

.upload__column input::placeholder {
  color: #d3c7c3;
}

.upload__column input:focus {
  background-color: #f9f5f3;
  border: 1px solid #f38e82;
  outline: none;
}

.upload__column button {
  grid-column: 1 / span 2;
  justify-self: center;
  margin-top: 1rem;
}

.upload__heading {
  text-transform: uppercase;
  grid-column: 1 / -1;
  margin-bottom: 1rem;
  font-size: 2.25rem;
  font-weight: 700;
}

.upload__btn {
  grid-column: 1 / -1;
  justify-self: center;
}
/*# sourceMappingURL=index.f7626f62.css.map */
export const API_URL = ‘https://forkify-api.herokuapp.com/api/v2/recipes/’;
export const TIMEOUT_SEC = 10;
export const RES_PER_PAGE = 10;
export const KEY = ‘<YOUR_KEY>’;
export const MODAL_CLOSE_SEC = 2.5;
import * as model from ‘./model.js’;
import { MODAL_CLOSE_SEC } from ‘./config.js’;
import recipeView from ‘./views/recipeView.js’;
import searchView from ‘./views/searchView.js’;
import resultsView from ‘./views/resultsView.js’;
import paginationView from ‘./views/paginationView.js’;
import bookmarksView from ‘./views/bookmarksView.js’;
import addRecipeView from ‘./views/addRecipeView.js’;

import ‘core-js/stable’;
import ‘regenerator-runtime/runtime’;
import { async } from ‘regenerator-runtime’;

const controlRecipes = async function () {
  try {
    const id = window.location.hash.slice(1);

    if (!id) return;
    recipeView.renderSpinner();

    // 0) Update results view to mark selected search result
    resultsView.update(model.getSearchResultsPage());

    // 1) Updating bookmarks view
    bookmarksView.update(model.state.bookmarks);

    // 2) Loading recipe
    await model.loadRecipe(id);

    // 3) Rendering recipe
    recipeView.render(model.state.recipe);
  } catch (err) {
    recipeView.renderError();
    console.error(err);
  }
};

const controlSearchResults = async function () {
  try {
    resultsView.renderSpinner();

    // 1) Get search query
    const query = searchView.getQuery();
    if (!query) return;

    // 2) Load search results
    await model.loadSearchResults(query);

    // 3) Render results
    resultsView.render(model.getSearchResultsPage());

    // 4) Render initial pagination buttons
    paginationView.render(model.state.search);
  } catch (err) {
    console.log(err);
  }
};

const controlPagination = function (goToPage) {
  // 1) Render NEW results
  resultsView.render(model.getSearchResultsPage(goToPage));

  // 2) Render NEW pagination buttons
  paginationView.render(model.state.search);
};

const controlServings = function (newServings) {
  // Update the recipe servings (in state)
  model.updateServings(newServings);

  // Update the recipe view
  recipeView.update(model.state.recipe);
};

const controlAddBookmark = function () {
  // 1) Add/remove bookmark
  if (!model.state.recipe.bookmarked) model.addBookmark(model.state.recipe);
  else model.deleteBookmark(model.state.recipe.id);

  // 2) Update recipe view
  recipeView.update(model.state.recipe);

  // 3) Render bookmarks
  bookmarksView.render(model.state.bookmarks);
};

const controlBookmarks = function () {
  bookmarksView.render(model.state.bookmarks);
};

const controlAddRecipe = async function (newRecipe) {
  try {
    // Show loading spinner
    addRecipeView.renderSpinner();

    // Upload the new recipe data
    await model.uploadRecipe(newRecipe);
    console.log(model.state.recipe);

    // Render recipe
    recipeView.render(model.state.recipe);

    // Success message
    addRecipeView.renderMessage();

    // Render bookmark view
    bookmarksView.render(model.state.bookmarks);

    // Change ID in URL
    window.history.pushState(null, , `#${model.state.recipe.id}`);

    // Close form window
    setTimeout(function () {
      addRecipeView.toggleWindow();
    }, MODAL_CLOSE_SEC * 1000);
  } catch (err) {
    console.error(‘💥’, err);
    addRecipeView.renderError(err.message);
  }
};

const init = function () {
  bookmarksView.addHandlerRender(controlBookmarks);
  recipeView.addHandlerRender(controlRecipes);
  recipeView.addHandlerUpdateServings(controlServings);
  recipeView.addHandlerAddBookmark(controlAddBookmark);
  searchView.addHandlerSearch(controlSearchResults);
  paginationView.addHandlerClick(controlPagination);
  addRecipeView.addHandlerUpload(controlAddRecipe);
};

init();
import { async } from ‘regenerator-runtime’;
import { TIMEOUT_SEC } from ‘./config.js’;

const timeout = function (s) {
  return new Promise(function (_, reject) {
    setTimeout(function () {
      reject(new Error(`Request took too long! Timeout after ${s} second`));
    }, s * 1000);
  });
};

export const AJAX = async function (url, uploadData = undefined) {
  try {
    const fetchPro = uploadData
      ? fetch(url, {
          method: ‘POST’,
          headers: {
            ‘Content-Type’: ‘application/json’,
          },
          body: JSON.stringify(uploadData),
        })
      : fetch(url);

    const res = await Promise.race([fetchPro, timeout(TIMEOUT_SEC)]);
    const data = await res.json();

    if (!res.ok) throw new Error(`${data.message} (${res.status})`);
    return data;
  } catch (err) {
    throw err;
  }
};

/*
export const getJSON = async function (url) {
  try {
    const fetchPro = fetch(url);
    const res = await Promise.race([fetchPro, timeout(TIMEOUT_SEC)]);
    const data = await res.json();

    if (!res.ok) throw new Error(`${data.message} (${res.status})`);
    return data;
  } catch (err) {
    throw err;
  }
};

export const sendJSON = async function (url, uploadData) {
  try {
    const fetchPro = fetch(url, {
      method: ‘POST’,
      headers: {
        ‘Content-Type’: ‘application/json’,
      },
      body: JSON.stringify(uploadData),
    });

    const res = await Promise.race([fetchPro, timeout(TIMEOUT_SEC)]);
    const data = await res.json();

    if (!res.ok) throw new Error(`${data.message} (${res.status})`);
    return data;
  } catch (err) {
    throw err;
  }
};
*/
import { async } from ‘regenerator-runtime’;
import { API_URL, RES_PER_PAGE, KEY } from ‘./config.js’;
// import { getJSON, sendJSON } from ‘./helpers.js’;
import { AJAX } from ‘./helpers.js’;

export const state = {
  recipe: {},
  search: {
    query: ,
    results: [],
    page: 1,
    resultsPerPage: RES_PER_PAGE,
  },
  bookmarks: [],
};

const createRecipeObject = function (data) {
  const { recipe } = data.data;
  return {
    id: recipe.id,
    title: recipe.title,
    publisher: recipe.publisher,
    sourceUrl: recipe.source_url,
    image: recipe.image_url,
    servings: recipe.servings,
    cookingTime: recipe.cooking_time,
    ingredients: recipe.ingredients,
    (recipe.key && { key: recipe.key }),
  };
};

export const loadRecipe = async function (id) {
  try {
    const data = await AJAX(`${API_URL}${id}?key=${KEY}`);
    state.recipe = createRecipeObject(data);

    if (state.bookmarks.some(bookmark => bookmark.id === id))
      state.recipe.bookmarked = true;
    else state.recipe.bookmarked = false;

    console.log(state.recipe);
  } catch (err) {
    // Temp error handling
    console.error(`${err} 💥💥💥💥`);
    throw err;
  }
};

export const loadSearchResults = async function (query) {
  try {
    state.search.query = query;

    const data = await AJAX(`${API_URL}?search=${query}&key=${KEY}`);
    console.log(data);

    state.search.results = data.data.recipes.map(rec => {
      return {
        id: rec.id,
        title: rec.title,
        publisher: rec.publisher,
        image: rec.image_url,
        (rec.key && { key: rec.key }),
      };
    });
    state.search.page = 1;
  } catch (err) {
    console.error(`${err} 💥💥💥💥`);
    throw err;
  }
};

export const getSearchResultsPage = function (page = state.search.page) {
  state.search.page = page;

  const start = (page 1) * state.search.resultsPerPage; // 0
  const end = page * state.search.resultsPerPage; // 9

  return state.search.results.slice(start, end);
};

export const updateServings = function (newServings) {
  state.recipe.ingredients.forEach(ing => {
    ing.quantity = (ing.quantity * newServings) / state.recipe.servings;
    // newQt = oldQt * newServings / oldServings // 2 * 8 / 4 = 4
  });

  state.recipe.servings = newServings;
};

const persistBookmarks = function () {
  localStorage.setItem(‘bookmarks’, JSON.stringify(state.bookmarks));
};

export const addBookmark = function (recipe) {
  // Add bookmark
  state.bookmarks.push(recipe);

  // Mark current recipe as bookmarked
  if (recipe.id === state.recipe.id) state.recipe.bookmarked = true;

  persistBookmarks();
};

export const deleteBookmark = function (id) {
  // Delete bookmark
  const index = state.bookmarks.findIndex(el => el.id === id);
  state.bookmarks.splice(index, 1);

  // Mark current recipe as NOT bookmarked
  if (id === state.recipe.id) state.recipe.bookmarked = false;

  persistBookmarks();
};

const init = function () {
  const storage = localStorage.getItem(‘bookmarks’);
  if (storage) state.bookmarks = JSON.parse(storage);
};
init();

const clearBookmarks = function () {
  localStorage.clear(‘bookmarks’);
};
// clearBookmarks();

export const uploadRecipe = async function (newRecipe) {
  try {
    const ingredients = Object.entries(newRecipe)
      .filter(entry => entry[0].startsWith(‘ingredient’) && entry[1] !== )
      .map(ing => {
        const ingArr = ing[1].split(‘,’).map(el => el.trim());
        // const ingArr = ing[1].replaceAll(‘ ‘, ”).split(‘,’);
        if (ingArr.length !== 3)
          throw new Error(
            ‘Wrong ingredient fromat! Please use the correct format :)’
          );

        const [quantity, unit, description] = ingArr;

        return { quantity: quantity ? +quantity : null, unit, description };
      });

    const recipe = {
      title: newRecipe.title,
      source_url: newRecipe.sourceUrl,
      image_url: newRecipe.image,
      publisher: newRecipe.publisher,
      cooking_time: +newRecipe.cookingTime,
      servings: +newRecipe.servings,
      ingredients,
    };

    const data = await AJAX(`${API_URL}?key=${KEY}`, recipe);
    state.recipe = createRecipeObject(data);
    addBookmark(state.recipe);
  } catch (err) {
    throw err;
  }
};
import View from ‘./View.js’;
import icons from ‘url:../../img/icons.svg’; // Parcel 2

class AddRecipeView extends View {
  _parentElement = document.querySelector(‘.upload’);
  _message = ‘Recipe was successfully uploaded :)’;

  _window = document.querySelector(‘.add-recipe-window’);
  _overlay = document.querySelector(‘.overlay’);
  _btnOpen = document.querySelector(‘.nav__btn–add-recipe’);
  _btnClose = document.querySelector(‘.btn–close-modal’);

  constructor() {
    super();
    this._addHandlerShowWindow();
    this._addHandlerHideWindow();
  }

  toggleWindow() {
    this._overlay.classList.toggle(‘hidden’);
    this._window.classList.toggle(‘hidden’);
  }

  _addHandlerShowWindow() {
    this._btnOpen.addEventListener(‘click’, this.toggleWindow.bind(this));
  }

  _addHandlerHideWindow() {
    this._btnClose.addEventListener(‘click’, this.toggleWindow.bind(this));
    this._overlay.addEventListener(‘click’, this.toggleWindow.bind(this));
  }

  addHandlerUpload(handler) {
    this._parentElement.addEventListener(‘submit’, function (e) {
      e.preventDefault();
      const dataArr = […new FormData(this)];
      const data = Object.fromEntries(dataArr);
      handler(data);
    });
  }

  _generateMarkup() {}
}

export default new AddRecipeView();
import View from ‘./View.js’;
import previewView from ‘./previewView.js’;
import icons from ‘url:../../img/icons.svg’; // Parcel 2

class BookmarksView extends View {
  _parentElement = document.querySelector(‘.bookmarks__list’);
  _errorMessage = ‘No bookmarks yet. Find a nice recipe and bookmark it ;)’;
  _message = ;

  addHandlerRender(handler) {
    window.addEventListener(‘load’, handler);
  }

  _generateMarkup() {
    return this._data
      .map(bookmark => previewView.render(bookmark, false))
      .join();
  }
}

export default new BookmarksView();
import View from ‘./View.js’;
import icons from ‘url:../../img/icons.svg’; // Parcel 2

class PaginationView extends View {
  _parentElement = document.querySelector(‘.pagination’);

  addHandlerClick(handler) {
    this._parentElement.addEventListener(‘click’, function (e) {
      const btn = e.target.closest(‘.btn–inline’);
      if (!btn) return;

      const goToPage = +btn.dataset.goto;
      handler(goToPage);
    });
  }

  _generateMarkup() {
    const curPage = this._data.page;
    const numPages = Math.ceil(
      this._data.results.length / this._data.resultsPerPage
    );

    // Page 1, and there are other pages
    if (curPage === 1 && numPages > 1) {
      return `
        <button data-goto=”${
          curPage + 1
        }” class=”btn–inline pagination__btn–next”>
          <span>Page ${curPage + 1}</span>
          <svg class=”search__icon”>
            <use href=”${icons}#icon-arrow-right”></use>
          </svg>
        </button>
      `;
    }

    // Last page
    if (curPage === numPages && numPages > 1) {
      return `
        <button data-goto=”${
          curPage 1
        }” class=”btn–inline pagination__btn–prev”>
          <svg class=”search__icon”>
            <use href=”${icons}#icon-arrow-left”></use>
          </svg>
          <span>Page ${curPage 1}</span>
        </button>
      `;
    }

    // Other page
    if (curPage < numPages) {
      return `
        <button data-goto=”${
          curPage 1
        }” class=”btn–inline pagination__btn–prev”>
          <svg class=”search__icon”>
            <use href=”${icons}#icon-arrow-left”></use>
          </svg>
          <span>Page ${curPage 1}</span>
        </button>
        <button data-goto=”${
          curPage + 1
        }” class=”btn–inline pagination__btn–next”>
          <span>Page ${curPage + 1}</span>
          <svg class=”search__icon”>
            <use href=”${icons}#icon-arrow-right”></use>
          </svg>
        </button>
      `;
    }

    // Page 1, and there are NO other pages
    return ;
  }
}

export default new PaginationView();
import View from ‘./View.js’;
import icons from ‘url:../../img/icons.svg’; // Parcel 2

class PreviewView extends View {
  _parentElement = ;

  _generateMarkup() {
    const id = window.location.hash.slice(1);

    return `
      <li class=”preview”>
        <a class=”preview__link ${
          this._data.id === id ? ‘preview__link–active’ :
        }” href=”#${this._data.id}“>
          <figure class=”preview__fig”>
            <img src=”${this._data.image}” alt=”${this._data.title}” />
          </figure>
          <div class=”preview__data”>
            <h4 class=”preview__title”>${this._data.title}</h4>
            <p class=”preview__publisher”>${this._data.publisher}</p>
            <div class=”preview__user-generated ${
              this._data.key ? : ‘hidden’
            }“>
              <svg>
              <use href=”${icons}#icon-user”></use>
              </svg>
            </div>
          </div>
        </a>
      </li>
    `;
  }
}

export default new PreviewView();
import View from ‘./View.js’;

// import icons from ‘../img/icons.svg’; // Parcel 1
import icons from ‘url:../../img/icons.svg’; // Parcel 2
import { Fraction } from ‘fractional’;

class RecipeView extends View {
  _parentElement = document.querySelector(‘.recipe’);
  _errorMessage = ‘We could not find that recipe. Please try another one!’;
  _message = ;

  addHandlerRender(handler) {
    [‘hashchange’, ‘load’].forEach(ev => window.addEventListener(ev, handler));
  }

  addHandlerUpdateServings(handler) {
    this._parentElement.addEventListener(‘click’, function (e) {
      const btn = e.target.closest(‘.btn–update-servings’);
      if (!btn) return;
      const { updateTo } = btn.dataset;
      if (+updateTo > 0) handler(+updateTo);
    });
  }

  addHandlerAddBookmark(handler) {
    this._parentElement.addEventListener(‘click’, function (e) {
      const btn = e.target.closest(‘.btn–bookmark’);
      if (!btn) return;
      handler();
    });
  }

  _generateMarkup() {
    return `
      <figure class=”recipe__fig”>
        <img src=”${this._data.image}” alt=”${
      this._data.title
    }” class=”recipe__img” />
        <h1 class=”recipe__title”>
          <span>${this._data.title}</span>
        </h1>
      </figure>

      <div class=”recipe__details”>
        <div class=”recipe__info”>
          <svg class=”recipe__info-icon”>
            <use href=”${icons}#icon-clock”></use>
          </svg>
          <span class=”recipe__info-data recipe__info-data–minutes”>${
            this._data.cookingTime
          }</span>
          <span class=”recipe__info-text”>minutes</span>
        </div>
        <div class=”recipe__info”>
          <svg class=”recipe__info-icon”>
            <use href=”${icons}#icon-users”></use>
          </svg>
          <span class=”recipe__info-data recipe__info-data–people”>${
            this._data.servings
          }</span>
          <span class=”recipe__info-text”>servings</span>

          <div class=”recipe__info-buttons”>
            <button class=”btn–tiny btn–update-servings” data-update-to=”${
              this._data.servings 1
            }“>
              <svg>
                <use href=”${icons}#icon-minus-circle”></use>
              </svg>
            </button>
            <button class=”btn–tiny btn–update-servings” data-update-to=”${
              this._data.servings + 1
            }“>
              <svg>
                <use href=”${icons}#icon-plus-circle”></use>
              </svg>
            </button>
          </div>
        </div>

        <div class=”recipe__user-generated ${this._data.key ? : ‘hidden’}“>
          <svg>
            <use href=”${icons}#icon-user”></use>
          </svg>
        </div>
        <button class=”btn–round btn–bookmark”>
          <svg class=””>
            <use href=”${icons}#icon-bookmark${
      this._data.bookmarked ? ‘-fill’ :
    }“></use>
          </svg>
        </button>
      </div>

      <div class=”recipe__ingredients”>
        <h2 class=”heading–2″>Recipe ingredients</h2>
        <ul class=”recipe__ingredient-list”>
          ${this._data.ingredients.map(this._generateMarkupIngredient).join()}
      </div>

      <div class=”recipe__directions”>
        <h2 class=”heading–2″>How to cook it</h2>
        <p class=”recipe__directions-text”>
          This recipe was carefully designed and tested by
          <span class=”recipe__publisher”>${
            this._data.publisher
          }</span>. Please check out
          directions at their website.
        </p>
        <a
          class=”btn–small recipe__btn”
          href=”${this._data.sourceUrl}
          target=”_blank”
        >
          <span>Directions</span>
          <svg class=”search__icon”>
            <use href=”${icons}#icon-arrow-right”></use>
          </svg>
        </a>
      </div>
    `;
  }

  _generateMarkupIngredient(ing) {
    return `
    <li class=”recipe__ingredient”>
      <svg class=”recipe__icon”>
        <use href=”${icons}#icon-check”></use>
      </svg>
      <div class=”recipe__quantity”>${
        ing.quantity ? new Fraction(ing.quantity).toString() :
      }</div>
      <div class=”recipe__description”>
        <span class=”recipe__unit”>${ing.unit}</span>
        ${ing.description}
      </div>
    </li>
  `;
  }
}

export default new RecipeView();
import View from ‘./View.js’;
import previewView from ‘./previewView.js’;
import icons from ‘url:../../img/icons.svg’; // Parcel 2

class ResultsView extends View {
  _parentElement = document.querySelector(‘.results’);
  _errorMessage = ‘No recipes found for your query! Please try again ;)’;
  _message = ;

  _generateMarkup() {
    return this._data.map(result => previewView.render(result, false)).join();
  }
}

export default new ResultsView();
class SearchView {
  _parentEl = document.querySelector(‘.search’);

  getQuery() {
    const query = this._parentEl.querySelector(‘.search__field’).value;
    this._clearInput();
    return query;
  }

  _clearInput() {
    this._parentEl.querySelector(‘.search__field’).value = ;
  }

  addHandlerSearch(handler) {
    this._parentEl.addEventListener(‘submit’, function (e) {
      e.preventDefault();
      handler();
    });
  }
}

export default new SearchView();
import icons from ‘url:../../img/icons.svg’; // Parcel 2

export default class View {
  _data;

  /**
   * Render the received object to the DOM
   * @param {Object | Object[]} data The data to be rendered (e.g. recipe)
   * @param {boolean} [render=true] If false, create markup string instead of rendering to the DOM
   * @returns {undefined | string} A markup string is returned if render=false
   * @this {Object} View instance
   * @author Jonas Schmedtmann
   * @todo Finish implementation
   */
  render(data, render = true) {
    if (!data || (Array.isArray(data) && data.length === 0))
      return this.renderError();

    this._data = data;
    const markup = this._generateMarkup();

    if (!render) return markup;

    this._clear();
    this._parentElement.insertAdjacentHTML(‘afterbegin’, markup);
  }

  update(data) {
    this._data = data;
    const newMarkup = this._generateMarkup();

    const newDOM = document.createRange().createContextualFragment(newMarkup);
    const newElements = Array.from(newDOM.querySelectorAll(‘*’));
    const curElements = Array.from(this._parentElement.querySelectorAll(‘*’));

    newElements.forEach((newEl, i) => {
      const curEl = curElements[i];
      // console.log(curEl, newEl.isEqualNode(curEl));

      // Updates changed TEXT
      if (
        !newEl.isEqualNode(curEl) &&
        newEl.firstChild?.nodeValue.trim() !==
      ) {
        // console.log(‘💥’, newEl.firstChild.nodeValue.trim());
        curEl.textContent = newEl.textContent;
      }

      // Updates changed ATTRIBUES
      if (!newEl.isEqualNode(curEl))
        Array.from(newEl.attributes).forEach(attr =>
          curEl.setAttribute(attr.name, attr.value)
        );
    });
  }

  _clear() {
    this._parentElement.innerHTML = ;
  }

  renderSpinner() {
    const markup = `
      <div class=”spinner”>
        <svg>
          <use href=”${icons}#icon-loader”></use>
        </svg>
      </div>
    `;
    this._clear();
    this._parentElement.insertAdjacentHTML(‘afterbegin’, markup);
  }

  renderError(message = this._errorMessage) {
    const markup = `
      <div class=”error”>
        <div>
          <svg>
            <use href=”${icons}#icon-alert-triangle”></use>
          </svg>
        </div>
        <p>${message}</p>
      </div>
    `;
    this._clear();
    this._parentElement.insertAdjacentHTML(‘afterbegin’, markup);
  }

  renderMessage(message = this._message) {
    const markup = `
      <div class=”message”>
        <div>
          <svg>
            <use href=”${icons}#icon-smile”></use>
          </svg>
        </div>
        <p>${message}</p>
      </div>
    `;
    this._clear();
    this._parentElement.insertAdjacentHTML(‘afterbegin’, markup);
  }
}