├── .gitignore ├── lesson-02 ├── base │ ├── app.js │ ├── assets │ │ ├── fish.jpg │ │ ├── lasagna.jpg │ │ └── roast.jpg │ ├── example.html │ ├── index.html │ └── static │ │ └── site.css └── finished │ ├── app.js │ ├── assets │ ├── fish.jpg │ ├── lasagna.jpg │ └── roast.jpg │ ├── example.html │ ├── index.html │ └── static │ └── site.css ├── lesson-03 ├── base │ ├── assets │ │ ├── fish.jpg │ │ ├── lasagna.jpg │ │ └── roast.jpg │ ├── create.html │ ├── index.html │ ├── login.html │ ├── register.html │ ├── src │ │ └── app.js │ └── static │ │ ├── form.css │ │ ├── recipe.css │ │ └── site.css └── finished │ ├── assets │ ├── fish.jpg │ ├── lasagna.jpg │ └── roast.jpg │ ├── create.html │ ├── index.html │ ├── login.html │ ├── register.html │ ├── src │ ├── app.js │ ├── create.js │ ├── login.js │ └── register.js │ └── static │ ├── form.css │ ├── recipe.css │ └── site.css ├── lesson-04 ├── base │ ├── assets │ │ ├── fish.jpg │ │ ├── lasagna.jpg │ │ └── roast.jpg │ ├── create.html │ ├── index.html │ ├── login.html │ ├── register.html │ ├── src │ │ ├── app.js │ │ ├── create.js │ │ ├── login.js │ │ └── register.js │ └── static │ │ ├── form.css │ │ ├── recipe.css │ │ └── site.css └── finished │ ├── assets │ ├── fish.jpg │ ├── lasagna.jpg │ └── roast.jpg │ ├── index.html │ ├── src │ ├── app.js │ ├── catalog.js │ ├── create.js │ ├── details.js │ ├── dom.js │ ├── edit.js │ ├── login.js │ └── register.js │ ├── static │ ├── form.css │ ├── recipe.css │ └── site.css │ └── tests │ ├── e2e.test.js │ └── mock-data.json ├── lesson-05 ├── base │ ├── assets │ │ ├── fish.jpg │ │ ├── lasagna.jpg │ │ ├── logo.png │ │ └── roast.jpg │ ├── home.html │ ├── index.html │ ├── src │ │ ├── app.js │ │ ├── catalog.js │ │ ├── create.js │ │ ├── details.js │ │ ├── dom.js │ │ ├── edit.js │ │ ├── login.js │ │ └── register.js │ ├── static │ │ ├── form.css │ │ ├── recipe.css │ │ └── site.css │ └── tests │ │ ├── e2e.test.js │ │ └── mock-data.json └── finished │ ├── assets │ ├── fish.jpg │ ├── lasagna.jpg │ ├── logo.png │ └── roast.jpg │ ├── index.html │ ├── src │ ├── api │ │ ├── api.js │ │ └── data.js │ ├── app.js │ ├── dom.js │ ├── navigation.js │ └── views │ │ ├── catalog.js │ │ ├── create.js │ │ ├── details.js │ │ ├── edit.js │ │ ├── home.js │ │ ├── login.js │ │ └── register.js │ ├── static │ ├── form.css │ ├── recipe.css │ └── site.css │ └── tests │ ├── e2e.test.js │ └── mock-data.json ├── lesson-06 ├── base │ ├── assets │ │ ├── fish.jpg │ │ ├── lasagna.jpg │ │ ├── logo.png │ │ └── roast.jpg │ ├── index.html │ ├── src │ │ ├── api │ │ │ ├── api.js │ │ │ └── data.js │ │ ├── app.js │ │ ├── dom.js │ │ ├── navigation.js │ │ └── views │ │ │ ├── catalog.js │ │ │ ├── create.js │ │ │ ├── details.js │ │ │ ├── edit.js │ │ │ ├── home.js │ │ │ ├── login.js │ │ │ └── register.js │ └── static │ │ ├── form.css │ │ ├── recipe.css │ │ └── site.css └── finished │ ├── assets │ ├── fish.jpg │ ├── lasagna.jpg │ ├── logo.png │ └── roast.jpg │ ├── index.html │ ├── src │ ├── api │ │ ├── api.js │ │ └── data.js │ ├── app.js │ ├── dom.js │ ├── navigation.js │ └── views │ │ ├── catalog.js │ │ ├── comments.js │ │ ├── create.js │ │ ├── details.js │ │ ├── edit.js │ │ ├── home.js │ │ ├── login.js │ │ └── register.js │ └── static │ ├── comments.css │ ├── form.css │ ├── recipe.css │ └── site.css ├── lesson-07 ├── base │ ├── assets │ │ ├── fish.jpg │ │ ├── lasagna.jpg │ │ ├── logo.png │ │ └── roast.jpg │ ├── index.html │ ├── src │ │ ├── api │ │ │ ├── api.js │ │ │ └── data.js │ │ ├── app.js │ │ ├── dom.js │ │ ├── navigation.js │ │ └── views │ │ │ ├── catalog.js │ │ │ ├── comments.js │ │ │ ├── create.js │ │ │ ├── details.js │ │ │ ├── edit.js │ │ │ ├── home.js │ │ │ ├── login.js │ │ │ └── register.js │ └── static │ │ ├── comments.css │ │ ├── form.css │ │ ├── recipe.css │ │ └── site.css └── finished │ ├── assets │ ├── fish.jpg │ ├── lasagna.jpg │ ├── logo.png │ └── roast.jpg │ ├── index.html │ ├── src │ ├── api │ │ ├── api.js │ │ └── data.js │ ├── app.js │ ├── dom.js │ ├── navigation.js │ └── views │ │ ├── catalog.js │ │ ├── comments.js │ │ ├── create.js │ │ ├── details.js │ │ ├── edit.js │ │ ├── home.js │ │ ├── login.js │ │ └── register.js │ └── static │ ├── comments.css │ ├── form.css │ ├── recipe.css │ └── site.css ├── package-lock.json ├── package.json └── server.bat /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | meta -------------------------------------------------------------------------------- /lesson-02/base/app.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-02/base/app.js -------------------------------------------------------------------------------- /lesson-02/base/assets/fish.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-02/base/assets/fish.jpg -------------------------------------------------------------------------------- /lesson-02/base/assets/lasagna.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-02/base/assets/lasagna.jpg -------------------------------------------------------------------------------- /lesson-02/base/assets/roast.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-02/base/assets/roast.jpg -------------------------------------------------------------------------------- /lesson-02/base/example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | My Cookbook 7 | 8 | 9 | 10 | 11 |
12 |

My Cookbook

13 |
14 |
15 |
16 |
17 |

Title

18 |
19 |
20 | 21 |
22 |
23 | 24 |
25 |

Title

26 |
27 |
28 | 29 |
30 |
31 |

Ingredients:

32 |
    33 |
  • Ingredient 1
  • 34 |
  • Ingredient 2
  • 35 |
  • Ingredient 3
  • 36 |
  • Ingredient 4
  • 37 |
38 |
39 |
40 |
41 |

Preparation:

42 |

Lorem ipsum, dolor sit amet consectetur adipisicing elit. Eius, quaerat.

43 |

Lorem ipsum dolor sit amet, consectetur adipisicing elit. Consectetur officia ipsam nulla vitae nobis 44 | reprehenderit pariatur aut dolor exercitationem impedit.

45 |

Lorem ipsum dolor sit amet consectetur adipisicing elit. Repellendus dolorem odit officiis numquam 46 | corrupti? Quam.

47 |
48 |
49 |
50 | 51 | 52 | -------------------------------------------------------------------------------- /lesson-02/base/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | My Cookbook 7 | 8 | 9 | 10 | 11 | 12 |

My Cookbook

13 |
14 |

Loading...

15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /lesson-02/base/static/site.css: -------------------------------------------------------------------------------- 1 | * { 2 | font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif; 3 | font-size: 16pt; 4 | padding: 0; 5 | margin: 0; 6 | box-sizing: border-box; 7 | } 8 | 9 | body { 10 | width: 980px; 11 | margin: 0 auto 0 auto; 12 | } 13 | 14 | header { 15 | padding: 32px; 16 | background-color: #cccccc; 17 | } 18 | 19 | h1 { 20 | font-size: 150%; 21 | } 22 | 23 | main { 24 | background-color: #666666; 25 | padding: 32px; 26 | } 27 | 28 | article { 29 | display: block; 30 | width: 720px; 31 | margin: 32px auto 32px auto; 32 | background-color: #cccccc; 33 | box-shadow: 8px 8px 8px 0 rgba(0, 0, 0, 0.2), 12px 12px 20px 0 rgba(0, 0, 0, 0.19); 34 | border-radius: 16px; 35 | } 36 | 37 | h2 { 38 | padding: 16px; 39 | padding-left: 32px; 40 | background-color: salmon; 41 | border-radius: 16px 16px 0 0; 42 | } 43 | 44 | .band { 45 | background-color: white; 46 | padding: 32px; 47 | } 48 | 49 | .band::after { 50 | content: ""; 51 | clear: both; 52 | display: table; 53 | } 54 | 55 | .thumb { 56 | width: 200px; 57 | float: left; 58 | } 59 | 60 | .thumb>img { 61 | max-width: 200px; 62 | box-shadow: 8px 8px 8px 0 rgba(0, 0, 0, 0.2), 12px 12px 20px 0 rgba(0, 0, 0, 0.19); 63 | } 64 | 65 | .ingredients { 66 | float: left; 67 | margin-left: 32px; 68 | } 69 | 70 | .ingredients>ul { 71 | margin-left: 32px; 72 | } 73 | 74 | .ingredients>ul>li { 75 | margin-top: 16px; 76 | } 77 | 78 | .description { 79 | padding: 32px; 80 | } 81 | 82 | .description>p { 83 | margin: 16px 0 16px 32px; 84 | } 85 | 86 | .preview { 87 | vertical-align: middle; 88 | } 89 | 90 | .preview::after { 91 | content: ""; 92 | clear: both; 93 | display: table; 94 | } 95 | 96 | .preview:hover { 97 | background-color: salmon; 98 | cursor: pointer; 99 | box-shadow: 16px 16px 8px 0 rgba(0, 0, 0, 0.2), 20px 20px 20px 0 rgba(0, 0, 0, 0.19); 100 | } 101 | 102 | .title { 103 | float: left; 104 | width: 420px; 105 | } 106 | 107 | .title h2 { 108 | background-color: transparent; 109 | } 110 | 111 | .small { 112 | float: left; 113 | background-color: white; 114 | width: 300px; 115 | text-align: center; 116 | border-radius: 0 16px 16px 0; 117 | } 118 | 119 | .small>img { 120 | max-width: 200px; 121 | margin: 16px auto; 122 | display: block; 123 | box-shadow: 8px 8px 8px 0 rgba(0, 0, 0, 0.2), 12px 12px 20px 0 rgba(0, 0, 0, 0.19); 124 | } -------------------------------------------------------------------------------- /lesson-02/finished/app.js: -------------------------------------------------------------------------------- 1 | async function getRecipes() { 2 | const response = await fetch('http://localhost:3030/jsonstore/cookbook/recipes'); 3 | const recipes = await response.json(); 4 | 5 | return Object.values(recipes); 6 | } 7 | 8 | async function getRecipeById(id) { 9 | const response = await fetch('http://localhost:3030/jsonstore/cookbook/details/' + id); 10 | const recipe = await response.json(); 11 | 12 | return recipe; 13 | } 14 | 15 | function createRecipePreview(recipe) { 16 | const result = e('article', { className: 'preview', onClick: toggleCard }, 17 | e('div', { className: 'title' }, e('h2', {}, recipe.name)), 18 | e('div', { className: 'small' }, e('img', { src: recipe.img })), 19 | ); 20 | 21 | return result; 22 | 23 | async function toggleCard() { 24 | const fullRecipe = await getRecipeById(recipe._id); 25 | 26 | result.replaceWith(createRecipeCard(fullRecipe)); 27 | } 28 | } 29 | 30 | function createRecipeCard(recipe) { 31 | const result = e('article', {}, 32 | e('h2', {}, recipe.name), 33 | e('div', { className: 'band' }, 34 | e('div', { className: 'thumb' }, e('img', { src: recipe.img })), 35 | e('div', { className: 'ingredients' }, 36 | e('h3', {}, 'Ingredients:'), 37 | e('ul', {}, recipe.ingredients.map(i => e('li', {}, i))), 38 | ) 39 | ), 40 | e('div', { className: 'description' }, 41 | e('h3', {}, 'Preparation:'), 42 | recipe.steps.map(s => e('p', {}, s)) 43 | ), 44 | ); 45 | 46 | return result; 47 | } 48 | 49 | window.addEventListener('load', async () => { 50 | const main = document.querySelector('main'); 51 | 52 | const recipes = await getRecipes(); 53 | const cards = recipes.map(createRecipePreview); 54 | 55 | main.innerHTML = ''; 56 | cards.forEach(c => main.appendChild(c)); 57 | }); 58 | 59 | function e(type, attributes, ...content) { 60 | const result = document.createElement(type); 61 | 62 | for (let [attr, value] of Object.entries(attributes || {})) { 63 | if (attr.substring(0, 2) == 'on') { 64 | result.addEventListener(attr.substring(2).toLocaleLowerCase(), value); 65 | } else { 66 | result[attr] = value; 67 | } 68 | } 69 | 70 | content = content.reduce((a, c) => a.concat(Array.isArray(c) ? c : [c]), []); 71 | 72 | content.forEach(e => { 73 | if (typeof e == 'string' || typeof e == 'number') { 74 | const node = document.createTextNode(e); 75 | result.appendChild(node); 76 | } else { 77 | result.appendChild(e); 78 | } 79 | }); 80 | 81 | return result; 82 | } -------------------------------------------------------------------------------- /lesson-02/finished/assets/fish.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-02/finished/assets/fish.jpg -------------------------------------------------------------------------------- /lesson-02/finished/assets/lasagna.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-02/finished/assets/lasagna.jpg -------------------------------------------------------------------------------- /lesson-02/finished/assets/roast.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-02/finished/assets/roast.jpg -------------------------------------------------------------------------------- /lesson-02/finished/example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | My Cookbook 7 | 8 | 9 | 10 | 11 |
12 |

My Cookbook

13 |
14 |
15 |
16 |
17 |

Title

18 |
19 |
20 | 21 |
22 |
23 | 24 |
25 |

Title

26 |
27 |
28 | 29 |
30 |
31 |

Ingredients:

32 |
    33 |
  • Ingredient 1
  • 34 |
  • Ingredient 2
  • 35 |
  • Ingredient 3
  • 36 |
  • Ingredient 4
  • 37 |
38 |
39 |
40 |
41 |

Preparation:

42 |

Lorem ipsum, dolor sit amet consectetur adipisicing elit. Eius, quaerat.

43 |

Lorem ipsum dolor sit amet, consectetur adipisicing elit. Consectetur officia ipsam nulla vitae nobis 44 | reprehenderit pariatur aut dolor exercitationem impedit.

45 |

Lorem ipsum dolor sit amet consectetur adipisicing elit. Repellendus dolorem odit officiis numquam 46 | corrupti? Quam.

47 |
48 |
49 |
50 | 51 | 52 | -------------------------------------------------------------------------------- /lesson-02/finished/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | My Cookbook 7 | 8 | 9 | 10 | 11 | 12 |

My Cookbook

13 |
14 |

Loading...

15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /lesson-02/finished/static/site.css: -------------------------------------------------------------------------------- 1 | * { 2 | font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif; 3 | font-size: 16pt; 4 | padding: 0; 5 | margin: 0; 6 | box-sizing: border-box; 7 | } 8 | 9 | body { 10 | width: 980px; 11 | margin: 0 auto 0 auto; 12 | } 13 | 14 | header { 15 | padding: 32px; 16 | background-color: #cccccc; 17 | } 18 | 19 | h1 { 20 | font-size: 150%; 21 | } 22 | 23 | main { 24 | background-color: #666666; 25 | padding: 32px; 26 | } 27 | 28 | article { 29 | display: block; 30 | width: 720px; 31 | margin: 32px auto 32px auto; 32 | background-color: #cccccc; 33 | box-shadow: 8px 8px 8px 0 rgba(0, 0, 0, 0.2), 12px 12px 20px 0 rgba(0, 0, 0, 0.19); 34 | border-radius: 16px; 35 | } 36 | 37 | h2 { 38 | padding: 16px; 39 | padding-left: 32px; 40 | background-color: salmon; 41 | border-radius: 16px 16px 0 0; 42 | } 43 | 44 | .band { 45 | background-color: white; 46 | padding: 32px; 47 | } 48 | 49 | .band::after { 50 | content: ""; 51 | clear: both; 52 | display: table; 53 | } 54 | 55 | .thumb { 56 | width: 200px; 57 | float: left; 58 | } 59 | 60 | .thumb>img { 61 | max-width: 200px; 62 | box-shadow: 8px 8px 8px 0 rgba(0, 0, 0, 0.2), 12px 12px 20px 0 rgba(0, 0, 0, 0.19); 63 | } 64 | 65 | .ingredients { 66 | float: left; 67 | margin-left: 32px; 68 | } 69 | 70 | .ingredients>ul { 71 | margin-left: 32px; 72 | } 73 | 74 | .ingredients>ul>li { 75 | margin-top: 16px; 76 | } 77 | 78 | .description { 79 | padding: 32px; 80 | } 81 | 82 | .description>p { 83 | margin: 16px 0 16px 32px; 84 | } 85 | 86 | .preview { 87 | vertical-align: middle; 88 | } 89 | 90 | .preview::after { 91 | content: ""; 92 | clear: both; 93 | display: table; 94 | } 95 | 96 | .preview:hover { 97 | background-color: salmon; 98 | cursor: pointer; 99 | box-shadow: 16px 16px 8px 0 rgba(0, 0, 0, 0.2), 20px 20px 20px 0 rgba(0, 0, 0, 0.19); 100 | } 101 | 102 | .title { 103 | float: left; 104 | width: 420px; 105 | } 106 | 107 | .title h2 { 108 | background-color: transparent; 109 | } 110 | 111 | .small { 112 | float: left; 113 | background-color: white; 114 | width: 300px; 115 | text-align: center; 116 | border-radius: 0 16px 16px 0; 117 | } 118 | 119 | .small>img { 120 | max-width: 200px; 121 | margin: 16px auto; 122 | display: block; 123 | box-shadow: 8px 8px 8px 0 rgba(0, 0, 0, 0.2), 12px 12px 20px 0 rgba(0, 0, 0, 0.19); 124 | } -------------------------------------------------------------------------------- /lesson-03/base/assets/fish.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-03/base/assets/fish.jpg -------------------------------------------------------------------------------- /lesson-03/base/assets/lasagna.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-03/base/assets/lasagna.jpg -------------------------------------------------------------------------------- /lesson-03/base/assets/roast.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-03/base/assets/roast.jpg -------------------------------------------------------------------------------- /lesson-03/base/create.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | My Cookbook 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |

My Cookbook

15 | 19 |
20 |
21 |
22 |

New Recipe

23 |
24 | 25 | 26 | 27 | 28 | 29 |
30 |
31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /lesson-03/base/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | My Cookbook 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |

My Cookbook

15 | 26 |
27 |
28 |

Loading...

29 |
30 | 31 | 32 | -------------------------------------------------------------------------------- /lesson-03/base/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | My Cookbook 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |

My Cookbook

15 | 20 |
21 |
22 |
23 |

Login

24 |
25 | 26 | 27 | 28 |
29 |
30 |
31 | 32 | 33 | -------------------------------------------------------------------------------- /lesson-03/base/register.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | My Cookbook 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |

My Cookbook

15 | 20 |
21 |
22 |
23 |

Register

24 |
25 | 26 | 27 | 28 | 29 |
30 |
31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /lesson-03/base/src/app.js: -------------------------------------------------------------------------------- 1 | async function getRecipes() { 2 | const response = await fetch('http://localhost:3030/jsonstore/cookbook/recipes'); 3 | const recipes = await response.json(); 4 | 5 | return Object.values(recipes); 6 | } 7 | 8 | async function getRecipeById(id) { 9 | const response = await fetch('http://localhost:3030/jsonstore/cookbook/details/' + id); 10 | const recipe = await response.json(); 11 | 12 | return recipe; 13 | } 14 | 15 | function createRecipePreview(recipe) { 16 | const result = e('article', { className: 'preview', onClick: toggleCard }, 17 | e('div', { className: 'title' }, e('h2', {}, recipe.name)), 18 | e('div', { className: 'small' }, e('img', { src: recipe.img })), 19 | ); 20 | 21 | return result; 22 | 23 | async function toggleCard() { 24 | const fullRecipe = await getRecipeById(recipe._id); 25 | 26 | result.replaceWith(createRecipeCard(fullRecipe)); 27 | } 28 | } 29 | 30 | function createRecipeCard(recipe) { 31 | const result = e('article', {}, 32 | e('h2', {}, recipe.name), 33 | e('div', { className: 'band' }, 34 | e('div', { className: 'thumb' }, e('img', { src: recipe.img })), 35 | e('div', { className: 'ingredients' }, 36 | e('h3', {}, 'Ingredients:'), 37 | e('ul', {}, recipe.ingredients.map(i => e('li', {}, i))), 38 | ) 39 | ), 40 | e('div', { className: 'description' }, 41 | e('h3', {}, 'Preparation:'), 42 | recipe.steps.map(s => e('p', {}, s)) 43 | ), 44 | ); 45 | 46 | return result; 47 | } 48 | 49 | window.addEventListener('load', async () => { 50 | const main = document.querySelector('main'); 51 | 52 | const recipes = await getRecipes(); 53 | const cards = recipes.map(createRecipePreview); 54 | 55 | main.innerHTML = ''; 56 | cards.forEach(c => main.appendChild(c)); 57 | }); 58 | 59 | function e(type, attributes, ...content) { 60 | const result = document.createElement(type); 61 | 62 | for (let [attr, value] of Object.entries(attributes || {})) { 63 | if (attr.substring(0, 2) == 'on') { 64 | result.addEventListener(attr.substring(2).toLocaleLowerCase(), value); 65 | } else { 66 | result[attr] = value; 67 | } 68 | } 69 | 70 | content = content.reduce((a, c) => a.concat(Array.isArray(c) ? c : [c]), []); 71 | 72 | content.forEach(e => { 73 | if (typeof e == 'string' || typeof e == 'number') { 74 | const node = document.createTextNode(e); 75 | result.appendChild(node); 76 | } else { 77 | result.appendChild(e); 78 | } 79 | }); 80 | 81 | return result; 82 | } -------------------------------------------------------------------------------- /lesson-03/base/static/form.css: -------------------------------------------------------------------------------- 1 | form { 2 | padding: 32px; 3 | } 4 | 5 | label { 6 | display: block; 7 | position: relative; 8 | margin: 16px 0; 9 | text-align: right; 10 | padding-right: 520px; 11 | line-height: 48px; 12 | } 13 | 14 | input[type="text"], input[type="password"], textarea { 15 | position: absolute; 16 | right: 0px; 17 | width: 500px; 18 | padding: 8px; 19 | } 20 | 21 | textarea { 22 | height: 300px; 23 | resize: none; 24 | } 25 | 26 | .ml { 27 | height: 300px; 28 | } 29 | 30 | input[type="submit"] { 31 | display: block; 32 | border: none; 33 | margin: auto; 34 | background-color: salmon; 35 | padding: 8px 16px; 36 | text-decoration: none; 37 | color: black; 38 | } 39 | 40 | input[type="submit"]:hover { 41 | background-color: #6c8b47; 42 | color: white; 43 | cursor: pointer; 44 | } -------------------------------------------------------------------------------- /lesson-03/base/static/recipe.css: -------------------------------------------------------------------------------- 1 | article { 2 | display: block; 3 | width: 720px; 4 | margin: 32px auto 32px auto; 5 | background-color: #cccccc; 6 | box-shadow: 8px 8px 8px 0 rgba(0, 0, 0, 0.2), 12px 12px 20px 0 rgba(0, 0, 0, 0.19); 7 | border-radius: 16px; 8 | } 9 | 10 | h2 { 11 | padding: 16px; 12 | padding-left: 32px; 13 | background-color: salmon; 14 | border-radius: 16px 16px 0 0; 15 | } 16 | 17 | .band { 18 | background-color: white; 19 | padding: 32px; 20 | } 21 | 22 | .band::after { 23 | content: ""; 24 | clear: both; 25 | display: table; 26 | } 27 | 28 | .thumb { 29 | width: 200px; 30 | float: left; 31 | } 32 | 33 | .thumb>img { 34 | max-width: 200px; 35 | box-shadow: 8px 8px 8px 0 rgba(0, 0, 0, 0.2), 12px 12px 20px 0 rgba(0, 0, 0, 0.19); 36 | } 37 | 38 | .ingredients { 39 | float: left; 40 | margin-left: 32px; 41 | } 42 | 43 | .ingredients>ul { 44 | margin-left: 32px; 45 | } 46 | 47 | .ingredients>ul>li { 48 | margin-top: 16px; 49 | } 50 | 51 | .description { 52 | padding: 32px; 53 | } 54 | 55 | .description>p { 56 | margin: 16px 0 16px 32px; 57 | } 58 | 59 | .preview { 60 | vertical-align: middle; 61 | } 62 | 63 | .preview::after { 64 | content: ""; 65 | clear: both; 66 | display: table; 67 | } 68 | 69 | .preview:hover { 70 | background-color: salmon; 71 | cursor: pointer; 72 | box-shadow: 16px 16px 8px 0 rgba(0, 0, 0, 0.2), 20px 20px 20px 0 rgba(0, 0, 0, 0.19); 73 | } 74 | 75 | .title { 76 | float: left; 77 | width: 420px; 78 | } 79 | 80 | .title h2 { 81 | background-color: transparent; 82 | } 83 | 84 | .small { 85 | float: left; 86 | background-color: white; 87 | width: 300px; 88 | text-align: center; 89 | border-radius: 0 16px 16px 0; 90 | } 91 | 92 | .small>img { 93 | max-width: 200px; 94 | max-height: 150px; 95 | margin: 16px auto; 96 | display: block; 97 | box-shadow: 8px 8px 8px 0 rgba(0, 0, 0, 0.2), 12px 12px 20px 0 rgba(0, 0, 0, 0.19); 98 | } -------------------------------------------------------------------------------- /lesson-03/base/static/site.css: -------------------------------------------------------------------------------- 1 | * { 2 | font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif; 3 | font-size: 16pt; 4 | padding: 0; 5 | margin: 0; 6 | box-sizing: border-box; 7 | } 8 | 9 | body { 10 | width: 980px; 11 | margin: 0 auto 0 auto; 12 | } 13 | 14 | header { 15 | padding: 32px; 16 | background-color: #cccccc; 17 | position: relative; 18 | } 19 | 20 | h1 { 21 | font-size: 150%; 22 | display: inline; 23 | } 24 | 25 | main { 26 | background-color: #666666; 27 | padding: 32px; 28 | } 29 | 30 | nav { 31 | display: inline-block; 32 | position: absolute; 33 | right: 16px; 34 | text-align: right; 35 | } 36 | 37 | nav div { 38 | display: inline-block; 39 | } 40 | 41 | nav a { 42 | display: inline-block; 43 | background-color: salmon; 44 | padding: 8px 16px; 45 | margin: 0 8px; 46 | text-decoration: none; 47 | color: black; 48 | } 49 | 50 | nav a:hover { 51 | background-color: #6c8b47; 52 | color: white; 53 | } 54 | 55 | a.active { 56 | background-color: #6c8b47; 57 | color: white; 58 | } 59 | 60 | #user { 61 | display: none; 62 | } 63 | 64 | #guest { 65 | display: none; 66 | } -------------------------------------------------------------------------------- /lesson-03/finished/assets/fish.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-03/finished/assets/fish.jpg -------------------------------------------------------------------------------- /lesson-03/finished/assets/lasagna.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-03/finished/assets/lasagna.jpg -------------------------------------------------------------------------------- /lesson-03/finished/assets/roast.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-03/finished/assets/roast.jpg -------------------------------------------------------------------------------- /lesson-03/finished/create.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | My Cookbook 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |

My Cookbook

16 | 20 |
21 |
22 |
23 |

New Recipe

24 |
25 | 26 | 27 | 28 | 29 | 30 |
31 |
32 |
33 | 34 | 35 | -------------------------------------------------------------------------------- /lesson-03/finished/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | My Cookbook 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |

My Cookbook

15 | 26 |
27 |
28 |

Loading...

29 |
30 | 31 | 32 | -------------------------------------------------------------------------------- /lesson-03/finished/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | My Cookbook 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |

My Cookbook

16 | 21 |
22 |
23 |
24 |

Login

25 |
26 | 27 | 28 | 29 |
30 |
31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /lesson-03/finished/register.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | My Cookbook 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |

My Cookbook

16 | 21 |
22 |
23 |
24 |

Register

25 |
26 | 27 | 28 | 29 | 30 |
31 |
32 |
33 | 34 | 35 | -------------------------------------------------------------------------------- /lesson-03/finished/src/create.js: -------------------------------------------------------------------------------- 1 | const form = document.querySelector('form'); 2 | 3 | form.addEventListener('submit', (ev => { 4 | ev.preventDefault(); 5 | const formData = new FormData(ev.target); 6 | onSubmit([...formData.entries()].reduce((p, [k, v]) => Object.assign(p, { [k]: v }), {})); 7 | })); 8 | 9 | async function onSubmit(data) { 10 | const body = JSON.stringify({ 11 | name: data.name, 12 | img: data.img, 13 | ingredients: data.ingredients.split('\n').map(l => l.trim()).filter(l => l != ''), 14 | steps: data.steps.split('\n').map(l => l.trim()).filter(l => l != '') 15 | }); 16 | 17 | const token = sessionStorage.getItem('authToken'); 18 | if (token == null) { 19 | return window.location.pathname = 'index.html'; 20 | } 21 | 22 | try { 23 | const response = await fetch('http://localhost:3030/data/recipes', { 24 | method: 'post', 25 | headers: { 26 | 'Content-Type': 'application/json', 27 | 'X-Authorization': token 28 | }, 29 | body 30 | }); 31 | 32 | if (response.status == 200) { 33 | window.location.pathname = 'index.html'; 34 | } else { 35 | throw new Error(await response.json()); 36 | } 37 | } catch (err) { 38 | console.error(err.message); 39 | } 40 | } -------------------------------------------------------------------------------- /lesson-03/finished/src/login.js: -------------------------------------------------------------------------------- 1 | const form = document.querySelector('form'); 2 | 3 | form.addEventListener('submit', (ev => { 4 | ev.preventDefault(); 5 | const formData = new FormData(ev.target); 6 | onSubmit([...formData.entries()].reduce((p, [k, v]) => Object.assign(p, { [k]: v }), {})); 7 | })); 8 | 9 | async function onSubmit(data) { 10 | const body = JSON.stringify({ 11 | email: data.email, 12 | password: data.password, 13 | }); 14 | 15 | try { 16 | const response = await fetch('http://localhost:3030/users/login', { 17 | method: 'post', 18 | headers: { 19 | 'Content-Type': 'application/json' 20 | }, 21 | body 22 | }); 23 | const data = await response.json(); 24 | if (response.status == 200) { 25 | sessionStorage.setItem('authToken', data.accessToken); 26 | window.location.pathname = 'index.html'; 27 | } else { 28 | throw new Error(data.message); 29 | } 30 | } catch (err) { 31 | console.error(err.message); 32 | } 33 | } -------------------------------------------------------------------------------- /lesson-03/finished/src/register.js: -------------------------------------------------------------------------------- 1 | const form = document.querySelector('form'); 2 | 3 | form.addEventListener('submit', (ev => { 4 | ev.preventDefault(); 5 | const formData = new FormData(ev.target); 6 | onSubmit([...formData.entries()].reduce((p, [k, v]) => Object.assign(p, { [k]: v }), {})); 7 | })); 8 | 9 | async function onSubmit(data) { 10 | if (data.password != data.rePass) { 11 | return console.error('Passwords don\'t match'); 12 | } 13 | 14 | const body = JSON.stringify({ 15 | email: data.email, 16 | password: data.password, 17 | }); 18 | 19 | try { 20 | const response = await fetch('http://localhost:3030/users/register', { 21 | method: 'post', 22 | headers: { 23 | 'Content-Type': 'application/json' 24 | }, 25 | body 26 | }); 27 | const data = await response.json(); 28 | if (response.status == 200) { 29 | sessionStorage.setItem('authToken', data.accessToken); 30 | window.location.pathname = 'index.html'; 31 | } else { 32 | throw new Error(data.message); 33 | } 34 | } catch (err) { 35 | console.error(err.message); 36 | } 37 | } -------------------------------------------------------------------------------- /lesson-03/finished/static/form.css: -------------------------------------------------------------------------------- 1 | form { 2 | padding: 32px; 3 | } 4 | 5 | label { 6 | display: block; 7 | position: relative; 8 | margin: 16px 0; 9 | text-align: right; 10 | padding-right: 520px; 11 | line-height: 48px; 12 | } 13 | 14 | input[type="text"], input[type="password"], textarea { 15 | position: absolute; 16 | right: 0px; 17 | width: 500px; 18 | padding: 8px; 19 | } 20 | 21 | textarea { 22 | height: 300px; 23 | resize: none; 24 | } 25 | 26 | .ml { 27 | height: 300px; 28 | } 29 | 30 | input[type="submit"] { 31 | display: block; 32 | border: none; 33 | margin: auto; 34 | background-color: salmon; 35 | padding: 8px 16px; 36 | text-decoration: none; 37 | color: black; 38 | } 39 | 40 | input[type="submit"]:hover { 41 | background-color: #6c8b47; 42 | color: white; 43 | cursor: pointer; 44 | } -------------------------------------------------------------------------------- /lesson-03/finished/static/recipe.css: -------------------------------------------------------------------------------- 1 | article { 2 | display: block; 3 | width: 720px; 4 | margin: 32px auto 32px auto; 5 | background-color: #cccccc; 6 | box-shadow: 8px 8px 8px 0 rgba(0, 0, 0, 0.2), 12px 12px 20px 0 rgba(0, 0, 0, 0.19); 7 | border-radius: 16px; 8 | } 9 | 10 | h2 { 11 | padding: 16px; 12 | padding-left: 32px; 13 | background-color: salmon; 14 | border-radius: 16px 16px 0 0; 15 | } 16 | 17 | .band { 18 | background-color: white; 19 | padding: 32px; 20 | } 21 | 22 | .band::after { 23 | content: ""; 24 | clear: both; 25 | display: table; 26 | } 27 | 28 | .thumb { 29 | width: 200px; 30 | float: left; 31 | } 32 | 33 | .thumb>img { 34 | max-width: 200px; 35 | box-shadow: 8px 8px 8px 0 rgba(0, 0, 0, 0.2), 12px 12px 20px 0 rgba(0, 0, 0, 0.19); 36 | } 37 | 38 | .ingredients { 39 | float: left; 40 | margin-left: 32px; 41 | } 42 | 43 | .ingredients>ul { 44 | margin-left: 32px; 45 | } 46 | 47 | .ingredients>ul>li { 48 | margin-top: 16px; 49 | } 50 | 51 | .description { 52 | padding: 32px; 53 | } 54 | 55 | .description>p { 56 | margin: 16px 0 16px 32px; 57 | } 58 | 59 | .preview { 60 | vertical-align: middle; 61 | } 62 | 63 | .preview::after { 64 | content: ""; 65 | clear: both; 66 | display: table; 67 | } 68 | 69 | .preview:hover { 70 | background-color: salmon; 71 | cursor: pointer; 72 | box-shadow: 16px 16px 8px 0 rgba(0, 0, 0, 0.2), 20px 20px 20px 0 rgba(0, 0, 0, 0.19); 73 | } 74 | 75 | .title { 76 | float: left; 77 | width: 420px; 78 | } 79 | 80 | .title h2 { 81 | background-color: transparent; 82 | } 83 | 84 | .small { 85 | float: left; 86 | background-color: white; 87 | width: 300px; 88 | text-align: center; 89 | border-radius: 0 16px 16px 0; 90 | } 91 | 92 | .small>img { 93 | max-width: 200px; 94 | max-height: 150px; 95 | margin: 16px auto; 96 | display: block; 97 | box-shadow: 8px 8px 8px 0 rgba(0, 0, 0, 0.2), 12px 12px 20px 0 rgba(0, 0, 0, 0.19); 98 | } -------------------------------------------------------------------------------- /lesson-03/finished/static/site.css: -------------------------------------------------------------------------------- 1 | * { 2 | font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif; 3 | font-size: 16pt; 4 | padding: 0; 5 | margin: 0; 6 | box-sizing: border-box; 7 | } 8 | 9 | body { 10 | width: 980px; 11 | margin: 0 auto 0 auto; 12 | } 13 | 14 | header { 15 | padding: 32px; 16 | background-color: #cccccc; 17 | position: relative; 18 | } 19 | 20 | h1 { 21 | font-size: 150%; 22 | display: inline; 23 | } 24 | 25 | main { 26 | background-color: #666666; 27 | padding: 32px; 28 | } 29 | 30 | nav { 31 | display: inline-block; 32 | position: absolute; 33 | right: 16px; 34 | text-align: right; 35 | } 36 | 37 | nav div { 38 | display: inline-block; 39 | } 40 | 41 | nav a { 42 | display: inline-block; 43 | background-color: salmon; 44 | padding: 8px 16px; 45 | margin: 0 8px; 46 | text-decoration: none; 47 | color: black; 48 | } 49 | 50 | nav a:hover { 51 | background-color: #6c8b47; 52 | color: white; 53 | } 54 | 55 | a.active { 56 | background-color: #6c8b47; 57 | color: white; 58 | } 59 | 60 | #user { 61 | display: none; 62 | } 63 | 64 | #guest { 65 | display: none; 66 | } -------------------------------------------------------------------------------- /lesson-04/base/assets/fish.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-04/base/assets/fish.jpg -------------------------------------------------------------------------------- /lesson-04/base/assets/lasagna.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-04/base/assets/lasagna.jpg -------------------------------------------------------------------------------- /lesson-04/base/assets/roast.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-04/base/assets/roast.jpg -------------------------------------------------------------------------------- /lesson-04/base/create.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | My Cookbook 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |

My Cookbook

16 | 20 |
21 |
22 |
23 |

New Recipe

24 |
25 | 26 | 27 | 28 | 29 | 30 |
31 |
32 |
33 | 34 | 35 | -------------------------------------------------------------------------------- /lesson-04/base/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | My Cookbook 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |

My Cookbook

15 | 26 |
27 |
28 |

Loading...

29 |
30 | 31 | 32 | -------------------------------------------------------------------------------- /lesson-04/base/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | My Cookbook 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |

My Cookbook

16 | 21 |
22 |
23 |
24 |

Login

25 |
26 | 27 | 28 | 29 |
30 |
31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /lesson-04/base/register.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | My Cookbook 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |

My Cookbook

16 | 21 |
22 |
23 |
24 |

Register

25 |
26 | 27 | 28 | 29 | 30 |
31 |
32 |
33 | 34 | 35 | -------------------------------------------------------------------------------- /lesson-04/base/src/create.js: -------------------------------------------------------------------------------- 1 | const form = document.querySelector('form'); 2 | 3 | form.addEventListener('submit', (ev => { 4 | ev.preventDefault(); 5 | const formData = new FormData(ev.target); 6 | onSubmit([...formData.entries()].reduce((p, [k, v]) => Object.assign(p, { [k]: v }), {})); 7 | })); 8 | 9 | async function onSubmit(data) { 10 | const body = JSON.stringify({ 11 | name: data.name, 12 | img: data.img, 13 | ingredients: data.ingredients.split('\n').map(l => l.trim()).filter(l => l != ''), 14 | steps: data.steps.split('\n').map(l => l.trim()).filter(l => l != '') 15 | }); 16 | 17 | const token = sessionStorage.getItem('authToken'); 18 | if (token == null) { 19 | return window.location.pathname = 'index.html'; 20 | } 21 | 22 | try { 23 | const response = await fetch('http://localhost:3030/data/recipes', { 24 | method: 'post', 25 | headers: { 26 | 'Content-Type': 'application/json', 27 | 'X-Authorization': token 28 | }, 29 | body 30 | }); 31 | 32 | if (response.status == 200) { 33 | window.location.pathname = 'index.html'; 34 | } else { 35 | throw new Error(await response.json()); 36 | } 37 | } catch (err) { 38 | console.error(err.message); 39 | } 40 | } -------------------------------------------------------------------------------- /lesson-04/base/src/login.js: -------------------------------------------------------------------------------- 1 | const form = document.querySelector('form'); 2 | 3 | form.addEventListener('submit', (ev => { 4 | ev.preventDefault(); 5 | const formData = new FormData(ev.target); 6 | onSubmit([...formData.entries()].reduce((p, [k, v]) => Object.assign(p, { [k]: v }), {})); 7 | })); 8 | 9 | async function onSubmit(data) { 10 | const body = JSON.stringify({ 11 | email: data.email, 12 | password: data.password, 13 | }); 14 | 15 | try { 16 | const response = await fetch('http://localhost:3030/users/login', { 17 | method: 'post', 18 | headers: { 19 | 'Content-Type': 'application/json' 20 | }, 21 | body 22 | }); 23 | const data = await response.json(); 24 | if (response.status == 200) { 25 | sessionStorage.setItem('authToken', data.accessToken); 26 | window.location.pathname = 'index.html'; 27 | } else { 28 | throw new Error(data.message); 29 | } 30 | } catch (err) { 31 | console.error(err.message); 32 | } 33 | } -------------------------------------------------------------------------------- /lesson-04/base/src/register.js: -------------------------------------------------------------------------------- 1 | const form = document.querySelector('form'); 2 | 3 | form.addEventListener('submit', (ev => { 4 | ev.preventDefault(); 5 | const formData = new FormData(ev.target); 6 | onSubmit([...formData.entries()].reduce((p, [k, v]) => Object.assign(p, { [k]: v }), {})); 7 | })); 8 | 9 | async function onSubmit(data) { 10 | if (data.password != data.rePass) { 11 | return console.error('Passwords don\'t match'); 12 | } 13 | 14 | const body = JSON.stringify({ 15 | email: data.email, 16 | password: data.password, 17 | }); 18 | 19 | try { 20 | const response = await fetch('http://localhost:3030/users/register', { 21 | method: 'post', 22 | headers: { 23 | 'Content-Type': 'application/json' 24 | }, 25 | body 26 | }); 27 | const data = await response.json(); 28 | if (response.status == 200) { 29 | sessionStorage.setItem('authToken', data.accessToken); 30 | window.location.pathname = 'index.html'; 31 | } else { 32 | throw new Error(data.message); 33 | } 34 | } catch (err) { 35 | console.error(err.message); 36 | } 37 | } -------------------------------------------------------------------------------- /lesson-04/base/static/form.css: -------------------------------------------------------------------------------- 1 | form { 2 | padding: 32px; 3 | } 4 | 5 | label { 6 | display: block; 7 | position: relative; 8 | margin: 16px 0; 9 | text-align: right; 10 | padding-right: 520px; 11 | line-height: 48px; 12 | } 13 | 14 | input[type="text"], input[type="password"], textarea { 15 | position: absolute; 16 | right: 0px; 17 | width: 500px; 18 | padding: 8px; 19 | } 20 | 21 | textarea { 22 | height: 300px; 23 | resize: none; 24 | } 25 | 26 | .ml { 27 | height: 300px; 28 | } 29 | 30 | input[type="submit"] { 31 | display: block; 32 | border: none; 33 | margin: auto; 34 | background-color: salmon; 35 | padding: 8px 16px; 36 | text-decoration: none; 37 | color: black; 38 | } 39 | 40 | input[type="submit"]:hover { 41 | background-color: #6c8b47; 42 | color: white; 43 | cursor: pointer; 44 | } -------------------------------------------------------------------------------- /lesson-04/base/static/recipe.css: -------------------------------------------------------------------------------- 1 | article { 2 | display: block; 3 | width: 720px; 4 | margin: 32px auto 32px auto; 5 | background-color: #cccccc; 6 | box-shadow: 8px 8px 8px 0 rgba(0, 0, 0, 0.2), 12px 12px 20px 0 rgba(0, 0, 0, 0.19); 7 | border-radius: 16px; 8 | } 9 | 10 | h2 { 11 | padding: 16px; 12 | padding-left: 32px; 13 | background-color: salmon; 14 | border-radius: 16px 16px 0 0; 15 | } 16 | 17 | .band { 18 | background-color: white; 19 | padding: 32px; 20 | } 21 | 22 | .band::after { 23 | content: ""; 24 | clear: both; 25 | display: table; 26 | } 27 | 28 | .thumb { 29 | width: 200px; 30 | float: left; 31 | } 32 | 33 | .thumb>img { 34 | max-width: 200px; 35 | box-shadow: 8px 8px 8px 0 rgba(0, 0, 0, 0.2), 12px 12px 20px 0 rgba(0, 0, 0, 0.19); 36 | } 37 | 38 | .ingredients { 39 | float: left; 40 | margin-left: 32px; 41 | } 42 | 43 | .ingredients>ul { 44 | margin-left: 32px; 45 | } 46 | 47 | .ingredients>ul>li { 48 | margin-top: 16px; 49 | } 50 | 51 | .description { 52 | padding: 32px; 53 | } 54 | 55 | .description>p { 56 | margin: 16px 0 16px 32px; 57 | } 58 | 59 | .preview { 60 | vertical-align: middle; 61 | } 62 | 63 | .preview::after { 64 | content: ""; 65 | clear: both; 66 | display: table; 67 | } 68 | 69 | .preview:hover { 70 | background-color: salmon; 71 | cursor: pointer; 72 | box-shadow: 16px 16px 8px 0 rgba(0, 0, 0, 0.2), 20px 20px 20px 0 rgba(0, 0, 0, 0.19); 73 | } 74 | 75 | .title { 76 | float: left; 77 | width: 420px; 78 | } 79 | 80 | .title h2 { 81 | background-color: transparent; 82 | } 83 | 84 | .small { 85 | float: left; 86 | background-color: white; 87 | width: 300px; 88 | text-align: center; 89 | border-radius: 0 16px 16px 0; 90 | } 91 | 92 | .small>img { 93 | max-width: 200px; 94 | max-height: 150px; 95 | margin: 16px auto; 96 | display: block; 97 | box-shadow: 8px 8px 8px 0 rgba(0, 0, 0, 0.2), 12px 12px 20px 0 rgba(0, 0, 0, 0.19); 98 | } 99 | 100 | .controls { 101 | padding: 13px 32px; 102 | text-align: right; 103 | } 104 | 105 | .controls button { 106 | padding: 4px 8px; 107 | cursor: pointer; 108 | margin: 0 4px; 109 | } -------------------------------------------------------------------------------- /lesson-04/base/static/site.css: -------------------------------------------------------------------------------- 1 | * { 2 | font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif; 3 | font-size: 16pt; 4 | padding: 0; 5 | margin: 0; 6 | box-sizing: border-box; 7 | } 8 | 9 | body { 10 | width: 980px; 11 | margin: 0 auto 0 auto; 12 | } 13 | 14 | header { 15 | padding: 32px; 16 | background-color: #cccccc; 17 | position: relative; 18 | } 19 | 20 | h1 { 21 | font-size: 150%; 22 | display: inline; 23 | } 24 | 25 | main { 26 | background-color: #666666; 27 | padding: 32px; 28 | } 29 | 30 | nav { 31 | display: inline-block; 32 | position: absolute; 33 | right: 16px; 34 | text-align: right; 35 | } 36 | 37 | nav div { 38 | display: inline-block; 39 | } 40 | 41 | nav a { 42 | display: inline-block; 43 | background-color: salmon; 44 | padding: 8px 16px; 45 | margin: 0 8px; 46 | text-decoration: none; 47 | color: black; 48 | } 49 | 50 | nav a:hover { 51 | background-color: #6c8b47; 52 | color: white; 53 | } 54 | 55 | a.active { 56 | background-color: #6c8b47; 57 | color: white; 58 | } 59 | 60 | #user { 61 | display: none; 62 | } 63 | 64 | #guest { 65 | display: none; 66 | } 67 | 68 | #views { 69 | display: none; 70 | } -------------------------------------------------------------------------------- /lesson-04/finished/assets/fish.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-04/finished/assets/fish.jpg -------------------------------------------------------------------------------- /lesson-04/finished/assets/lasagna.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-04/finished/assets/lasagna.jpg -------------------------------------------------------------------------------- /lesson-04/finished/assets/roast.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-04/finished/assets/roast.jpg -------------------------------------------------------------------------------- /lesson-04/finished/src/catalog.js: -------------------------------------------------------------------------------- 1 | import { e } from './dom.js'; 2 | import { showDetails } from './details.js'; 3 | 4 | async function getRecipes() { 5 | const response = await fetch('http://localhost:3030/data/recipes?select=' + encodeURIComponent('_id,name,img')); 6 | const recipes = await response.json(); 7 | 8 | return recipes; 9 | } 10 | 11 | function createRecipePreview(recipe) { 12 | const result = e('article', { className: 'preview', onClick: () => showDetails(recipe._id) }, 13 | e('div', { className: 'title' }, e('h2', {}, recipe.name)), 14 | e('div', { className: 'small' }, e('img', { src: recipe.img })), 15 | ); 16 | 17 | return result; 18 | } 19 | 20 | let main; 21 | let section; 22 | let setActiveNav; 23 | 24 | export function setupCatalog(targetMain, targetSection, onActiveNav) { 25 | main = targetMain; 26 | section = targetSection; 27 | setActiveNav = onActiveNav; 28 | } 29 | 30 | export async function showCatalog() { 31 | setActiveNav('catalogLink'); 32 | section.innerHTML = 'Loading…'; 33 | main.innerHTML = ''; 34 | main.appendChild(section); 35 | 36 | const recipes = await getRecipes(); 37 | const cards = recipes.map(createRecipePreview); 38 | 39 | const fragment = document.createDocumentFragment(); 40 | cards.forEach(c => fragment.appendChild(c)); 41 | section.innerHTML = ''; 42 | section.appendChild(fragment); 43 | } -------------------------------------------------------------------------------- /lesson-04/finished/src/create.js: -------------------------------------------------------------------------------- 1 | import { showDetails } from './details.js'; 2 | 3 | 4 | let main; 5 | let section; 6 | let setActiveNav; 7 | 8 | export function setupCreate(targetMain, targetSection, onActiveNav) { 9 | main = targetMain; 10 | section = targetSection; 11 | setActiveNav = onActiveNav; 12 | const form = targetSection.querySelector('form'); 13 | 14 | form.addEventListener('submit', (ev => { 15 | ev.preventDefault(); 16 | const formData = new FormData(ev.target); 17 | onSubmit([...formData.entries()].reduce((p, [k, v]) => Object.assign(p, { [k]: v }), {})); 18 | })); 19 | 20 | async function onSubmit(data) { 21 | const body = JSON.stringify({ 22 | name: data.name, 23 | img: data.img, 24 | ingredients: data.ingredients.split('\n').map(l => l.trim()).filter(l => l != ''), 25 | steps: data.steps.split('\n').map(l => l.trim()).filter(l => l != '') 26 | }); 27 | 28 | const token = sessionStorage.getItem('authToken'); 29 | if (token == null) { 30 | return alert('You\'re not logged in!'); 31 | } 32 | 33 | try { 34 | const response = await fetch('http://localhost:3030/data/recipes', { 35 | method: 'post', 36 | headers: { 37 | 'Content-Type': 'application/json', 38 | 'X-Authorization': token 39 | }, 40 | body 41 | }); 42 | 43 | if (response.status == 200) { 44 | showDetails((await response.json())._id); 45 | } else { 46 | const error = await response.json(); 47 | throw new Error(error.message); 48 | } 49 | } catch (err) { 50 | alert(err.message); 51 | console.error(err.message); 52 | } 53 | } 54 | } 55 | 56 | export function showCreate() { 57 | setActiveNav('createLink'); 58 | main.innerHTML = ''; 59 | main.appendChild(section); 60 | } -------------------------------------------------------------------------------- /lesson-04/finished/src/details.js: -------------------------------------------------------------------------------- 1 | import { e } from './dom.js'; 2 | import { showEdit } from './edit.js'; 3 | 4 | 5 | async function getRecipeById(id) { 6 | const response = await fetch('http://localhost:3030/data/recipes/' + id); 7 | const recipe = await response.json(); 8 | 9 | return recipe; 10 | } 11 | 12 | async function deleteRecipeById(id) { 13 | const token = sessionStorage.getItem('authToken'); 14 | 15 | try { 16 | const response = await fetch('http://localhost:3030/data/recipes/' + id, { 17 | method: 'delete', 18 | headers: { 19 | 'X-Authorization': token 20 | } 21 | }); 22 | 23 | if (response.status != 200) { 24 | const error = await response.json(); 25 | throw new Error(error.message); 26 | } 27 | 28 | section.innerHTML = ''; 29 | section.appendChild(e('article', {}, e('h2', {}, 'Recipe deleted'))); 30 | } catch (err) { 31 | alert(err.message); 32 | } 33 | } 34 | 35 | function createRecipeCard(recipe) { 36 | const result = e('article', {}, 37 | e('h2', {}, recipe.name), 38 | e('div', { className: 'band' }, 39 | e('div', { className: 'thumb' }, e('img', { src: recipe.img })), 40 | e('div', { className: 'ingredients' }, 41 | e('h3', {}, 'Ingredients:'), 42 | e('ul', {}, recipe.ingredients.map(i => e('li', {}, i))), 43 | ) 44 | ), 45 | e('div', { className: 'description' }, 46 | e('h3', {}, 'Preparation:'), 47 | recipe.steps.map(s => e('p', {}, s)) 48 | ), 49 | ); 50 | 51 | const userId = sessionStorage.getItem('userId'); 52 | if (userId != null && recipe._ownerId == userId) { 53 | result.appendChild(e('div', { className: 'controls' }, 54 | e('button', { onClick: () => showEdit(recipe._id) }, '\u270E Edit'), 55 | e('button', { onClick: onDelete }, '\u2716 Delete'), 56 | )); 57 | } 58 | 59 | return result; 60 | 61 | function onDelete() { 62 | const confirmed = confirm(`Are you sure you want to delete ${recipe.name}?`); 63 | if (confirmed) { 64 | deleteRecipeById(recipe._id); 65 | } 66 | } 67 | } 68 | 69 | let main; 70 | let section; 71 | let setActiveNav; 72 | 73 | export function setupDetails(targetMain, targetSection, onActiveNav) { 74 | main = targetMain; 75 | section = targetSection; 76 | setActiveNav = onActiveNav; 77 | } 78 | 79 | export async function showDetails(id) { 80 | setActiveNav(); 81 | section.innerHTML = 'Loading…'; 82 | main.innerHTML = ''; 83 | main.appendChild(section); 84 | 85 | const recipe = await getRecipeById(id); 86 | section.innerHTML = ''; 87 | section.appendChild(createRecipeCard(recipe)); 88 | } -------------------------------------------------------------------------------- /lesson-04/finished/src/dom.js: -------------------------------------------------------------------------------- 1 | export function e(type, attributes, ...content) { 2 | const result = document.createElement(type); 3 | 4 | for (let [attr, value] of Object.entries(attributes || {})) { 5 | if (attr.substring(0, 2) == 'on') { 6 | result.addEventListener(attr.substring(2).toLocaleLowerCase(), value); 7 | } else { 8 | result[attr] = value; 9 | } 10 | } 11 | 12 | content = content.reduce((a, c) => a.concat(Array.isArray(c) ? c : [c]), []); 13 | 14 | content.forEach(e => { 15 | if (typeof e == 'string' || typeof e == 'number') { 16 | const node = document.createTextNode(e); 17 | result.appendChild(node); 18 | } else { 19 | result.appendChild(e); 20 | } 21 | }); 22 | 23 | return result; 24 | } -------------------------------------------------------------------------------- /lesson-04/finished/src/edit.js: -------------------------------------------------------------------------------- 1 | import { showDetails } from './details.js'; 2 | 3 | 4 | async function getRecipeById(id) { 5 | const response = await fetch('http://localhost:3030/data/recipes/' + id); 6 | const recipe = await response.json(); 7 | 8 | return recipe; 9 | } 10 | 11 | let main; 12 | let section; 13 | let setActiveNav; 14 | let recipeId; 15 | 16 | export function setupEdit(targetMain, targetSection, onActiveNav) { 17 | main = targetMain; 18 | section = targetSection; 19 | setActiveNav = onActiveNav; 20 | const form = targetSection.querySelector('form'); 21 | 22 | form.addEventListener('submit', (ev => { 23 | ev.preventDefault(); 24 | const formData = new FormData(ev.target); 25 | onSubmit([...formData.entries()].reduce((p, [k, v]) => Object.assign(p, { [k]: v }), {})); 26 | })); 27 | 28 | async function onSubmit(data) { 29 | const body = JSON.stringify({ 30 | name: data.name, 31 | img: data.img, 32 | ingredients: data.ingredients.split('\n').map(l => l.trim()).filter(l => l != ''), 33 | steps: data.steps.split('\n').map(l => l.trim()).filter(l => l != '') 34 | }); 35 | 36 | const token = sessionStorage.getItem('authToken'); 37 | if (token == null) { 38 | return alert('You\'re not logged in!'); 39 | } 40 | 41 | try { 42 | const response = await fetch('http://localhost:3030/data/recipes/' + recipeId, { 43 | method: 'put', 44 | headers: { 45 | 'Content-Type': 'application/json', 46 | 'X-Authorization': token 47 | }, 48 | body 49 | }); 50 | 51 | if (response.status == 200) { 52 | showDetails(recipeId); 53 | } else { 54 | const error = await response.json(); 55 | throw new Error(error.message); 56 | } 57 | } catch (err) { 58 | alert(err.message); 59 | console.error(err.message); 60 | } 61 | } 62 | } 63 | 64 | 65 | export async function showEdit(id) { 66 | setActiveNav(); 67 | main.innerHTML = ''; 68 | main.appendChild(section); 69 | 70 | recipeId = id; 71 | const recipe = await getRecipeById(recipeId); 72 | 73 | section.querySelector('[name="name"]').value = recipe.name; 74 | section.querySelector('[name="img"]').value = recipe.img; 75 | section.querySelector('[name="ingredients"]').value = recipe.ingredients.join('\n'); 76 | section.querySelector('[name="steps"]').value = recipe.steps.join('\n'); 77 | } -------------------------------------------------------------------------------- /lesson-04/finished/src/login.js: -------------------------------------------------------------------------------- 1 | import { showCatalog } from './catalog.js'; 2 | 3 | 4 | let main; 5 | let section; 6 | let setActiveNav; 7 | 8 | export function setupLogin(targetMain, targetSection, onActiveNav) { 9 | main = targetMain; 10 | section = targetSection; 11 | setActiveNav = onActiveNav; 12 | 13 | const form = targetSection.querySelector('form'); 14 | 15 | form.addEventListener('submit', (ev => { 16 | ev.preventDefault(); 17 | const formData = new FormData(ev.target); 18 | onSubmit([...formData.entries()].reduce((p, [k, v]) => Object.assign(p, { [k]: v }), {})); 19 | })); 20 | 21 | async function onSubmit(data) { 22 | const body = JSON.stringify({ 23 | email: data.email, 24 | password: data.password, 25 | }); 26 | 27 | try { 28 | const response = await fetch('http://localhost:3030/users/login', { 29 | method: 'post', 30 | headers: { 31 | 'Content-Type': 'application/json' 32 | }, 33 | body 34 | }); 35 | const data = await response.json(); 36 | if (response.status == 200) { 37 | sessionStorage.setItem('authToken', data.accessToken); 38 | sessionStorage.setItem('userId', data._id); 39 | document.getElementById('user').style.display = 'inline-block'; 40 | document.getElementById('guest').style.display = 'none'; 41 | 42 | showCatalog(); 43 | } else { 44 | alert(data.message); 45 | throw new Error(data.message); 46 | } 47 | } catch (err) { 48 | console.error(err.message); 49 | } 50 | } 51 | } 52 | 53 | export function showLogin() { 54 | setActiveNav('loginLink'); 55 | main.innerHTML = ''; 56 | main.appendChild(section); 57 | } -------------------------------------------------------------------------------- /lesson-04/finished/src/register.js: -------------------------------------------------------------------------------- 1 | import { showCatalog } from './catalog.js'; 2 | 3 | 4 | let main; 5 | let section; 6 | let setActiveNav; 7 | 8 | export function setupRegister(targetMain, targetSection, onActiveNav) { 9 | main = targetMain; 10 | section = targetSection; 11 | setActiveNav = onActiveNav; 12 | const form = targetSection.querySelector('form'); 13 | 14 | form.addEventListener('submit', (ev => { 15 | ev.preventDefault(); 16 | const formData = new FormData(ev.target); 17 | onSubmit([...formData.entries()].reduce((p, [k, v]) => Object.assign(p, { [k]: v }), {})); 18 | })); 19 | 20 | async function onSubmit(data) { 21 | if (data.password != data.rePass) { 22 | return alert('Passwords don\'t match'); 23 | } 24 | 25 | const body = JSON.stringify({ 26 | email: data.email, 27 | password: data.password, 28 | }); 29 | 30 | try { 31 | const response = await fetch('http://localhost:3030/users/register', { 32 | method: 'post', 33 | headers: { 34 | 'Content-Type': 'application/json' 35 | }, 36 | body 37 | }); 38 | const data = await response.json(); 39 | if (response.status == 200) { 40 | sessionStorage.setItem('authToken', data.accessToken); 41 | sessionStorage.setItem('userId', data._id); 42 | document.getElementById('user').style.display = 'inline-block'; 43 | document.getElementById('guest').style.display = 'none'; 44 | 45 | showCatalog(); 46 | } else { 47 | alert(data.message); 48 | throw new Error(data.message); 49 | } 50 | } catch (err) { 51 | console.error(err.message); 52 | } 53 | } 54 | } 55 | 56 | 57 | export function showRegister() { 58 | setActiveNav('registerLink'); 59 | main.innerHTML = ''; 60 | main.appendChild(section); 61 | } -------------------------------------------------------------------------------- /lesson-04/finished/static/form.css: -------------------------------------------------------------------------------- 1 | form { 2 | padding: 32px; 3 | } 4 | 5 | label { 6 | display: block; 7 | position: relative; 8 | margin: 16px 0; 9 | text-align: right; 10 | padding-right: 520px; 11 | line-height: 48px; 12 | } 13 | 14 | input[type="text"], input[type="password"], textarea { 15 | position: absolute; 16 | right: 0px; 17 | width: 500px; 18 | padding: 8px; 19 | } 20 | 21 | textarea { 22 | height: 300px; 23 | resize: none; 24 | } 25 | 26 | .ml { 27 | height: 300px; 28 | } 29 | 30 | input[type="submit"] { 31 | display: block; 32 | border: none; 33 | margin: auto; 34 | background-color: salmon; 35 | padding: 8px 16px; 36 | text-decoration: none; 37 | color: black; 38 | } 39 | 40 | input[type="submit"]:hover { 41 | background-color: #6c8b47; 42 | color: white; 43 | cursor: pointer; 44 | } -------------------------------------------------------------------------------- /lesson-04/finished/static/recipe.css: -------------------------------------------------------------------------------- 1 | article { 2 | display: block; 3 | width: 720px; 4 | margin: 32px auto 32px auto; 5 | background-color: #cccccc; 6 | box-shadow: 8px 8px 8px 0 rgba(0, 0, 0, 0.2), 12px 12px 20px 0 rgba(0, 0, 0, 0.19); 7 | border-radius: 16px; 8 | } 9 | 10 | h2 { 11 | padding: 16px; 12 | padding-left: 32px; 13 | background-color: salmon; 14 | border-radius: 16px 16px 0 0; 15 | } 16 | 17 | .band { 18 | background-color: white; 19 | padding: 32px; 20 | } 21 | 22 | .band::after { 23 | content: ""; 24 | clear: both; 25 | display: table; 26 | } 27 | 28 | .thumb { 29 | width: 200px; 30 | float: left; 31 | } 32 | 33 | .thumb>img { 34 | max-width: 200px; 35 | box-shadow: 8px 8px 8px 0 rgba(0, 0, 0, 0.2), 12px 12px 20px 0 rgba(0, 0, 0, 0.19); 36 | } 37 | 38 | .ingredients { 39 | float: left; 40 | margin-left: 32px; 41 | } 42 | 43 | .ingredients>ul { 44 | margin-left: 32px; 45 | } 46 | 47 | .ingredients>ul>li { 48 | margin-top: 16px; 49 | } 50 | 51 | .description { 52 | padding: 32px; 53 | } 54 | 55 | .description>p { 56 | margin: 16px 0 16px 32px; 57 | } 58 | 59 | .preview { 60 | vertical-align: middle; 61 | } 62 | 63 | .preview::after { 64 | content: ""; 65 | clear: both; 66 | display: table; 67 | } 68 | 69 | .preview:hover { 70 | background-color: salmon; 71 | cursor: pointer; 72 | box-shadow: 16px 16px 8px 0 rgba(0, 0, 0, 0.2), 20px 20px 20px 0 rgba(0, 0, 0, 0.19); 73 | } 74 | 75 | .title { 76 | float: left; 77 | width: 420px; 78 | } 79 | 80 | .title h2 { 81 | background-color: transparent; 82 | } 83 | 84 | .small { 85 | float: left; 86 | background-color: white; 87 | width: 300px; 88 | text-align: center; 89 | border-radius: 0 16px 16px 0; 90 | } 91 | 92 | .small>img { 93 | max-width: 200px; 94 | max-height: 150px; 95 | margin: 16px auto; 96 | display: block; 97 | box-shadow: 8px 8px 8px 0 rgba(0, 0, 0, 0.2), 12px 12px 20px 0 rgba(0, 0, 0, 0.19); 98 | } 99 | 100 | .controls { 101 | padding: 13px 32px; 102 | text-align: right; 103 | } 104 | 105 | .controls button { 106 | padding: 4px 8px; 107 | cursor: pointer; 108 | margin: 0 4px; 109 | } -------------------------------------------------------------------------------- /lesson-04/finished/static/site.css: -------------------------------------------------------------------------------- 1 | * { 2 | font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif; 3 | font-size: 16pt; 4 | padding: 0; 5 | margin: 0; 6 | box-sizing: border-box; 7 | } 8 | 9 | body { 10 | width: 980px; 11 | margin: 0 auto 0 auto; 12 | } 13 | 14 | header { 15 | padding: 32px; 16 | background-color: #cccccc; 17 | position: relative; 18 | } 19 | 20 | h1 { 21 | font-size: 150%; 22 | display: inline; 23 | } 24 | 25 | main { 26 | background-color: #666666; 27 | padding: 32px; 28 | } 29 | 30 | nav { 31 | display: inline-block; 32 | position: absolute; 33 | right: 16px; 34 | text-align: right; 35 | } 36 | 37 | nav div { 38 | display: inline-block; 39 | } 40 | 41 | nav a { 42 | display: inline-block; 43 | background-color: salmon; 44 | padding: 8px 16px; 45 | margin: 0 8px; 46 | text-decoration: none; 47 | color: black; 48 | } 49 | 50 | nav a:hover { 51 | background-color: #6c8b47; 52 | color: white; 53 | } 54 | 55 | a.active { 56 | background-color: #6c8b47; 57 | color: white; 58 | } 59 | 60 | #user { 61 | display: none; 62 | } 63 | 64 | #guest { 65 | display: none; 66 | } 67 | 68 | #views { 69 | display: none; 70 | } -------------------------------------------------------------------------------- /lesson-05/base/assets/fish.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-05/base/assets/fish.jpg -------------------------------------------------------------------------------- /lesson-05/base/assets/lasagna.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-05/base/assets/lasagna.jpg -------------------------------------------------------------------------------- /lesson-05/base/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-05/base/assets/logo.png -------------------------------------------------------------------------------- /lesson-05/base/assets/roast.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-05/base/assets/roast.jpg -------------------------------------------------------------------------------- /lesson-05/base/home.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | My Cookbook 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |

My Cookbook

17 | 28 |
29 | 30 |
31 |
32 |
33 |

Welcome to My Cookbook

34 |
35 |
Recently added recipes
36 |
37 |
38 |
39 |
Roast Trout
40 |
41 |
42 |
43 |
44 |
Grilled Duck Fillet
45 |
46 |
47 |
48 |
49 |
Easy Lasagna
50 |
51 |
52 | 55 |
56 |
57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /lesson-05/base/src/catalog.js: -------------------------------------------------------------------------------- 1 | import { e } from './dom.js'; 2 | import { showDetails } from './details.js'; 3 | 4 | async function getRecipes() { 5 | const response = await fetch('http://localhost:3030/data/recipes?select=' + encodeURIComponent('_id,name,img')); 6 | const recipes = await response.json(); 7 | 8 | return recipes; 9 | } 10 | 11 | function createRecipePreview(recipe) { 12 | const result = e('article', { className: 'preview', onClick: () => showDetails(recipe._id) }, 13 | e('div', { className: 'title' }, e('h2', {}, recipe.name)), 14 | e('div', { className: 'small' }, e('img', { src: recipe.img })), 15 | ); 16 | 17 | return result; 18 | } 19 | 20 | let main; 21 | let section; 22 | let setActiveNav; 23 | 24 | export function setupCatalog(targetMain, targetSection, onActiveNav) { 25 | main = targetMain; 26 | section = targetSection; 27 | setActiveNav = onActiveNav; 28 | } 29 | 30 | export async function showCatalog() { 31 | setActiveNav('catalogLink'); 32 | section.innerHTML = 'Loading…'; 33 | main.innerHTML = ''; 34 | main.appendChild(section); 35 | 36 | const recipes = await getRecipes(); 37 | const cards = recipes.map(createRecipePreview); 38 | 39 | const fragment = document.createDocumentFragment(); 40 | cards.forEach(c => fragment.appendChild(c)); 41 | section.innerHTML = ''; 42 | section.appendChild(fragment); 43 | } -------------------------------------------------------------------------------- /lesson-05/base/src/create.js: -------------------------------------------------------------------------------- 1 | import { showDetails } from './details.js'; 2 | 3 | 4 | let main; 5 | let section; 6 | let setActiveNav; 7 | 8 | export function setupCreate(targetMain, targetSection, onActiveNav) { 9 | main = targetMain; 10 | section = targetSection; 11 | setActiveNav = onActiveNav; 12 | const form = targetSection.querySelector('form'); 13 | 14 | form.addEventListener('submit', (ev => { 15 | ev.preventDefault(); 16 | const formData = new FormData(ev.target); 17 | onSubmit([...formData.entries()].reduce((p, [k, v]) => Object.assign(p, { [k]: v }), {})); 18 | })); 19 | 20 | async function onSubmit(data) { 21 | const body = JSON.stringify({ 22 | name: data.name, 23 | img: data.img, 24 | ingredients: data.ingredients.split('\n').map(l => l.trim()).filter(l => l != ''), 25 | steps: data.steps.split('\n').map(l => l.trim()).filter(l => l != '') 26 | }); 27 | 28 | const token = sessionStorage.getItem('authToken'); 29 | if (token == null) { 30 | return alert('You\'re not logged in!'); 31 | } 32 | 33 | try { 34 | const response = await fetch('http://localhost:3030/data/recipes', { 35 | method: 'post', 36 | headers: { 37 | 'Content-Type': 'application/json', 38 | 'X-Authorization': token 39 | }, 40 | body 41 | }); 42 | 43 | if (response.status == 200) { 44 | showDetails((await response.json())._id); 45 | } else { 46 | const error = await response.json(); 47 | throw new Error(error.message); 48 | } 49 | } catch (err) { 50 | alert(err.message); 51 | console.error(err.message); 52 | } 53 | } 54 | } 55 | 56 | export function showCreate() { 57 | setActiveNav('createLink'); 58 | main.innerHTML = ''; 59 | main.appendChild(section); 60 | } -------------------------------------------------------------------------------- /lesson-05/base/src/details.js: -------------------------------------------------------------------------------- 1 | import { e } from './dom.js'; 2 | import { showEdit } from './edit.js'; 3 | 4 | 5 | async function getRecipeById(id) { 6 | const response = await fetch('http://localhost:3030/data/recipes/' + id); 7 | const recipe = await response.json(); 8 | 9 | return recipe; 10 | } 11 | 12 | async function deleteRecipeById(id) { 13 | const token = sessionStorage.getItem('authToken'); 14 | 15 | try { 16 | const response = await fetch('http://localhost:3030/data/recipes/' + id, { 17 | method: 'delete', 18 | headers: { 19 | 'X-Authorization': token 20 | } 21 | }); 22 | 23 | if (response.status != 200) { 24 | const error = await response.json(); 25 | throw new Error(error.message); 26 | } 27 | 28 | section.innerHTML = ''; 29 | section.appendChild(e('article', {}, e('h2', {}, 'Recipe deleted'))); 30 | } catch (err) { 31 | alert(err.message); 32 | } 33 | } 34 | 35 | function createRecipeCard(recipe) { 36 | const result = e('article', {}, 37 | e('h2', {}, recipe.name), 38 | e('div', { className: 'band' }, 39 | e('div', { className: 'thumb' }, e('img', { src: recipe.img })), 40 | e('div', { className: 'ingredients' }, 41 | e('h3', {}, 'Ingredients:'), 42 | e('ul', {}, recipe.ingredients.map(i => e('li', {}, i))), 43 | ) 44 | ), 45 | e('div', { className: 'description' }, 46 | e('h3', {}, 'Preparation:'), 47 | recipe.steps.map(s => e('p', {}, s)) 48 | ), 49 | ); 50 | 51 | const userId = sessionStorage.getItem('userId'); 52 | if (userId != null && recipe._ownerId == userId) { 53 | result.appendChild(e('div', { className: 'controls' }, 54 | e('button', { onClick: () => showEdit(recipe._id) }, '\u270E Edit'), 55 | e('button', { onClick: onDelete }, '\u2716 Delete'), 56 | )); 57 | } 58 | 59 | return result; 60 | 61 | function onDelete() { 62 | const confirmed = confirm(`Are you sure you want to delete ${recipe.name}?`); 63 | if (confirmed) { 64 | deleteRecipeById(recipe._id); 65 | } 66 | } 67 | } 68 | 69 | let main; 70 | let section; 71 | let setActiveNav; 72 | 73 | export function setupDetails(targetMain, targetSection, onActiveNav) { 74 | main = targetMain; 75 | section = targetSection; 76 | setActiveNav = onActiveNav; 77 | } 78 | 79 | export async function showDetails(id) { 80 | setActiveNav(); 81 | section.innerHTML = 'Loading…'; 82 | main.innerHTML = ''; 83 | main.appendChild(section); 84 | 85 | const recipe = await getRecipeById(id); 86 | section.innerHTML = ''; 87 | section.appendChild(createRecipeCard(recipe)); 88 | } -------------------------------------------------------------------------------- /lesson-05/base/src/dom.js: -------------------------------------------------------------------------------- 1 | export function e(type, attributes, ...content) { 2 | const result = document.createElement(type); 3 | 4 | for (let [attr, value] of Object.entries(attributes || {})) { 5 | if (attr.substring(0, 2) == 'on') { 6 | result.addEventListener(attr.substring(2).toLocaleLowerCase(), value); 7 | } else { 8 | result[attr] = value; 9 | } 10 | } 11 | 12 | content = content.reduce((a, c) => a.concat(Array.isArray(c) ? c : [c]), []); 13 | 14 | content.forEach(e => { 15 | if (typeof e == 'string' || typeof e == 'number') { 16 | const node = document.createTextNode(e); 17 | result.appendChild(node); 18 | } else { 19 | result.appendChild(e); 20 | } 21 | }); 22 | 23 | return result; 24 | } -------------------------------------------------------------------------------- /lesson-05/base/src/edit.js: -------------------------------------------------------------------------------- 1 | import { showDetails } from './details.js'; 2 | 3 | 4 | async function getRecipeById(id) { 5 | const response = await fetch('http://localhost:3030/data/recipes/' + id); 6 | const recipe = await response.json(); 7 | 8 | return recipe; 9 | } 10 | 11 | let main; 12 | let section; 13 | let setActiveNav; 14 | let recipeId; 15 | 16 | export function setupEdit(targetMain, targetSection, onActiveNav) { 17 | main = targetMain; 18 | section = targetSection; 19 | setActiveNav = onActiveNav; 20 | const form = targetSection.querySelector('form'); 21 | 22 | form.addEventListener('submit', (ev => { 23 | ev.preventDefault(); 24 | const formData = new FormData(ev.target); 25 | onSubmit([...formData.entries()].reduce((p, [k, v]) => Object.assign(p, { [k]: v }), {})); 26 | })); 27 | 28 | async function onSubmit(data) { 29 | const body = JSON.stringify({ 30 | name: data.name, 31 | img: data.img, 32 | ingredients: data.ingredients.split('\n').map(l => l.trim()).filter(l => l != ''), 33 | steps: data.steps.split('\n').map(l => l.trim()).filter(l => l != '') 34 | }); 35 | 36 | const token = sessionStorage.getItem('authToken'); 37 | if (token == null) { 38 | return alert('You\'re not logged in!'); 39 | } 40 | 41 | try { 42 | const response = await fetch('http://localhost:3030/data/recipes/' + recipeId, { 43 | method: 'put', 44 | headers: { 45 | 'Content-Type': 'application/json', 46 | 'X-Authorization': token 47 | }, 48 | body 49 | }); 50 | 51 | if (response.status == 200) { 52 | showDetails(recipeId); 53 | } else { 54 | const error = await response.json(); 55 | throw new Error(error.message); 56 | } 57 | } catch (err) { 58 | alert(err.message); 59 | console.error(err.message); 60 | } 61 | } 62 | } 63 | 64 | 65 | export async function showEdit(id) { 66 | setActiveNav(); 67 | main.innerHTML = ''; 68 | main.appendChild(section); 69 | 70 | recipeId = id; 71 | const recipe = await getRecipeById(recipeId); 72 | 73 | section.querySelector('[name="name"]').value = recipe.name; 74 | section.querySelector('[name="img"]').value = recipe.img; 75 | section.querySelector('[name="ingredients"]').value = recipe.ingredients.join('\n'); 76 | section.querySelector('[name="steps"]').value = recipe.steps.join('\n'); 77 | } -------------------------------------------------------------------------------- /lesson-05/base/src/login.js: -------------------------------------------------------------------------------- 1 | import { showCatalog } from './catalog.js'; 2 | 3 | 4 | let main; 5 | let section; 6 | let setActiveNav; 7 | 8 | export function setupLogin(targetMain, targetSection, onActiveNav) { 9 | main = targetMain; 10 | section = targetSection; 11 | setActiveNav = onActiveNav; 12 | 13 | const form = targetSection.querySelector('form'); 14 | 15 | form.addEventListener('submit', (ev => { 16 | ev.preventDefault(); 17 | const formData = new FormData(ev.target); 18 | onSubmit([...formData.entries()].reduce((p, [k, v]) => Object.assign(p, { [k]: v }), {})); 19 | })); 20 | 21 | async function onSubmit(data) { 22 | const body = JSON.stringify({ 23 | email: data.email, 24 | password: data.password, 25 | }); 26 | 27 | try { 28 | const response = await fetch('http://localhost:3030/users/login', { 29 | method: 'post', 30 | headers: { 31 | 'Content-Type': 'application/json' 32 | }, 33 | body 34 | }); 35 | const data = await response.json(); 36 | if (response.status == 200) { 37 | sessionStorage.setItem('authToken', data.accessToken); 38 | sessionStorage.setItem('userId', data._id); 39 | document.getElementById('user').style.display = 'inline-block'; 40 | document.getElementById('guest').style.display = 'none'; 41 | 42 | showCatalog(); 43 | } else { 44 | alert(data.message); 45 | throw new Error(data.message); 46 | } 47 | } catch (err) { 48 | console.error(err.message); 49 | } 50 | } 51 | } 52 | 53 | export function showLogin() { 54 | setActiveNav('loginLink'); 55 | main.innerHTML = ''; 56 | main.appendChild(section); 57 | } -------------------------------------------------------------------------------- /lesson-05/base/src/register.js: -------------------------------------------------------------------------------- 1 | import { showCatalog } from './catalog.js'; 2 | 3 | 4 | let main; 5 | let section; 6 | let setActiveNav; 7 | 8 | export function setupRegister(targetMain, targetSection, onActiveNav) { 9 | main = targetMain; 10 | section = targetSection; 11 | setActiveNav = onActiveNav; 12 | const form = targetSection.querySelector('form'); 13 | 14 | form.addEventListener('submit', (ev => { 15 | ev.preventDefault(); 16 | const formData = new FormData(ev.target); 17 | onSubmit([...formData.entries()].reduce((p, [k, v]) => Object.assign(p, { [k]: v }), {})); 18 | })); 19 | 20 | async function onSubmit(data) { 21 | if (data.password != data.rePass) { 22 | return alert('Passwords don\'t match'); 23 | } 24 | 25 | const body = JSON.stringify({ 26 | email: data.email, 27 | password: data.password, 28 | }); 29 | 30 | try { 31 | const response = await fetch('http://localhost:3030/users/register', { 32 | method: 'post', 33 | headers: { 34 | 'Content-Type': 'application/json' 35 | }, 36 | body 37 | }); 38 | const data = await response.json(); 39 | if (response.status == 200) { 40 | sessionStorage.setItem('authToken', data.accessToken); 41 | sessionStorage.setItem('userId', data._id); 42 | document.getElementById('user').style.display = 'inline-block'; 43 | document.getElementById('guest').style.display = 'none'; 44 | 45 | showCatalog(); 46 | } else { 47 | alert(data.message); 48 | throw new Error(data.message); 49 | } 50 | } catch (err) { 51 | console.error(err.message); 52 | } 53 | } 54 | } 55 | 56 | 57 | export function showRegister() { 58 | setActiveNav('registerLink'); 59 | main.innerHTML = ''; 60 | main.appendChild(section); 61 | } -------------------------------------------------------------------------------- /lesson-05/base/static/form.css: -------------------------------------------------------------------------------- 1 | form { 2 | padding: 32px; 3 | } 4 | 5 | label { 6 | display: block; 7 | position: relative; 8 | margin: 16px 0; 9 | text-align: right; 10 | padding-right: 520px; 11 | line-height: 48px; 12 | } 13 | 14 | input[type="text"], input[type="password"], textarea { 15 | position: absolute; 16 | right: 0px; 17 | width: 500px; 18 | padding: 8px; 19 | } 20 | 21 | textarea { 22 | height: 300px; 23 | resize: none; 24 | } 25 | 26 | .ml { 27 | height: 300px; 28 | } 29 | 30 | input[type="submit"] { 31 | display: block; 32 | border: none; 33 | margin: auto; 34 | background-color: salmon; 35 | padding: 8px 16px; 36 | text-decoration: none; 37 | color: black; 38 | } 39 | 40 | input[type="submit"]:hover { 41 | background-color: #6c8b47; 42 | color: white; 43 | cursor: pointer; 44 | } -------------------------------------------------------------------------------- /lesson-05/base/static/site.css: -------------------------------------------------------------------------------- 1 | * { 2 | font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif; 3 | font-size: 16pt; 4 | padding: 0; 5 | margin: 0; 6 | box-sizing: border-box; 7 | } 8 | 9 | body { 10 | width: 980px; 11 | margin: 0 auto 0 auto; 12 | } 13 | 14 | header { 15 | padding: 32px; 16 | background-color: #cccccc; 17 | position: relative; 18 | } 19 | 20 | h1 { 21 | font-size: 32px; 22 | line-height: 32px; 23 | vertical-align: bottom; 24 | display: inline; 25 | } 26 | 27 | h1 img { 28 | height: 32px; 29 | vertical-align: bottom; 30 | margin-right: 8px; 31 | } 32 | 33 | h1 a { 34 | font-size: 32px; 35 | text-decoration: none; 36 | color: black; 37 | } 38 | 39 | h1 a:visited { 40 | color: black; 41 | } 42 | 43 | main { 44 | background-color: #666666; 45 | padding: 32px; 46 | } 47 | 48 | nav { 49 | display: inline-block; 50 | position: absolute; 51 | right: 16px; 52 | text-align: right; 53 | } 54 | 55 | nav div { 56 | display: inline-block; 57 | } 58 | 59 | nav a { 60 | display: inline-block; 61 | background-color: salmon; 62 | padding: 8px 16px; 63 | margin: 0 8px; 64 | text-decoration: none; 65 | color: black; 66 | } 67 | 68 | nav a:visited { 69 | color: black; 70 | } 71 | 72 | nav a:hover { 73 | background-color: #6c8b47; 74 | color: white; 75 | } 76 | 77 | a.active { 78 | background-color: #6c8b47; 79 | color: white; 80 | } 81 | 82 | #user { 83 | display: none; 84 | } 85 | 86 | #guest { 87 | display: none; 88 | } 89 | 90 | #views { 91 | display: none; 92 | } 93 | 94 | .hero { 95 | display: block; 96 | width: 850px; 97 | margin: 32px auto 32px auto; 98 | background-color: #cccccc; 99 | box-shadow: 8px 8px 8px 0 rgba(0, 0, 0, 0.2), 12px 12px 20px 0 rgba(0, 0, 0, 0.19); 100 | padding: 32px; 101 | } 102 | 103 | .section-title { 104 | display: block; 105 | width: 850px; 106 | margin: 32px auto 32px auto; 107 | background-color: #cccccc; 108 | box-shadow: 8px 8px 8px 0 rgba(0, 0, 0, 0.2), 12px 12px 20px 0 rgba(0, 0, 0, 0.19); 109 | padding: 16px 32px; 110 | } 111 | 112 | a { 113 | color: #40522a; 114 | } 115 | 116 | a:visited { 117 | color: #40522a; 118 | } -------------------------------------------------------------------------------- /lesson-05/base/tests/e2e.test.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | const { chromium } = require('playwright-chromium'); 3 | const { expect } = require('chai'); 4 | 5 | 6 | let browser; 7 | let context; 8 | let page; 9 | 10 | describe('E2E tests', function () { 11 | this.timeout(6000); 12 | 13 | before(async () => { 14 | // browser = await chromium.launch({ headless: false, slowMo: 500 }); 15 | browser = await chromium.launch(); 16 | }); 17 | 18 | after(async () => { 19 | await browser.close(); 20 | }); 21 | 22 | beforeEach(async () => { 23 | context = await browser.newContext(); 24 | 25 | // block intensive resources and external calls (page routes take precedence) 26 | await context.route('**/*.{png,jpg,jpeg}', route => route.abort()); 27 | await context.route(url => { 28 | return url.hostname != 'localhost'; 29 | }, route => route.abort()); 30 | 31 | page = await context.newPage(); 32 | }); 33 | 34 | afterEach(async () => { 35 | await page.close(); 36 | await context.close(); 37 | }); 38 | 39 | }); 40 | -------------------------------------------------------------------------------- /lesson-05/finished/assets/fish.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-05/finished/assets/fish.jpg -------------------------------------------------------------------------------- /lesson-05/finished/assets/lasagna.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-05/finished/assets/lasagna.jpg -------------------------------------------------------------------------------- /lesson-05/finished/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-05/finished/assets/logo.png -------------------------------------------------------------------------------- /lesson-05/finished/assets/roast.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-05/finished/assets/roast.jpg -------------------------------------------------------------------------------- /lesson-05/finished/src/api/data.js: -------------------------------------------------------------------------------- 1 | import createApi from './api.js'; 2 | 3 | const api = createApi(null, null, (msg) => alert(msg)); 4 | 5 | const endpoints = { 6 | RECIPE_LIST: 'data/recipes?select=' + encodeURIComponent('_id,name,img'), 7 | RECIPE_COUNT: 'data/recipes?count', 8 | RECENT_RECIPES: 'data/recipes?select=' + encodeURIComponent('_id,name,img') + '&sortBy=' + encodeURIComponent('_createdOn desc'), 9 | RECIPES: 'data/recipes', 10 | RECIPE_BY_ID: 'data/recipes/' 11 | }; 12 | 13 | export const login = api.login.bind(api); 14 | export const regster = api.register.bind(api); 15 | export const logout = api.logout.bind(api); 16 | 17 | export async function getRecipes(page = 1) { 18 | return await api.get(endpoints.RECIPE_LIST + `&offset=${(page - 1) * 5}&pageSize=5`); 19 | } 20 | 21 | export async function getRecipeCount() { 22 | return await api.get(endpoints.RECIPE_COUNT); 23 | } 24 | 25 | export async function getRecent() { 26 | return await api.get(endpoints.RECENT_RECIPES); 27 | } 28 | 29 | export async function getRecipeById(id) { 30 | return await api.get(endpoints.RECIPE_BY_ID + id); 31 | } 32 | 33 | export async function createRecipe(recipe) { 34 | return await api.post(endpoints.RECIPES, recipe); 35 | } 36 | 37 | export async function editRecipe(id, recipe) { 38 | return await api.put(endpoints.RECIPE_BY_ID + id, recipe); 39 | } 40 | 41 | export async function deleteRecipeById(id) { 42 | return await api.delete(endpoints.RECIPE_BY_ID + id); 43 | } -------------------------------------------------------------------------------- /lesson-05/finished/src/app.js: -------------------------------------------------------------------------------- 1 | import { createNav } from './navigation.js'; 2 | import { logout as apiLogout } from './api/data.js'; 3 | 4 | import { setupHome } from './views/home.js'; 5 | import { setupCatalog } from './views/catalog.js'; 6 | import { setupCreate } from './views/create.js'; 7 | import { setupLogin } from './views/login.js'; 8 | import { setupRegister } from './views/register.js'; 9 | import { setupDetails } from './views/details.js'; 10 | import { setupEdit } from './views/edit.js'; 11 | 12 | 13 | window.addEventListener('load', async () => { 14 | const main = document.querySelector('main'); 15 | const navbar = document.querySelector('nav'); 16 | const navigation = createNav(main, navbar); 17 | 18 | navigation.registerView('home', document.getElementById('home'), setupHome); 19 | navigation.registerView('catalog', document.getElementById('catalog'), setupCatalog, 'catalogLink'); 20 | navigation.registerView('details', document.getElementById('details'), setupDetails); 21 | navigation.registerView('login', document.getElementById('login'), setupLogin, 'loginLink'); 22 | navigation.registerView('register', document.getElementById('register'), setupRegister, 'registerLink'); 23 | navigation.registerView('create', document.getElementById('create'), setupCreate, 'createLink'); 24 | navigation.registerView('edit', document.getElementById('edit'), setupEdit); 25 | document.getElementById('views').remove(); 26 | 27 | navigation.setUserNav(); 28 | document.getElementById('logoutBtn').addEventListener('click', logout); 29 | 30 | // Start application in catalog view 31 | navigation.goTo('home'); 32 | 33 | 34 | async function logout() { 35 | try { 36 | await apiLogout(); 37 | navigation.updateNav(); 38 | navigation.goTo('catalog'); 39 | } catch (err) { 40 | alert(err.message); 41 | } 42 | } 43 | }); 44 | -------------------------------------------------------------------------------- /lesson-05/finished/src/dom.js: -------------------------------------------------------------------------------- 1 | export function e(type, attributes, ...content) { 2 | const result = document.createElement(type); 3 | 4 | for (let [attr, value] of Object.entries(attributes || {})) { 5 | if (attr.substring(0, 2) == 'on') { 6 | result.addEventListener(attr.substring(2).toLocaleLowerCase(), value); 7 | } else { 8 | result[attr] = value; 9 | } 10 | } 11 | 12 | content = content.reduce((a, c) => a.concat(Array.isArray(c) ? c : [c]), []); 13 | 14 | content.forEach(e => { 15 | if (typeof e == 'string' || typeof e == 'number') { 16 | const node = document.createTextNode(e); 17 | result.appendChild(node); 18 | } else { 19 | result.appendChild(e); 20 | } 21 | }); 22 | 23 | return result; 24 | } -------------------------------------------------------------------------------- /lesson-05/finished/src/navigation.js: -------------------------------------------------------------------------------- 1 | export function createNav(main, navbar) { 2 | const views = {}; 3 | const links = {}; 4 | 5 | setupNavigation(); 6 | 7 | const navigator = { 8 | registerView, 9 | goTo, 10 | setUserNav 11 | }; 12 | 13 | return navigator; 14 | 15 | function setupNavigation() { 16 | navbar.addEventListener('click', (ev) => { 17 | if (ev.target.tagName == 'A') { 18 | const handlerName = links[ev.target.id]; 19 | if (handlerName) { 20 | ev.preventDefault(); 21 | goTo(handlerName); 22 | } 23 | } 24 | }); 25 | } 26 | 27 | async function goTo(name, ...params) { 28 | main.innerHTML = ''; 29 | const result = await views[name](...params); 30 | main.appendChild(result); 31 | } 32 | 33 | function registerView(name, section, setup, navId) { 34 | const execute = setup(section, navigator); 35 | 36 | views[name] = (...params) => { 37 | [...navbar.querySelectorAll('a')].forEach(a => a.classList.remove('active')); 38 | if (navId) { 39 | navbar.querySelector('#' + navId).classList.add('active'); 40 | } 41 | return execute(...params); 42 | }; 43 | if (navId) { 44 | links[navId] = name; 45 | } 46 | } 47 | 48 | function setUserNav() { 49 | if (sessionStorage.getItem('userToken') != null) { 50 | document.getElementById('user').style.display = 'inline-block'; 51 | document.getElementById('guest').style.display = 'none'; 52 | } else { 53 | document.getElementById('user').style.display = 'none'; 54 | document.getElementById('guest').style.display = 'inline-block'; 55 | } 56 | } 57 | } 58 | 59 | -------------------------------------------------------------------------------- /lesson-05/finished/src/views/catalog.js: -------------------------------------------------------------------------------- 1 | import { e } from '../dom.js'; 2 | import { getRecipes, getRecipeCount } from '../api/data.js'; 3 | 4 | 5 | export function setupCatalog(section, nav) { 6 | return showCatalog; 7 | 8 | async function showCatalog(page = 1) { 9 | section.innerHTML = 'Loading…'; 10 | 11 | const recipes = await getRecipes(page); 12 | const count = await getRecipeCount(); 13 | const pages = Math.ceil(count / 5); 14 | const cards = recipes.map(createRecipePreview); 15 | 16 | const fragment = document.createDocumentFragment(); 17 | fragment.appendChild(createPager(page, pages, true)); 18 | cards.forEach(c => fragment.appendChild(c)); 19 | fragment.appendChild(createPager(page, pages)); 20 | 21 | section.innerHTML = ''; 22 | section.appendChild(fragment); 23 | 24 | return section; 25 | } 26 | 27 | function createPager(page, pages, header) { 28 | const type = header ? 'header' : 'footer'; 29 | const result = e(type, { className: 'section-title' }, `Page ${page} of ${pages}`); 30 | if (page > 1) { 31 | result.appendChild(e('a', { href: '/catalog', className: 'pager', onClick: (e) => { e.preventDefault(); nav.goTo('catalog', page - 1); } }, '< Prev')); 32 | } 33 | if (page < pages) { 34 | result.appendChild(e('a', { href: '/catalog', className: 'pager', onClick: (e) => { e.preventDefault(); nav.goTo('catalog', page + 1); } }, 'Next >')); 35 | } 36 | return result; 37 | } 38 | 39 | function createRecipePreview(recipe) { 40 | const result = e('article', { className: 'preview', onClick: () => nav.goTo('details', recipe._id) }, 41 | e('div', { className: 'title' }, e('h2', {}, recipe.name)), 42 | e('div', { className: 'small' }, e('img', { src: recipe.img })), 43 | ); 44 | 45 | return result; 46 | } 47 | 48 | } -------------------------------------------------------------------------------- /lesson-05/finished/src/views/create.js: -------------------------------------------------------------------------------- 1 | import { createRecipe } from '../api/data.js'; 2 | 3 | 4 | export function setupCreate(section, nav) { 5 | const form = section.querySelector('form'); 6 | 7 | form.addEventListener('submit', (ev => { 8 | ev.preventDefault(); 9 | const formData = new FormData(ev.target); 10 | onSubmit([...formData.entries()].reduce((p, [k, v]) => Object.assign(p, { [k]: v }), {})); 11 | })); 12 | 13 | return showCreate; 14 | 15 | function showCreate() { 16 | return section; 17 | } 18 | 19 | async function onSubmit(data) { 20 | const body = { 21 | name: data.name, 22 | img: data.img, 23 | ingredients: data.ingredients.split('\n').map(l => l.trim()).filter(l => l != ''), 24 | steps: data.steps.split('\n').map(l => l.trim()).filter(l => l != '') 25 | }; 26 | 27 | try { 28 | const result = await createRecipe(body); 29 | nav.goTo('details', result._id); 30 | } catch (err) { 31 | alert(err.message); 32 | } 33 | } 34 | } 35 | 36 | -------------------------------------------------------------------------------- /lesson-05/finished/src/views/details.js: -------------------------------------------------------------------------------- 1 | import { e } from '../dom.js'; 2 | import { getRecipeById, deleteRecipeById } from '../api/data.js'; 3 | 4 | 5 | export function setupDetails(section, nav) { 6 | return showDetails; 7 | 8 | async function showDetails(id) { 9 | section.innerHTML = 'Loading…'; 10 | 11 | const recipe = await getRecipeById(id); 12 | section.innerHTML = ''; 13 | section.appendChild(createRecipeCard(recipe)); 14 | 15 | return section; 16 | } 17 | 18 | function createRecipeCard(recipe) { 19 | const result = e('article', {}, 20 | e('h2', {}, recipe.name), 21 | e('div', { className: 'band' }, 22 | e('div', { className: 'thumb' }, e('img', { src: recipe.img })), 23 | e('div', { className: 'ingredients' }, 24 | e('h3', {}, 'Ingredients:'), 25 | e('ul', {}, recipe.ingredients.map(i => e('li', {}, i))), 26 | ) 27 | ), 28 | e('div', { className: 'description' }, 29 | e('h3', {}, 'Preparation:'), 30 | recipe.steps.map(s => e('p', {}, s)) 31 | ), 32 | ); 33 | 34 | const userId = sessionStorage.getItem('userId'); 35 | if (userId != null && recipe._ownerId == userId) { 36 | result.appendChild(e('div', { className: 'controls' }, 37 | e('button', { onClick: () => nav.goTo('edit', recipe._id) }, '\u270E Edit'), 38 | e('button', { onClick: onDelete }, '\u2716 Delete'), 39 | )); 40 | } 41 | 42 | return result; 43 | 44 | async function onDelete() { 45 | const confirmed = confirm(`Are you sure you want to delete ${recipe.name}?`); 46 | if (confirmed) { 47 | try { 48 | await deleteRecipeById(recipe._id); 49 | section.innerHTML = ''; 50 | section.appendChild(e('article', {}, e('h2', {}, 'Recipe deleted'))); 51 | } catch (err) { 52 | alert(err.message); 53 | } 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /lesson-05/finished/src/views/edit.js: -------------------------------------------------------------------------------- 1 | import { getRecipeById, editRecipe } from '../api/data.js'; 2 | 3 | 4 | export function setupEdit(section, nav) { 5 | let recipeId; 6 | const form = section.querySelector('form'); 7 | 8 | form.addEventListener('submit', (ev => { 9 | ev.preventDefault(); 10 | const formData = new FormData(ev.target); 11 | onSubmit([...formData.entries()].reduce((p, [k, v]) => Object.assign(p, { [k]: v }), {})); 12 | })); 13 | 14 | return showEdit; 15 | 16 | async function showEdit(id) { 17 | recipeId = id; 18 | const recipe = await getRecipeById(recipeId); 19 | 20 | section.querySelector('[name="name"]').value = recipe.name; 21 | section.querySelector('[name="img"]').value = recipe.img; 22 | section.querySelector('[name="ingredients"]').value = recipe.ingredients.join('\n'); 23 | section.querySelector('[name="steps"]').value = recipe.steps.join('\n'); 24 | 25 | return section; 26 | } 27 | 28 | async function onSubmit(data) { 29 | const body = { 30 | name: data.name, 31 | img: data.img, 32 | ingredients: data.ingredients.split('\n').map(l => l.trim()).filter(l => l != ''), 33 | steps: data.steps.split('\n').map(l => l.trim()).filter(l => l != '') 34 | }; 35 | 36 | try { 37 | await editRecipe(recipeId, body); 38 | nav.goTo('details', recipeId); 39 | } catch (err) { 40 | alert(err.message); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lesson-05/finished/src/views/home.js: -------------------------------------------------------------------------------- 1 | import { e } from '../dom.js'; 2 | import { getRecent } from '../api/data.js'; 3 | 4 | 5 | export function setupHome(section, nav) { 6 | const container = section.querySelector('.recent-recipes'); 7 | return showHome; 8 | 9 | async function showHome() { 10 | container.innerHTML = 'Loading…'; 11 | 12 | const recipes = await getRecent(); 13 | const cards = recipes.map(createRecipePreview); 14 | 15 | const fragment = document.createDocumentFragment(); 16 | 17 | while (cards.length > 0) { 18 | fragment.appendChild(cards.shift()); 19 | if (cards.length > 0) { 20 | fragment.appendChild(createSpacer()); 21 | } 22 | } 23 | container.innerHTML = ''; 24 | container.appendChild(fragment); 25 | 26 | return section; 27 | } 28 | 29 | function createRecipePreview(recipe) { 30 | const result = e('article', { className: 'recent', onClick: () => nav.goTo('details', recipe._id) }, 31 | e('div', { className: 'recent-preview' }, e('img', { src: recipe.img })), 32 | e('div', { className: 'recent-title' }, recipe.name), 33 | ); 34 | 35 | return result; 36 | } 37 | 38 | function createSpacer() { 39 | return e('div', { className: 'recent-space' }); 40 | } 41 | 42 | } -------------------------------------------------------------------------------- /lesson-05/finished/src/views/login.js: -------------------------------------------------------------------------------- 1 | import { login } from '../api/data.js'; 2 | 3 | 4 | export function setupLogin(section, nav) { 5 | const form = section.querySelector('form'); 6 | 7 | form.addEventListener('submit', (ev => { 8 | ev.preventDefault(); 9 | const formData = new FormData(ev.target); 10 | onSubmit([...formData.entries()].reduce((p, [k, v]) => Object.assign(p, { [k]: v }), {})); 11 | })); 12 | 13 | return showLogin; 14 | 15 | function showLogin() { 16 | return section; 17 | } 18 | 19 | async function onSubmit(data) { 20 | try { 21 | console.log('logging in'); 22 | await login(data.email, data.password); 23 | nav.setUserNav(); 24 | nav.goTo('catalog'); 25 | } catch (err) { 26 | alert(err.message); 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /lesson-05/finished/src/views/register.js: -------------------------------------------------------------------------------- 1 | import { regster } from '../api/data.js'; 2 | 3 | 4 | export function setupRegister(section, nav) { 5 | const form = section.querySelector('form'); 6 | 7 | form.addEventListener('submit', (ev => { 8 | ev.preventDefault(); 9 | const formData = new FormData(ev.target); 10 | onSubmit([...formData.entries()].reduce((p, [k, v]) => Object.assign(p, { [k]: v }), {})); 11 | })); 12 | 13 | return showRegister; 14 | 15 | function showRegister() { 16 | return section; 17 | } 18 | 19 | async function onSubmit(data) { 20 | if (data.password != data.rePass) { 21 | return alert('Passwords don\'t match'); 22 | } 23 | 24 | try { 25 | await regster(data.email, data.password); 26 | nav.setUserNav(); 27 | nav.goTo('catalog'); 28 | } catch (err) { 29 | alert(err.message); 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /lesson-05/finished/static/form.css: -------------------------------------------------------------------------------- 1 | form { 2 | padding: 32px; 3 | } 4 | 5 | label { 6 | display: block; 7 | position: relative; 8 | margin: 16px 0; 9 | text-align: right; 10 | padding-right: 520px; 11 | line-height: 48px; 12 | } 13 | 14 | input[type="text"], input[type="password"], textarea { 15 | position: absolute; 16 | right: 0px; 17 | width: 500px; 18 | padding: 8px; 19 | } 20 | 21 | textarea { 22 | height: 300px; 23 | resize: none; 24 | } 25 | 26 | .ml { 27 | height: 300px; 28 | } 29 | 30 | input[type="submit"] { 31 | display: block; 32 | border: none; 33 | margin: auto; 34 | background-color: salmon; 35 | padding: 8px 16px; 36 | text-decoration: none; 37 | color: black; 38 | } 39 | 40 | input[type="submit"]:hover { 41 | background-color: #6c8b47; 42 | color: white; 43 | cursor: pointer; 44 | } -------------------------------------------------------------------------------- /lesson-05/finished/static/site.css: -------------------------------------------------------------------------------- 1 | * { 2 | font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif; 3 | font-size: 16pt; 4 | padding: 0; 5 | margin: 0; 6 | box-sizing: border-box; 7 | } 8 | 9 | body { 10 | width: 980px; 11 | margin: 0 auto 0 auto; 12 | } 13 | 14 | header { 15 | padding: 32px; 16 | background-color: #cccccc; 17 | position: relative; 18 | } 19 | 20 | h1 { 21 | font-size: 32px; 22 | line-height: 32px; 23 | vertical-align: bottom; 24 | display: inline; 25 | } 26 | 27 | h1 img { 28 | height: 32px; 29 | vertical-align: bottom; 30 | margin-right: 8px; 31 | } 32 | 33 | h1 a { 34 | font-size: 32px; 35 | text-decoration: none; 36 | color: black; 37 | } 38 | 39 | h1 a:visited { 40 | color: black; 41 | } 42 | 43 | main { 44 | background-color: #666666; 45 | padding: 32px; 46 | } 47 | 48 | nav { 49 | display: inline-block; 50 | position: absolute; 51 | right: 16px; 52 | text-align: right; 53 | } 54 | 55 | nav div { 56 | display: inline-block; 57 | } 58 | 59 | nav a { 60 | display: inline-block; 61 | background-color: salmon; 62 | padding: 8px 16px; 63 | margin: 0 8px; 64 | text-decoration: none; 65 | color: black; 66 | } 67 | 68 | nav a:visited { 69 | color: black; 70 | } 71 | 72 | nav a:hover { 73 | background-color: #6c8b47; 74 | color: white; 75 | } 76 | 77 | a.active { 78 | background-color: #6c8b47; 79 | color: white; 80 | } 81 | 82 | #user { 83 | display: none; 84 | } 85 | 86 | #guest { 87 | display: none; 88 | } 89 | 90 | #views { 91 | display: none; 92 | } 93 | 94 | .hero { 95 | display: block; 96 | width: 850px; 97 | margin: 32px auto 32px auto; 98 | background-color: #cccccc; 99 | box-shadow: 8px 8px 8px 0 rgba(0, 0, 0, 0.2), 12px 12px 20px 0 rgba(0, 0, 0, 0.19); 100 | padding: 32px; 101 | } 102 | 103 | .section-title { 104 | display: block; 105 | width: 850px; 106 | margin: 32px auto 32px auto; 107 | background-color: #cccccc; 108 | box-shadow: 8px 8px 8px 0 rgba(0, 0, 0, 0.2), 12px 12px 20px 0 rgba(0, 0, 0, 0.19); 109 | padding: 16px 32px; 110 | } 111 | 112 | a { 113 | color: #40522a; 114 | } 115 | 116 | a:visited { 117 | color: #40522a; 118 | } 119 | 120 | .pager { 121 | margin: 0 8px; 122 | } -------------------------------------------------------------------------------- /lesson-06/base/assets/fish.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-06/base/assets/fish.jpg -------------------------------------------------------------------------------- /lesson-06/base/assets/lasagna.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-06/base/assets/lasagna.jpg -------------------------------------------------------------------------------- /lesson-06/base/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-06/base/assets/logo.png -------------------------------------------------------------------------------- /lesson-06/base/assets/roast.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-06/base/assets/roast.jpg -------------------------------------------------------------------------------- /lesson-06/base/src/api/data.js: -------------------------------------------------------------------------------- 1 | import createApi from './api.js'; 2 | 3 | const api = createApi(null, null, (msg) => alert(msg)); 4 | 5 | const endpoints = { 6 | RECIPE_LIST: 'data/recipes?select=' + encodeURIComponent('_id,name,img'), 7 | RECIPE_COUNT: 'data/recipes?count', 8 | RECENT_RECIPES: 'data/recipes?select=' + encodeURIComponent('_id,name,img') + '&sortBy=' + encodeURIComponent('_createdOn desc'), 9 | RECIPES: 'data/recipes', 10 | RECIPE_BY_ID: 'data/recipes/' 11 | }; 12 | 13 | export const login = api.login.bind(api); 14 | export const regster = api.register.bind(api); 15 | export const logout = api.logout.bind(api); 16 | 17 | export async function getRecipes(page = 1) { 18 | return await api.get(endpoints.RECIPE_LIST + `&offset=${(page - 1) * 5}&pageSize=5`); 19 | } 20 | 21 | export async function getRecipeCount() { 22 | return await api.get(endpoints.RECIPE_COUNT); 23 | } 24 | 25 | export async function getRecent() { 26 | return await api.get(endpoints.RECENT_RECIPES); 27 | } 28 | 29 | export async function getRecipeById(id) { 30 | return await api.get(endpoints.RECIPE_BY_ID + id); 31 | } 32 | 33 | export async function createRecipe(recipe) { 34 | return await api.post(endpoints.RECIPES, recipe); 35 | } 36 | 37 | export async function editRecipe(id, recipe) { 38 | return await api.put(endpoints.RECIPE_BY_ID + id, recipe); 39 | } 40 | 41 | export async function deleteRecipeById(id) { 42 | return await api.delete(endpoints.RECIPE_BY_ID + id); 43 | } -------------------------------------------------------------------------------- /lesson-06/base/src/app.js: -------------------------------------------------------------------------------- 1 | import { createNav } from './navigation.js'; 2 | import { logout as apiLogout } from './api/data.js'; 3 | 4 | import { setupHome } from './views/home.js'; 5 | import { setupCatalog } from './views/catalog.js'; 6 | import { setupCreate } from './views/create.js'; 7 | import { setupLogin } from './views/login.js'; 8 | import { setupRegister } from './views/register.js'; 9 | import { setupDetails } from './views/details.js'; 10 | import { setupEdit } from './views/edit.js'; 11 | 12 | 13 | window.addEventListener('load', async () => { 14 | const main = document.querySelector('main'); 15 | const navbar = document.querySelector('nav'); 16 | const navigation = createNav(main, navbar); 17 | 18 | navigation.registerView('home', document.getElementById('home'), setupHome); 19 | navigation.registerView('catalog', document.getElementById('catalog'), setupCatalog, 'catalogLink'); 20 | navigation.registerView('details', document.getElementById('details'), setupDetails); 21 | navigation.registerView('login', document.getElementById('login'), setupLogin, 'loginLink'); 22 | navigation.registerView('register', document.getElementById('register'), setupRegister, 'registerLink'); 23 | navigation.registerView('create', document.getElementById('create'), setupCreate, 'createLink'); 24 | navigation.registerView('edit', document.getElementById('edit'), setupEdit); 25 | document.getElementById('views').remove(); 26 | 27 | navigation.setUserNav(); 28 | document.getElementById('logoutBtn').addEventListener('click', logout); 29 | 30 | // Start application in catalog view 31 | navigation.goTo('home'); 32 | 33 | 34 | async function logout() { 35 | try { 36 | await apiLogout(); 37 | navigation.updateNav(); 38 | navigation.goTo('catalog'); 39 | } catch (err) { 40 | alert(err.message); 41 | } 42 | } 43 | }); 44 | -------------------------------------------------------------------------------- /lesson-06/base/src/dom.js: -------------------------------------------------------------------------------- 1 | export function e(type, attributes, ...content) { 2 | const result = document.createElement(type); 3 | 4 | for (let [attr, value] of Object.entries(attributes || {})) { 5 | if (attr.substring(0, 2) == 'on') { 6 | result.addEventListener(attr.substring(2).toLocaleLowerCase(), value); 7 | } else { 8 | result[attr] = value; 9 | } 10 | } 11 | 12 | content = content.reduce((a, c) => a.concat(Array.isArray(c) ? c : [c]), []); 13 | 14 | content.forEach(e => { 15 | if (typeof e == 'string' || typeof e == 'number') { 16 | const node = document.createTextNode(e); 17 | result.appendChild(node); 18 | } else { 19 | result.appendChild(e); 20 | } 21 | }); 22 | 23 | return result; 24 | } -------------------------------------------------------------------------------- /lesson-06/base/src/navigation.js: -------------------------------------------------------------------------------- 1 | export function createNav(main, navbar) { 2 | const views = {}; 3 | const links = {}; 4 | 5 | setupNavigation(); 6 | 7 | const navigator = { 8 | registerView, 9 | goTo, 10 | setUserNav 11 | }; 12 | 13 | return navigator; 14 | 15 | function setupNavigation() { 16 | navbar.addEventListener('click', (ev) => { 17 | if (ev.target.tagName == 'A') { 18 | const handlerName = links[ev.target.id]; 19 | if (handlerName) { 20 | ev.preventDefault(); 21 | goTo(handlerName); 22 | } 23 | } 24 | }); 25 | } 26 | 27 | async function goTo(name, ...params) { 28 | main.innerHTML = ''; 29 | const result = await views[name](...params); 30 | main.appendChild(result); 31 | } 32 | 33 | function registerView(name, section, setup, navId) { 34 | const execute = setup(section, navigator); 35 | 36 | views[name] = (...params) => { 37 | [...navbar.querySelectorAll('a')].forEach(a => a.classList.remove('active')); 38 | if (navId) { 39 | navbar.querySelector('#' + navId).classList.add('active'); 40 | } 41 | return execute(...params); 42 | }; 43 | if (navId) { 44 | links[navId] = name; 45 | } 46 | } 47 | 48 | function setUserNav() { 49 | if (sessionStorage.getItem('userToken') != null) { 50 | document.getElementById('user').style.display = 'inline-block'; 51 | document.getElementById('guest').style.display = 'none'; 52 | } else { 53 | document.getElementById('user').style.display = 'none'; 54 | document.getElementById('guest').style.display = 'inline-block'; 55 | } 56 | } 57 | } 58 | 59 | -------------------------------------------------------------------------------- /lesson-06/base/src/views/catalog.js: -------------------------------------------------------------------------------- 1 | import { e } from '../dom.js'; 2 | import { getRecipes, getRecipeCount } from '../api/data.js'; 3 | 4 | 5 | export function setupCatalog(section, nav) { 6 | return showCatalog; 7 | 8 | async function showCatalog(page = 1) { 9 | section.innerHTML = 'Loading…'; 10 | 11 | const recipes = await getRecipes(page); 12 | const count = await getRecipeCount(); 13 | const pages = Math.ceil(count / 5); 14 | const cards = recipes.map(createRecipePreview); 15 | 16 | const fragment = document.createDocumentFragment(); 17 | fragment.appendChild(createPager(page, pages, true)); 18 | cards.forEach(c => fragment.appendChild(c)); 19 | fragment.appendChild(createPager(page, pages)); 20 | 21 | section.innerHTML = ''; 22 | section.appendChild(fragment); 23 | 24 | return section; 25 | } 26 | 27 | function createPager(page, pages, header) { 28 | const type = header ? 'header' : 'footer'; 29 | const result = e(type, { className: 'section-title' }, `Page ${page} of ${pages}`); 30 | if (page > 1) { 31 | result.appendChild(e('a', { href: '/catalog', className: 'pager', onClick: (e) => { e.preventDefault(); nav.goTo('catalog', page - 1); } }, '< Prev')); 32 | } 33 | if (page < pages) { 34 | result.appendChild(e('a', { href: '/catalog', className: 'pager', onClick: (e) => { e.preventDefault(); nav.goTo('catalog', page + 1); } }, 'Next >')); 35 | } 36 | return result; 37 | } 38 | 39 | function createRecipePreview(recipe) { 40 | const result = e('article', { className: 'preview', onClick: () => nav.goTo('details', recipe._id) }, 41 | e('div', { className: 'title' }, e('h2', {}, recipe.name)), 42 | e('div', { className: 'small' }, e('img', { src: recipe.img })), 43 | ); 44 | 45 | return result; 46 | } 47 | 48 | } -------------------------------------------------------------------------------- /lesson-06/base/src/views/create.js: -------------------------------------------------------------------------------- 1 | import { createRecipe } from '../api/data.js'; 2 | 3 | 4 | export function setupCreate(section, nav) { 5 | const form = section.querySelector('form'); 6 | 7 | form.addEventListener('submit', (ev => { 8 | ev.preventDefault(); 9 | const formData = new FormData(ev.target); 10 | onSubmit([...formData.entries()].reduce((p, [k, v]) => Object.assign(p, { [k]: v }), {})); 11 | })); 12 | 13 | return showCreate; 14 | 15 | function showCreate() { 16 | return section; 17 | } 18 | 19 | async function onSubmit(data) { 20 | const body = { 21 | name: data.name, 22 | img: data.img, 23 | ingredients: data.ingredients.split('\n').map(l => l.trim()).filter(l => l != ''), 24 | steps: data.steps.split('\n').map(l => l.trim()).filter(l => l != '') 25 | }; 26 | 27 | try { 28 | const result = await createRecipe(body); 29 | nav.goTo('details', result._id); 30 | } catch (err) { 31 | alert(err.message); 32 | } 33 | } 34 | } 35 | 36 | -------------------------------------------------------------------------------- /lesson-06/base/src/views/details.js: -------------------------------------------------------------------------------- 1 | import { e } from '../dom.js'; 2 | import { getRecipeById, deleteRecipeById } from '../api/data.js'; 3 | 4 | 5 | export function setupDetails(section, nav) { 6 | return showDetails; 7 | 8 | async function showDetails(id) { 9 | section.innerHTML = 'Loading…'; 10 | 11 | const recipe = await getRecipeById(id); 12 | section.innerHTML = ''; 13 | section.appendChild(createRecipeCard(recipe)); 14 | 15 | return section; 16 | } 17 | 18 | function createRecipeCard(recipe) { 19 | const result = e('article', {}, 20 | e('h2', {}, recipe.name), 21 | e('div', { className: 'band' }, 22 | e('div', { className: 'thumb' }, e('img', { src: recipe.img })), 23 | e('div', { className: 'ingredients' }, 24 | e('h3', {}, 'Ingredients:'), 25 | e('ul', {}, recipe.ingredients.map(i => e('li', {}, i))), 26 | ) 27 | ), 28 | e('div', { className: 'description' }, 29 | e('h3', {}, 'Preparation:'), 30 | recipe.steps.map(s => e('p', {}, s)) 31 | ), 32 | ); 33 | 34 | const userId = sessionStorage.getItem('userId'); 35 | if (userId != null && recipe._ownerId == userId) { 36 | result.appendChild(e('div', { className: 'controls' }, 37 | e('button', { onClick: () => nav.goTo('edit', recipe._id) }, '\u270E Edit'), 38 | e('button', { onClick: onDelete }, '\u2716 Delete'), 39 | )); 40 | } 41 | 42 | return result; 43 | 44 | async function onDelete() { 45 | const confirmed = confirm(`Are you sure you want to delete ${recipe.name}?`); 46 | if (confirmed) { 47 | try { 48 | await deleteRecipeById(recipe._id); 49 | section.innerHTML = ''; 50 | section.appendChild(e('article', {}, e('h2', {}, 'Recipe deleted'))); 51 | } catch (err) { 52 | alert(err.message); 53 | } 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /lesson-06/base/src/views/edit.js: -------------------------------------------------------------------------------- 1 | import { getRecipeById, editRecipe } from '../api/data.js'; 2 | 3 | 4 | export function setupEdit(section, nav) { 5 | let recipeId; 6 | const form = section.querySelector('form'); 7 | 8 | form.addEventListener('submit', (ev => { 9 | ev.preventDefault(); 10 | const formData = new FormData(ev.target); 11 | onSubmit([...formData.entries()].reduce((p, [k, v]) => Object.assign(p, { [k]: v }), {})); 12 | })); 13 | 14 | return showEdit; 15 | 16 | async function showEdit(id) { 17 | recipeId = id; 18 | const recipe = await getRecipeById(recipeId); 19 | 20 | section.querySelector('[name="name"]').value = recipe.name; 21 | section.querySelector('[name="img"]').value = recipe.img; 22 | section.querySelector('[name="ingredients"]').value = recipe.ingredients.join('\n'); 23 | section.querySelector('[name="steps"]').value = recipe.steps.join('\n'); 24 | 25 | return section; 26 | } 27 | 28 | async function onSubmit(data) { 29 | const body = { 30 | name: data.name, 31 | img: data.img, 32 | ingredients: data.ingredients.split('\n').map(l => l.trim()).filter(l => l != ''), 33 | steps: data.steps.split('\n').map(l => l.trim()).filter(l => l != '') 34 | }; 35 | 36 | try { 37 | await editRecipe(recipeId, body); 38 | nav.goTo('details', recipeId); 39 | } catch (err) { 40 | alert(err.message); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lesson-06/base/src/views/home.js: -------------------------------------------------------------------------------- 1 | import { e } from '../dom.js'; 2 | import { getRecent } from '../api/data.js'; 3 | 4 | 5 | export function setupHome(section, nav) { 6 | const container = section.querySelector('.recent-recipes'); 7 | return showHome; 8 | 9 | async function showHome() { 10 | container.innerHTML = 'Loading…'; 11 | 12 | const recipes = await getRecent(); 13 | const cards = recipes.map(createRecipePreview); 14 | 15 | const fragment = document.createDocumentFragment(); 16 | 17 | while (cards.length > 0) { 18 | fragment.appendChild(cards.shift()); 19 | if (cards.length > 0) { 20 | fragment.appendChild(createSpacer()); 21 | } 22 | } 23 | container.innerHTML = ''; 24 | container.appendChild(fragment); 25 | 26 | return section; 27 | } 28 | 29 | function createRecipePreview(recipe) { 30 | const result = e('article', { className: 'recent', onClick: () => nav.goTo('details', recipe._id) }, 31 | e('div', { className: 'recent-preview' }, e('img', { src: recipe.img })), 32 | e('div', { className: 'recent-title' }, recipe.name), 33 | ); 34 | 35 | return result; 36 | } 37 | 38 | function createSpacer() { 39 | return e('div', { className: 'recent-space' }); 40 | } 41 | 42 | } -------------------------------------------------------------------------------- /lesson-06/base/src/views/login.js: -------------------------------------------------------------------------------- 1 | import { login } from '../api/data.js'; 2 | 3 | 4 | export function setupLogin(section, nav) { 5 | const form = section.querySelector('form'); 6 | 7 | form.addEventListener('submit', (ev => { 8 | ev.preventDefault(); 9 | const formData = new FormData(ev.target); 10 | onSubmit([...formData.entries()].reduce((p, [k, v]) => Object.assign(p, { [k]: v }), {})); 11 | })); 12 | 13 | return showLogin; 14 | 15 | function showLogin() { 16 | return section; 17 | } 18 | 19 | async function onSubmit(data) { 20 | try { 21 | console.log('logging in'); 22 | await login(data.email, data.password); 23 | nav.setUserNav(); 24 | nav.goTo('catalog'); 25 | } catch (err) { 26 | alert(err.message); 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /lesson-06/base/src/views/register.js: -------------------------------------------------------------------------------- 1 | import { regster } from '../api/data.js'; 2 | 3 | 4 | export function setupRegister(section, nav) { 5 | const form = section.querySelector('form'); 6 | 7 | form.addEventListener('submit', (ev => { 8 | ev.preventDefault(); 9 | const formData = new FormData(ev.target); 10 | onSubmit([...formData.entries()].reduce((p, [k, v]) => Object.assign(p, { [k]: v }), {})); 11 | })); 12 | 13 | return showRegister; 14 | 15 | function showRegister() { 16 | return section; 17 | } 18 | 19 | async function onSubmit(data) { 20 | if (data.password != data.rePass) { 21 | return alert('Passwords don\'t match'); 22 | } 23 | 24 | try { 25 | await regster(data.email, data.password); 26 | nav.setUserNav(); 27 | nav.goTo('catalog'); 28 | } catch (err) { 29 | alert(err.message); 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /lesson-06/base/static/form.css: -------------------------------------------------------------------------------- 1 | form { 2 | padding: 32px; 3 | } 4 | 5 | label { 6 | display: block; 7 | position: relative; 8 | margin: 16px 0; 9 | text-align: right; 10 | padding-right: 520px; 11 | line-height: 48px; 12 | } 13 | 14 | input[type="text"], input[type="password"], textarea { 15 | position: absolute; 16 | right: 0px; 17 | width: 500px; 18 | padding: 8px; 19 | } 20 | 21 | textarea { 22 | height: 300px; 23 | resize: none; 24 | } 25 | 26 | .ml { 27 | height: 300px; 28 | } 29 | 30 | input[type="submit"] { 31 | display: block; 32 | border: none; 33 | margin: auto; 34 | background-color: salmon; 35 | padding: 8px 16px; 36 | text-decoration: none; 37 | color: black; 38 | } 39 | 40 | input[type="submit"]:hover { 41 | background-color: #6c8b47; 42 | color: white; 43 | cursor: pointer; 44 | } -------------------------------------------------------------------------------- /lesson-06/base/static/site.css: -------------------------------------------------------------------------------- 1 | * { 2 | font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif; 3 | font-size: 16pt; 4 | padding: 0; 5 | margin: 0; 6 | box-sizing: border-box; 7 | } 8 | 9 | body { 10 | width: 980px; 11 | margin: 0 auto 0 auto; 12 | } 13 | 14 | header { 15 | padding: 32px; 16 | background-color: #cccccc; 17 | position: relative; 18 | } 19 | 20 | h1 { 21 | font-size: 32px; 22 | line-height: 32px; 23 | vertical-align: bottom; 24 | display: inline; 25 | } 26 | 27 | h1 img { 28 | height: 32px; 29 | vertical-align: bottom; 30 | margin-right: 8px; 31 | } 32 | 33 | h1 a { 34 | font-size: 32px; 35 | text-decoration: none; 36 | color: black; 37 | } 38 | 39 | h1 a:visited { 40 | color: black; 41 | } 42 | 43 | main { 44 | background-color: #666666; 45 | padding: 32px; 46 | } 47 | 48 | nav { 49 | display: inline-block; 50 | position: absolute; 51 | right: 16px; 52 | text-align: right; 53 | } 54 | 55 | nav div { 56 | display: inline-block; 57 | } 58 | 59 | nav a { 60 | display: inline-block; 61 | background-color: salmon; 62 | padding: 8px 16px; 63 | margin: 0 8px; 64 | text-decoration: none; 65 | color: black; 66 | } 67 | 68 | nav a:visited { 69 | color: black; 70 | } 71 | 72 | nav a:hover { 73 | background-color: #6c8b47; 74 | color: white; 75 | } 76 | 77 | a.active { 78 | background-color: #6c8b47; 79 | color: white; 80 | } 81 | 82 | #user { 83 | display: none; 84 | } 85 | 86 | #guest { 87 | display: none; 88 | } 89 | 90 | #views { 91 | display: none; 92 | } 93 | 94 | .hero { 95 | display: block; 96 | width: 850px; 97 | margin: 32px auto 32px auto; 98 | background-color: #cccccc; 99 | box-shadow: 8px 8px 8px 0 rgba(0, 0, 0, 0.2), 12px 12px 20px 0 rgba(0, 0, 0, 0.19); 100 | padding: 32px; 101 | } 102 | 103 | .section-title { 104 | display: block; 105 | width: 850px; 106 | margin: 32px auto 32px auto; 107 | background-color: #cccccc; 108 | box-shadow: 8px 8px 8px 0 rgba(0, 0, 0, 0.2), 12px 12px 20px 0 rgba(0, 0, 0, 0.19); 109 | padding: 16px 32px; 110 | } 111 | 112 | a { 113 | color: #40522a; 114 | } 115 | 116 | a:visited { 117 | color: #40522a; 118 | } 119 | 120 | .pager { 121 | margin: 0 8px; 122 | } -------------------------------------------------------------------------------- /lesson-06/finished/assets/fish.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-06/finished/assets/fish.jpg -------------------------------------------------------------------------------- /lesson-06/finished/assets/lasagna.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-06/finished/assets/lasagna.jpg -------------------------------------------------------------------------------- /lesson-06/finished/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-06/finished/assets/logo.png -------------------------------------------------------------------------------- /lesson-06/finished/assets/roast.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-06/finished/assets/roast.jpg -------------------------------------------------------------------------------- /lesson-06/finished/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | My Cookbook 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |

My Cookbook

18 | 29 |
30 | 31 |
32 |
33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /lesson-06/finished/src/api/data.js: -------------------------------------------------------------------------------- 1 | import createApi from './api.js'; 2 | 3 | const api = createApi(null, null, (msg) => alert(msg)); 4 | 5 | const endpoints = { 6 | RECIPE_LIST: 'data/recipes?select=' + encodeURIComponent('_id,name,img'), 7 | RECIPE_COUNT: 'data/recipes?count', 8 | RECENT_RECIPES: 'data/recipes?select=' + encodeURIComponent('_id,name,img') + '&sortBy=' + encodeURIComponent('_createdOn desc'), 9 | RECIPES: 'data/recipes', 10 | RECIPE_BY_ID: 'data/recipes/', 11 | COMMENTS: 'data/comments', 12 | COMMENTS_BY_RECIPE_ID: 'data/comments?where=' + encodeURIComponent('recipeId='), 13 | }; 14 | 15 | export const login = api.login.bind(api); 16 | export const regster = api.register.bind(api); 17 | export const logout = api.logout.bind(api); 18 | 19 | export async function getRecipes(page = 1) { 20 | return await api.get(endpoints.RECIPE_LIST + `&offset=${(page - 1) * 5}&pageSize=5`); 21 | } 22 | 23 | export async function getRecipeCount() { 24 | return await api.get(endpoints.RECIPE_COUNT); 25 | } 26 | 27 | export async function getRecent() { 28 | return await api.get(endpoints.RECENT_RECIPES); 29 | } 30 | 31 | export async function getRecipeById(id) { 32 | return await api.get(endpoints.RECIPE_BY_ID + id); 33 | } 34 | 35 | export async function createRecipe(recipe) { 36 | return await api.post(endpoints.RECIPES, recipe); 37 | } 38 | 39 | export async function editRecipe(id, recipe) { 40 | return await api.put(endpoints.RECIPE_BY_ID + id, recipe); 41 | } 42 | 43 | export async function deleteRecipeById(id) { 44 | return await api.delete(endpoints.RECIPE_BY_ID + id); 45 | } 46 | 47 | export async function getCommentsByRecipeId(recipeId) { 48 | return await api.get(endpoints.COMMENTS_BY_RECIPE_ID + encodeURIComponent(`"${recipeId}"`) + '&load=' + encodeURIComponent('author=_ownerId:users')); 49 | } 50 | 51 | export async function createComment(comment) { 52 | return await api.post(endpoints.COMMENTS, comment); 53 | } -------------------------------------------------------------------------------- /lesson-06/finished/src/app.js: -------------------------------------------------------------------------------- 1 | import { createNav } from './navigation.js'; 2 | import { logout as apiLogout } from './api/data.js'; 3 | 4 | import { setupHome } from './views/home.js'; 5 | import { setupCatalog } from './views/catalog.js'; 6 | import { setupCreate } from './views/create.js'; 7 | import { setupLogin } from './views/login.js'; 8 | import { setupRegister } from './views/register.js'; 9 | import { setupDetails } from './views/details.js'; 10 | import { setupEdit, setupDeleted } from './views/edit.js'; 11 | 12 | 13 | window.addEventListener('load', async () => { 14 | const main = document.querySelector('main'); 15 | const navbar = document.querySelector('nav'); 16 | const navigation = createNav(main, navbar); 17 | 18 | navigation.registerView('home', setupHome); 19 | navigation.registerView('catalog', setupCatalog, 'catalogLink'); 20 | navigation.registerView('details', setupDetails); 21 | navigation.registerView('login', setupLogin, 'loginLink'); 22 | navigation.registerView('register', setupRegister, 'registerLink'); 23 | navigation.registerView('create', setupCreate, 'createLink'); 24 | navigation.registerView('edit', setupEdit); 25 | navigation.registerView('deleted', setupDeleted); 26 | 27 | navigation.setUserNav(); 28 | document.getElementById('logoutBtn').addEventListener('click', logout); 29 | 30 | // Start application in catalog view 31 | navigation.goTo('home'); 32 | 33 | 34 | async function logout() { 35 | try { 36 | await apiLogout(); 37 | navigation.setUserNav(); 38 | navigation.goTo('catalog'); 39 | } catch (err) { 40 | alert(err.message); 41 | } 42 | } 43 | }); 44 | -------------------------------------------------------------------------------- /lesson-06/finished/src/dom.js: -------------------------------------------------------------------------------- 1 | import { html, render } from 'https://unpkg.com/lit-html?module'; 2 | import { until } from 'https://unpkg.com/lit-html/directives/until?module'; 3 | 4 | 5 | export { 6 | html, 7 | render, 8 | until 9 | }; 10 | 11 | export function e(type, attributes, ...content) { 12 | const result = document.createElement(type); 13 | 14 | for (let [attr, value] of Object.entries(attributes || {})) { 15 | if (attr.substring(0, 2) == 'on') { 16 | result.addEventListener(attr.substring(2).toLocaleLowerCase(), value); 17 | } else { 18 | result[attr] = value; 19 | } 20 | } 21 | 22 | content = content.reduce((a, c) => a.concat(Array.isArray(c) ? c : [c]), []); 23 | 24 | content.forEach(e => { 25 | if (typeof e == 'string' || typeof e == 'number') { 26 | const node = document.createTextNode(e); 27 | result.appendChild(node); 28 | } else { 29 | result.appendChild(e); 30 | } 31 | }); 32 | 33 | return result; 34 | } -------------------------------------------------------------------------------- /lesson-06/finished/src/navigation.js: -------------------------------------------------------------------------------- 1 | import { render } from './dom.js'; 2 | 3 | 4 | export function createNav(main, navbar) { 5 | const views = {}; 6 | const links = {}; 7 | const forms = {}; 8 | 9 | setupNavigation(); 10 | setupForms(); 11 | 12 | const navigator = { 13 | registerView, 14 | goTo, 15 | setUserNav, 16 | registerForm 17 | }; 18 | 19 | return navigator; 20 | 21 | function setupNavigation() { 22 | navbar.addEventListener('click', (ev) => { 23 | if (ev.target.tagName == 'A') { 24 | const handlerName = links[ev.target.id]; 25 | if (handlerName) { 26 | ev.preventDefault(); 27 | goTo(handlerName); 28 | } 29 | } 30 | }); 31 | } 32 | 33 | async function goTo(name, ...params) { 34 | const result = await views[name](...params); 35 | render(result, main); 36 | } 37 | 38 | function registerView(name, setup, navId) { 39 | const execute = setup(navigator); 40 | 41 | views[name] = (...params) => { 42 | [...navbar.querySelectorAll('a')].forEach(a => a.classList.remove('active')); 43 | if (navId) { 44 | navbar.querySelector('#' + navId).classList.add('active'); 45 | } 46 | return execute(...params); 47 | }; 48 | if (navId) { 49 | links[navId] = name; 50 | } 51 | } 52 | 53 | function setUserNav() { 54 | if (sessionStorage.getItem('userToken') != null) { 55 | document.getElementById('user').style.display = 'inline-block'; 56 | document.getElementById('guest').style.display = 'none'; 57 | } else { 58 | document.getElementById('user').style.display = 'none'; 59 | document.getElementById('guest').style.display = 'inline-block'; 60 | } 61 | } 62 | 63 | function setupForms() { 64 | document.body.addEventListener('submit', onSubmit); 65 | } 66 | 67 | function registerForm(name, handler) { 68 | forms[name] = handler; 69 | } 70 | 71 | function onSubmit(ev) { 72 | const handler = forms[ev.target.id]; 73 | if (typeof handler == 'function') { 74 | ev.preventDefault(); 75 | const formData = new FormData(ev.target); 76 | const body = [...formData.entries()].reduce((p, [k, v]) => Object.assign(p, { [k]: v }), {}); 77 | handler(body); 78 | } 79 | } 80 | } 81 | 82 | -------------------------------------------------------------------------------- /lesson-06/finished/src/views/catalog.js: -------------------------------------------------------------------------------- 1 | import { e, html } from '../dom.js'; 2 | import { getRecipes, getRecipeCount } from '../api/data.js'; 3 | 4 | 5 | const catalogTemplate = (recipes, goTo, page, pages) => html` 6 |
7 |
${pager(goTo, page, pages)}
8 | ${recipes.map(r => recipePreview(r, goTo))} 9 | 10 |
`; 11 | 12 | const recipePreview = (recipe, goTo) => html` 13 |
goTo('details', recipe._id)}> 14 |
15 |

${recipe.name}

16 |
17 |
18 |
`; 19 | 20 | const pager = (goTo, page, pages) => html` 21 | Page ${page} of ${pages} 22 | ${page > 1 ? html` changePage(e, goTo, page - 1)}>< Prev` : ''} 23 | ${page < pages ? html` changePage(e, goTo, page + 1)}>Next >` : ''}`; 24 | 25 | function changePage(e, goTo, newPage) { 26 | e.preventDefault(); 27 | goTo('catalog', newPage); 28 | } 29 | 30 | export function setupCatalog(nav) { 31 | return showCatalog; 32 | 33 | async function showCatalog(page = 1) { 34 | const recipes = await getRecipes(page); 35 | const count = await getRecipeCount(); 36 | const pages = Math.ceil(count / 5); 37 | 38 | return catalogTemplate(recipes, nav.goTo, page, pages); 39 | } 40 | } -------------------------------------------------------------------------------- /lesson-06/finished/src/views/comments.js: -------------------------------------------------------------------------------- 1 | import { render, html, until } from '../dom.js'; 2 | import { getCommentsByRecipeId, createComment } from '../api/data.js'; 3 | 4 | 5 | const commentsTemplate = (recipe, commentForm, comments) => html` 6 |
7 | Comments for ${recipe.name} 8 |
9 | ${commentForm} 10 |
11 | ${until((async () => commentsList(await comments))(), 'Loading comments...')} 12 |
`; 13 | 14 | const commentFormTemplate = (active, toggleForm) => html` 15 |
16 | ${active 17 | ? html` 18 |

New comment

19 |
20 | 21 | 22 |
` 23 | : html`
`} 24 |
`; 25 | 26 | const commentsList = (comments) => html` 27 | `; 30 | 31 | const comment = (data) => html` 32 |
  • 33 |
    ${data.author.email}
    34 |

    ${data.content}

    35 |
  • `; 36 | 37 | export function showComments(recipe, nav) { 38 | let formActive = false; 39 | nav.registerForm('commentForm', onSubmit); 40 | const commentsPromise = getCommentsByRecipeId(recipe._id); 41 | const result = document.createElement('div'); 42 | renderTemplate(commentsPromise); 43 | 44 | return result; 45 | 46 | function renderTemplate(comments) { 47 | render(commentsTemplate(recipe, createForm(formActive, toggleForm), comments), result); 48 | } 49 | 50 | function toggleForm() { 51 | formActive = !formActive; 52 | renderTemplate(commentsPromise); 53 | } 54 | 55 | async function onSubmit(data) { 56 | toggleForm(); 57 | const comments = await commentsPromise; 58 | 59 | const comment = { 60 | content: data.content, 61 | recipeId: recipe._id 62 | }; 63 | 64 | const result = await createComment(comment); 65 | 66 | comments.unshift(result); 67 | renderTemplate(comments); 68 | } 69 | } 70 | 71 | function createForm(formActive, toggleForm) { 72 | const userId = sessionStorage.getItem('userId'); 73 | if (userId == null) { 74 | return ''; 75 | } else { 76 | return commentFormTemplate(formActive, toggleForm); 77 | } 78 | } -------------------------------------------------------------------------------- /lesson-06/finished/src/views/create.js: -------------------------------------------------------------------------------- 1 | import { html } from '../dom.js'; 2 | import { createRecipe } from '../api/data.js'; 3 | 4 | 5 | const createTemplate = () => html` 6 |
    7 |
    8 |

    New Recipe

    9 |
    10 | 11 | 12 | 14 | 16 | 17 |
    18 |
    19 |
    `; 20 | 21 | export function setupCreate(nav) { 22 | nav.registerForm('createForm', onSubmit); 23 | return showCreate; 24 | 25 | function showCreate() { 26 | return createTemplate(); 27 | } 28 | 29 | async function onSubmit(data) { 30 | const body = { 31 | name: data.name, 32 | img: data.img, 33 | ingredients: data.ingredients.split('\n').map(l => l.trim()).filter(l => l != ''), 34 | steps: data.steps.split('\n').map(l => l.trim()).filter(l => l != '') 35 | }; 36 | 37 | try { 38 | const result = await createRecipe(body); 39 | nav.goTo('details', result._id); 40 | } catch (err) { 41 | alert(err.message); 42 | } 43 | } 44 | } 45 | 46 | -------------------------------------------------------------------------------- /lesson-06/finished/src/views/details.js: -------------------------------------------------------------------------------- 1 | import { html } from '../dom.js'; 2 | import { getRecipeById, deleteRecipeById } from '../api/data.js'; 3 | import { showComments } from './comments.js'; 4 | 5 | 6 | const detailsTemplate = (recipe, isOwner, nav, onDelete) => html` 7 |
    8 | ${recipeCard(recipe, isOwner, nav.goTo, onDelete)} 9 | ${showComments(recipe, nav)} 10 |
    `; 11 | 12 | const recipeCard = (recipe, isOwner, goTo, onDelete) => html` 13 |
    14 |

    ${recipe.name}

    15 |
    16 |
    17 |
    18 |

    Ingredients:

    19 |
      20 | ${recipe.ingredients.map(i => html`
    • ${i}
    • `)} 21 |
    22 |
    23 |
    24 |
    25 |

    Preparation:

    26 | ${recipe.steps.map(s => html`

    ${s}

    `)} 27 |
    28 | ${isOwner 29 | ? html` 30 |
    31 | 32 | 33 |
    ` 34 | : ''} 35 |
    `; 36 | 37 | 38 | export function setupDetails(nav) { 39 | return showDetails; 40 | 41 | async function showDetails(id) { 42 | const recipe = await getRecipeById(id); 43 | 44 | const userId = sessionStorage.getItem('userId'); 45 | const isOwner = userId != null && recipe._ownerId == userId; 46 | 47 | return detailsTemplate(recipe, isOwner, nav, () => onDelete(recipe)); 48 | } 49 | 50 | async function onDelete(recipe) { 51 | const confirmed = confirm(`Are you sure you want to delete ${recipe.name}?`); 52 | if (confirmed) { 53 | try { 54 | await deleteRecipeById(recipe._id); 55 | nav.goTo('deleted'); 56 | } catch (err) { 57 | alert(err.message); 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /lesson-06/finished/src/views/edit.js: -------------------------------------------------------------------------------- 1 | import { html } from '../dom.js'; 2 | import { getRecipeById, editRecipe } from '../api/data.js'; 3 | 4 | 5 | const editTemplate = (recipe) => html` 6 |
    7 |
    8 |

    Edit Recipe

    9 |
    10 | 11 | 12 | 15 | 18 | 19 |
    20 |
    21 |
    `; 22 | 23 | export function setupEdit(nav) { 24 | return showEdit; 25 | 26 | async function showEdit(recipeId) { 27 | nav.registerForm('editForm', onSubmit); 28 | const recipe = await getRecipeById(recipeId); 29 | 30 | return editTemplate(recipe); 31 | 32 | async function onSubmit(data) { 33 | const body = { 34 | name: data.name, 35 | img: data.img, 36 | ingredients: data.ingredients.split('\n').map(l => l.trim()).filter(l => l != ''), 37 | steps: data.steps.split('\n').map(l => l.trim()).filter(l => l != '') 38 | }; 39 | 40 | try { 41 | await editRecipe(recipeId, body); 42 | nav.goTo('details', recipeId); 43 | } catch (err) { 44 | alert(err.message); 45 | } 46 | } 47 | } 48 | } 49 | 50 | export function setupDeleted() { 51 | return () => html` 52 |
    53 |
    54 |

    Recipe deleted

    55 |
    56 |
    `; 57 | } -------------------------------------------------------------------------------- /lesson-06/finished/src/views/home.js: -------------------------------------------------------------------------------- 1 | import { html } from '../dom.js'; 2 | import { getRecent } from '../api/data.js'; 3 | 4 | 5 | const homeTemplate = (recentRecipes, goTo) => html` 6 |
    7 |
    8 |

    Welcome to My Cookbook

    9 |
    10 |
    Recently added recipes
    11 |
    12 | ${recentRecipes[0] ? recentRecipe(recentRecipes[0], goTo) : ''} 13 | ${spacer()} 14 | ${recentRecipes[1] ? recentRecipe(recentRecipes[1], goTo) : ''} 15 | ${spacer()} 16 | ${recentRecipes[2] ? recentRecipe(recentRecipes[2], goTo) : ''} 17 |
    18 | 21 |
    `; 22 | 23 | const recentRecipe = (recipe, goTo) => html` 24 |
    goTo('details', recipe._id)}> 25 |
    26 |
    ${recipe.name}
    27 |
    `; 28 | 29 | const spacer = () => html`
    `; 30 | 31 | export function setupHome(nav) { 32 | return showHome; 33 | 34 | async function showHome() { 35 | const recipes = await getRecent(); 36 | 37 | return homeTemplate(recipes, nav.goTo); 38 | } 39 | } -------------------------------------------------------------------------------- /lesson-06/finished/src/views/login.js: -------------------------------------------------------------------------------- 1 | import { html } from '../dom.js'; 2 | import { login } from '../api/data.js'; 3 | 4 | 5 | const loginTemplate = () => html` 6 |
    7 |
    8 |

    Login

    9 |
    10 | 11 | 12 | 13 |
    14 |
    15 |
    `; 16 | 17 | 18 | export function setupLogin(nav) { 19 | nav.registerForm('loginForm', onSubmit); 20 | return showLogin; 21 | 22 | function showLogin() { 23 | return loginTemplate(); 24 | } 25 | 26 | async function onSubmit(data) { 27 | try { 28 | await login(data.email, data.password); 29 | nav.setUserNav(); 30 | nav.goTo('catalog'); 31 | } catch (err) { 32 | alert(err.message); 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /lesson-06/finished/src/views/register.js: -------------------------------------------------------------------------------- 1 | import { html } from '../dom.js'; 2 | import { regster } from '../api/data.js'; 3 | 4 | 5 | const registerTemplate = () => html` 6 |
    7 |
    8 |

    Register

    9 |
    10 | 11 | 12 | 13 | 14 |
    15 |
    16 |
    `; 17 | 18 | 19 | export function setupRegister(nav) { 20 | nav.registerForm('registerForm', onSubmit); 21 | return showRegister; 22 | 23 | function showRegister() { 24 | return registerTemplate(); 25 | } 26 | 27 | async function onSubmit(data) { 28 | if (data.password != data.rePass) { 29 | return alert('Passwords don\'t match'); 30 | } 31 | 32 | try { 33 | await regster(data.email, data.password); 34 | nav.setUserNav(); 35 | nav.goTo('catalog'); 36 | } catch (err) { 37 | alert(err.message); 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /lesson-06/finished/static/comments.css: -------------------------------------------------------------------------------- 1 | .new-comment textarea { 2 | resize: none; 3 | width: 100%; 4 | height: 200px; 5 | margin-bottom: 32px; 6 | } 7 | 8 | .comments li { 9 | display: block; 10 | width: 720px; 11 | margin: 32px auto 32px auto; 12 | box-shadow: 8px 8px 8px 0 rgba(0, 0, 0, 0.2), 12px 12px 20px 0 rgba(0, 0, 0, 0.19); 13 | border-radius: 16px; 14 | } 15 | 16 | .comment { 17 | background-color: white; 18 | } 19 | 20 | .comment p { 21 | padding: 16px; 22 | } 23 | 24 | .comment header { 25 | border-radius: 16px 16px 0 0; 26 | background-color: #cccccc; 27 | padding: 8px 16px; 28 | } 29 | 30 | .button { 31 | display: block; 32 | border: none; 33 | margin: auto; 34 | background-color: salmon; 35 | padding: 8px 16px; 36 | text-decoration: none; 37 | color: black; 38 | } 39 | 40 | .button:hover { 41 | background-color: #6c8b47; 42 | color: white; 43 | cursor: pointer; 44 | } -------------------------------------------------------------------------------- /lesson-06/finished/static/form.css: -------------------------------------------------------------------------------- 1 | form { 2 | padding: 32px; 3 | } 4 | 5 | label { 6 | display: block; 7 | position: relative; 8 | margin: 16px 0; 9 | text-align: right; 10 | padding-right: 520px; 11 | line-height: 48px; 12 | } 13 | 14 | input[type="text"], input[type="password"], .ml textarea { 15 | position: absolute; 16 | right: 0px; 17 | width: 500px; 18 | padding: 8px; 19 | } 20 | 21 | .ml textarea { 22 | height: 300px; 23 | resize: none; 24 | } 25 | 26 | .ml { 27 | height: 300px; 28 | } 29 | 30 | input[type="submit"] { 31 | display: block; 32 | border: none; 33 | margin: auto; 34 | background-color: salmon; 35 | padding: 8px 16px; 36 | text-decoration: none; 37 | color: black; 38 | } 39 | 40 | input[type="submit"]:hover { 41 | background-color: #6c8b47; 42 | color: white; 43 | cursor: pointer; 44 | } -------------------------------------------------------------------------------- /lesson-06/finished/static/site.css: -------------------------------------------------------------------------------- 1 | * { 2 | font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif; 3 | font-size: 16pt; 4 | padding: 0; 5 | margin: 0; 6 | box-sizing: border-box; 7 | } 8 | 9 | body { 10 | width: 980px; 11 | margin: 0 auto 0 auto; 12 | } 13 | 14 | body>header { 15 | padding: 32px; 16 | background-color: #cccccc; 17 | position: relative; 18 | } 19 | 20 | h1 { 21 | font-size: 32px; 22 | line-height: 32px; 23 | vertical-align: bottom; 24 | display: inline; 25 | } 26 | 27 | h1 img { 28 | height: 32px; 29 | vertical-align: bottom; 30 | margin-right: 8px; 31 | } 32 | 33 | h1 a { 34 | font-size: 32px; 35 | text-decoration: none; 36 | color: black; 37 | } 38 | 39 | h1 a:visited { 40 | color: black; 41 | } 42 | 43 | main { 44 | background-color: #666666; 45 | padding: 32px; 46 | } 47 | 48 | nav { 49 | display: inline-block; 50 | position: absolute; 51 | right: 16px; 52 | text-align: right; 53 | } 54 | 55 | nav div { 56 | display: inline-block; 57 | } 58 | 59 | nav a { 60 | display: inline-block; 61 | background-color: salmon; 62 | padding: 8px 16px; 63 | margin: 0 8px; 64 | text-decoration: none; 65 | color: black; 66 | } 67 | 68 | nav a:visited { 69 | color: black; 70 | } 71 | 72 | nav a:hover { 73 | background-color: #6c8b47; 74 | color: white; 75 | } 76 | 77 | a.active { 78 | background-color: #6c8b47; 79 | color: white; 80 | } 81 | 82 | #user { 83 | display: none; 84 | } 85 | 86 | #guest { 87 | display: none; 88 | } 89 | 90 | #views { 91 | display: none; 92 | } 93 | 94 | .hero { 95 | display: block; 96 | width: 850px; 97 | margin: 32px auto 32px auto; 98 | background-color: #cccccc; 99 | box-shadow: 8px 8px 8px 0 rgba(0, 0, 0, 0.2), 12px 12px 20px 0 rgba(0, 0, 0, 0.19); 100 | padding: 32px; 101 | } 102 | 103 | .section-title { 104 | display: block; 105 | width: 850px; 106 | margin: 32px auto 32px auto; 107 | background-color: #cccccc; 108 | box-shadow: 8px 8px 8px 0 rgba(0, 0, 0, 0.2), 12px 12px 20px 0 rgba(0, 0, 0, 0.19); 109 | padding: 16px 32px; 110 | } 111 | 112 | a { 113 | color: #40522a; 114 | } 115 | 116 | a:visited { 117 | color: #40522a; 118 | } 119 | 120 | .pager { 121 | margin: 0 8px; 122 | } -------------------------------------------------------------------------------- /lesson-07/base/assets/fish.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-07/base/assets/fish.jpg -------------------------------------------------------------------------------- /lesson-07/base/assets/lasagna.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-07/base/assets/lasagna.jpg -------------------------------------------------------------------------------- /lesson-07/base/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-07/base/assets/logo.png -------------------------------------------------------------------------------- /lesson-07/base/assets/roast.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-07/base/assets/roast.jpg -------------------------------------------------------------------------------- /lesson-07/base/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | My Cookbook 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
    17 |

    My Cookbook

    18 | 29 |
    30 | 31 |
    32 |
    33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /lesson-07/base/src/api/data.js: -------------------------------------------------------------------------------- 1 | import createApi from './api.js'; 2 | 3 | const api = createApi(null, null, (msg) => alert(msg)); 4 | 5 | const endpoints = { 6 | RECIPE_LIST: 'data/recipes?select=' + encodeURIComponent('_id,name,img'), 7 | RECIPE_COUNT: 'data/recipes?count', 8 | RECENT_RECIPES: 'data/recipes?select=' + encodeURIComponent('_id,name,img') + '&sortBy=' + encodeURIComponent('_createdOn desc'), 9 | RECIPES: 'data/recipes', 10 | RECIPE_BY_ID: 'data/recipes/', 11 | COMMENTS: 'data/comments', 12 | COMMENTS_BY_RECIPE_ID: 'data/comments?where=' + encodeURIComponent('recipeId='), 13 | }; 14 | 15 | export const login = api.login.bind(api); 16 | export const regster = api.register.bind(api); 17 | export const logout = api.logout.bind(api); 18 | 19 | export async function getRecipes(page = 1) { 20 | return await api.get(endpoints.RECIPE_LIST + `&offset=${(page - 1) * 5}&pageSize=5`); 21 | } 22 | 23 | export async function getRecipeCount() { 24 | return await api.get(endpoints.RECIPE_COUNT); 25 | } 26 | 27 | export async function getRecent() { 28 | return await api.get(endpoints.RECENT_RECIPES); 29 | } 30 | 31 | export async function getRecipeById(id) { 32 | return await api.get(endpoints.RECIPE_BY_ID + id); 33 | } 34 | 35 | export async function createRecipe(recipe) { 36 | return await api.post(endpoints.RECIPES, recipe); 37 | } 38 | 39 | export async function editRecipe(id, recipe) { 40 | return await api.put(endpoints.RECIPE_BY_ID + id, recipe); 41 | } 42 | 43 | export async function deleteRecipeById(id) { 44 | return await api.delete(endpoints.RECIPE_BY_ID + id); 45 | } 46 | 47 | export async function getCommentsByRecipeId(recipeId) { 48 | return await api.get(endpoints.COMMENTS_BY_RECIPE_ID + encodeURIComponent(`"${recipeId}"`) + '&load=' + encodeURIComponent('author=_ownerId:users')); 49 | } 50 | 51 | export async function createComment(comment) { 52 | return await api.post(endpoints.COMMENTS, comment); 53 | } -------------------------------------------------------------------------------- /lesson-07/base/src/app.js: -------------------------------------------------------------------------------- 1 | import { createNav } from './navigation.js'; 2 | import { logout as apiLogout } from './api/data.js'; 3 | 4 | import { setupHome } from './views/home.js'; 5 | import { setupCatalog } from './views/catalog.js'; 6 | import { setupCreate } from './views/create.js'; 7 | import { setupLogin } from './views/login.js'; 8 | import { setupRegister } from './views/register.js'; 9 | import { setupDetails } from './views/details.js'; 10 | import { setupEdit, setupDeleted } from './views/edit.js'; 11 | 12 | 13 | window.addEventListener('load', async () => { 14 | const main = document.querySelector('main'); 15 | const navbar = document.querySelector('nav'); 16 | const navigation = createNav(main, navbar); 17 | 18 | navigation.registerView('home', setupHome); 19 | navigation.registerView('catalog', setupCatalog, 'catalogLink'); 20 | navigation.registerView('details', setupDetails); 21 | navigation.registerView('login', setupLogin, 'loginLink'); 22 | navigation.registerView('register', setupRegister, 'registerLink'); 23 | navigation.registerView('create', setupCreate, 'createLink'); 24 | navigation.registerView('edit', setupEdit); 25 | navigation.registerView('deleted', setupDeleted); 26 | 27 | navigation.setUserNav(); 28 | document.getElementById('logoutBtn').addEventListener('click', logout); 29 | 30 | // Start application in catalog view 31 | navigation.goTo('home'); 32 | 33 | 34 | async function logout() { 35 | try { 36 | await apiLogout(); 37 | navigation.setUserNav(); 38 | navigation.goTo('catalog'); 39 | } catch (err) { 40 | alert(err.message); 41 | } 42 | } 43 | }); 44 | -------------------------------------------------------------------------------- /lesson-07/base/src/dom.js: -------------------------------------------------------------------------------- 1 | import { html, render } from 'https://unpkg.com/lit-html?module'; 2 | import { until } from 'https://unpkg.com/lit-html/directives/until?module'; 3 | 4 | 5 | export { 6 | html, 7 | render, 8 | until 9 | }; 10 | 11 | export function e(type, attributes, ...content) { 12 | const result = document.createElement(type); 13 | 14 | for (let [attr, value] of Object.entries(attributes || {})) { 15 | if (attr.substring(0, 2) == 'on') { 16 | result.addEventListener(attr.substring(2).toLocaleLowerCase(), value); 17 | } else { 18 | result[attr] = value; 19 | } 20 | } 21 | 22 | content = content.reduce((a, c) => a.concat(Array.isArray(c) ? c : [c]), []); 23 | 24 | content.forEach(e => { 25 | if (typeof e == 'string' || typeof e == 'number') { 26 | const node = document.createTextNode(e); 27 | result.appendChild(node); 28 | } else { 29 | result.appendChild(e); 30 | } 31 | }); 32 | 33 | return result; 34 | } -------------------------------------------------------------------------------- /lesson-07/base/src/navigation.js: -------------------------------------------------------------------------------- 1 | import { render } from './dom.js'; 2 | 3 | 4 | export function createNav(main, navbar) { 5 | const views = {}; 6 | const links = {}; 7 | const forms = {}; 8 | 9 | setupNavigation(); 10 | setupForms(); 11 | 12 | const navigator = { 13 | registerView, 14 | goTo, 15 | setUserNav, 16 | registerForm 17 | }; 18 | 19 | return navigator; 20 | 21 | function setupNavigation() { 22 | navbar.addEventListener('click', (ev) => { 23 | if (ev.target.tagName == 'A') { 24 | const handlerName = links[ev.target.id]; 25 | if (handlerName) { 26 | ev.preventDefault(); 27 | goTo(handlerName); 28 | } 29 | } 30 | }); 31 | } 32 | 33 | async function goTo(name, ...params) { 34 | const result = await views[name](...params); 35 | render(result, main); 36 | } 37 | 38 | function registerView(name, setup, navId) { 39 | const execute = setup(navigator); 40 | 41 | views[name] = (...params) => { 42 | [...navbar.querySelectorAll('a')].forEach(a => a.classList.remove('active')); 43 | if (navId) { 44 | navbar.querySelector('#' + navId).classList.add('active'); 45 | } 46 | return execute(...params); 47 | }; 48 | if (navId) { 49 | links[navId] = name; 50 | } 51 | } 52 | 53 | function setUserNav() { 54 | if (sessionStorage.getItem('userToken') != null) { 55 | document.getElementById('user').style.display = 'inline-block'; 56 | document.getElementById('guest').style.display = 'none'; 57 | } else { 58 | document.getElementById('user').style.display = 'none'; 59 | document.getElementById('guest').style.display = 'inline-block'; 60 | } 61 | } 62 | 63 | function setupForms() { 64 | document.body.addEventListener('submit', onSubmit); 65 | } 66 | 67 | function registerForm(name, handler) { 68 | forms[name] = handler; 69 | } 70 | 71 | function onSubmit(ev) { 72 | const handler = forms[ev.target.id]; 73 | if (typeof handler == 'function') { 74 | ev.preventDefault(); 75 | const formData = new FormData(ev.target); 76 | const body = [...formData.entries()].reduce((p, [k, v]) => Object.assign(p, { [k]: v }), {}); 77 | handler(body); 78 | } 79 | } 80 | } 81 | 82 | -------------------------------------------------------------------------------- /lesson-07/base/src/views/catalog.js: -------------------------------------------------------------------------------- 1 | import { e, html } from '../dom.js'; 2 | import { getRecipes, getRecipeCount } from '../api/data.js'; 3 | 4 | 5 | const catalogTemplate = (recipes, goTo, page, pages) => html` 6 |
    7 |
    ${pager(goTo, page, pages)}
    8 | ${recipes.map(r => recipePreview(r, goTo))} 9 | 10 |
    `; 11 | 12 | const recipePreview = (recipe, goTo) => html` 13 |
    goTo('details', recipe._id)}> 14 |
    15 |

    ${recipe.name}

    16 |
    17 |
    18 |
    `; 19 | 20 | const pager = (goTo, page, pages) => html` 21 | Page ${page} of ${pages} 22 | ${page > 1 ? html` changePage(e, goTo, page - 1)}>< Prev` : ''} 23 | ${page < pages ? html` changePage(e, goTo, page + 1)}>Next >` : ''}`; 24 | 25 | function changePage(e, goTo, newPage) { 26 | e.preventDefault(); 27 | goTo('catalog', newPage); 28 | } 29 | 30 | export function setupCatalog(nav) { 31 | return showCatalog; 32 | 33 | async function showCatalog(page = 1) { 34 | const recipes = await getRecipes(page); 35 | const count = await getRecipeCount(); 36 | const pages = Math.ceil(count / 5); 37 | 38 | return catalogTemplate(recipes, nav.goTo, page, pages); 39 | } 40 | } -------------------------------------------------------------------------------- /lesson-07/base/src/views/comments.js: -------------------------------------------------------------------------------- 1 | import { render, html, until } from '../dom.js'; 2 | import { getCommentsByRecipeId, createComment } from '../api/data.js'; 3 | 4 | 5 | const commentsTemplate = (recipe, commentForm, comments) => html` 6 |
    7 | Comments for ${recipe.name} 8 |
    9 | ${commentForm} 10 |
    11 | ${until((async () => commentsList(await comments))(), 'Loading comments...')} 12 |
    `; 13 | 14 | const commentFormTemplate = (active, toggleForm) => html` 15 |
    16 | ${active 17 | ? html` 18 |

    New comment

    19 |
    20 | 21 | 22 |
    ` 23 | : html`
    `} 24 |
    `; 25 | 26 | const commentsList = (comments) => html` 27 | `; 30 | 31 | const comment = (data) => html` 32 |
  • 33 |
    ${data.author.email}
    34 |

    ${data.content}

    35 |
  • `; 36 | 37 | export function showComments(recipe, nav) { 38 | let formActive = false; 39 | nav.registerForm('commentForm', onSubmit); 40 | const commentsPromise = getCommentsByRecipeId(recipe._id); 41 | const result = document.createElement('div'); 42 | renderTemplate(commentsPromise); 43 | 44 | return result; 45 | 46 | function renderTemplate(comments) { 47 | render(commentsTemplate(recipe, createForm(formActive, toggleForm), comments), result); 48 | } 49 | 50 | function toggleForm() { 51 | formActive = !formActive; 52 | renderTemplate(commentsPromise); 53 | } 54 | 55 | async function onSubmit(data) { 56 | toggleForm(); 57 | const comments = await commentsPromise; 58 | 59 | const comment = { 60 | content: data.content, 61 | recipeId: recipe._id 62 | }; 63 | 64 | const result = await createComment(comment); 65 | 66 | comments.unshift(result); 67 | renderTemplate(comments); 68 | } 69 | } 70 | 71 | function createForm(formActive, toggleForm) { 72 | const userId = sessionStorage.getItem('userId'); 73 | if (userId == null) { 74 | return ''; 75 | } else { 76 | return commentFormTemplate(formActive, toggleForm); 77 | } 78 | } -------------------------------------------------------------------------------- /lesson-07/base/src/views/create.js: -------------------------------------------------------------------------------- 1 | import { html } from '../dom.js'; 2 | import { createRecipe } from '../api/data.js'; 3 | 4 | 5 | const createTemplate = () => html` 6 |
    7 |
    8 |

    New Recipe

    9 |
    10 | 11 | 12 | 14 | 16 | 17 |
    18 |
    19 |
    `; 20 | 21 | export function setupCreate(nav) { 22 | nav.registerForm('createForm', onSubmit); 23 | return showCreate; 24 | 25 | function showCreate() { 26 | return createTemplate(); 27 | } 28 | 29 | async function onSubmit(data) { 30 | const body = { 31 | name: data.name, 32 | img: data.img, 33 | ingredients: data.ingredients.split('\n').map(l => l.trim()).filter(l => l != ''), 34 | steps: data.steps.split('\n').map(l => l.trim()).filter(l => l != '') 35 | }; 36 | 37 | try { 38 | const result = await createRecipe(body); 39 | nav.goTo('details', result._id); 40 | } catch (err) { 41 | alert(err.message); 42 | } 43 | } 44 | } 45 | 46 | -------------------------------------------------------------------------------- /lesson-07/base/src/views/details.js: -------------------------------------------------------------------------------- 1 | import { html } from '../dom.js'; 2 | import { getRecipeById, deleteRecipeById } from '../api/data.js'; 3 | import { showComments } from './comments.js'; 4 | 5 | 6 | const detailsTemplate = (recipe, isOwner, nav, onDelete) => html` 7 |
    8 | ${recipeCard(recipe, isOwner, nav.goTo, onDelete)} 9 | ${showComments(recipe, nav)} 10 |
    `; 11 | 12 | const recipeCard = (recipe, isOwner, goTo, onDelete) => html` 13 |
    14 |

    ${recipe.name}

    15 |
    16 |
    17 |
    18 |

    Ingredients:

    19 |
      20 | ${recipe.ingredients.map(i => html`
    • ${i}
    • `)} 21 |
    22 |
    23 |
    24 |
    25 |

    Preparation:

    26 | ${recipe.steps.map(s => html`

    ${s}

    `)} 27 |
    28 | ${isOwner 29 | ? html` 30 |
    31 | 32 | 33 |
    ` 34 | : ''} 35 |
    `; 36 | 37 | 38 | export function setupDetails(nav) { 39 | return showDetails; 40 | 41 | async function showDetails(id) { 42 | const recipe = await getRecipeById(id); 43 | 44 | const userId = sessionStorage.getItem('userId'); 45 | const isOwner = userId != null && recipe._ownerId == userId; 46 | 47 | return detailsTemplate(recipe, isOwner, nav, () => onDelete(recipe)); 48 | } 49 | 50 | async function onDelete(recipe) { 51 | const confirmed = confirm(`Are you sure you want to delete ${recipe.name}?`); 52 | if (confirmed) { 53 | try { 54 | await deleteRecipeById(recipe._id); 55 | nav.goTo('deleted'); 56 | } catch (err) { 57 | alert(err.message); 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /lesson-07/base/src/views/edit.js: -------------------------------------------------------------------------------- 1 | import { html } from '../dom.js'; 2 | import { getRecipeById, editRecipe } from '../api/data.js'; 3 | 4 | 5 | const editTemplate = (recipe) => html` 6 |
    7 |
    8 |

    Edit Recipe

    9 |
    10 | 11 | 12 | 15 | 18 | 19 |
    20 |
    21 |
    `; 22 | 23 | export function setupEdit(nav) { 24 | return showEdit; 25 | 26 | async function showEdit(recipeId) { 27 | nav.registerForm('editForm', onSubmit); 28 | const recipe = await getRecipeById(recipeId); 29 | 30 | return editTemplate(recipe); 31 | 32 | async function onSubmit(data) { 33 | const body = { 34 | name: data.name, 35 | img: data.img, 36 | ingredients: data.ingredients.split('\n').map(l => l.trim()).filter(l => l != ''), 37 | steps: data.steps.split('\n').map(l => l.trim()).filter(l => l != '') 38 | }; 39 | 40 | try { 41 | await editRecipe(recipeId, body); 42 | nav.goTo('details', recipeId); 43 | } catch (err) { 44 | alert(err.message); 45 | } 46 | } 47 | } 48 | } 49 | 50 | export function setupDeleted() { 51 | return () => html` 52 |
    53 |
    54 |

    Recipe deleted

    55 |
    56 |
    `; 57 | } -------------------------------------------------------------------------------- /lesson-07/base/src/views/home.js: -------------------------------------------------------------------------------- 1 | import { html } from '../dom.js'; 2 | import { getRecent } from '../api/data.js'; 3 | 4 | 5 | const homeTemplate = (recentRecipes, goTo) => html` 6 |
    7 |
    8 |

    Welcome to My Cookbook

    9 |
    10 |
    Recently added recipes
    11 |
    12 | ${recentRecipes[0] ? recentRecipe(recentRecipes[0], goTo) : ''} 13 | ${spacer()} 14 | ${recentRecipes[1] ? recentRecipe(recentRecipes[1], goTo) : ''} 15 | ${spacer()} 16 | ${recentRecipes[2] ? recentRecipe(recentRecipes[2], goTo) : ''} 17 |
    18 | 21 |
    `; 22 | 23 | const recentRecipe = (recipe, goTo) => html` 24 |
    goTo('details', recipe._id)}> 25 |
    26 |
    ${recipe.name}
    27 |
    `; 28 | 29 | const spacer = () => html`
    `; 30 | 31 | export function setupHome(nav) { 32 | return showHome; 33 | 34 | async function showHome() { 35 | const recipes = await getRecent(); 36 | 37 | return homeTemplate(recipes, nav.goTo); 38 | } 39 | } -------------------------------------------------------------------------------- /lesson-07/base/src/views/login.js: -------------------------------------------------------------------------------- 1 | import { html } from '../dom.js'; 2 | import { login } from '../api/data.js'; 3 | 4 | 5 | const loginTemplate = () => html` 6 |
    7 |
    8 |

    Login

    9 |
    10 | 11 | 12 | 13 |
    14 |
    15 |
    `; 16 | 17 | 18 | export function setupLogin(nav) { 19 | nav.registerForm('loginForm', onSubmit); 20 | return showLogin; 21 | 22 | function showLogin() { 23 | return loginTemplate(); 24 | } 25 | 26 | async function onSubmit(data) { 27 | try { 28 | await login(data.email, data.password); 29 | nav.setUserNav(); 30 | nav.goTo('catalog'); 31 | } catch (err) { 32 | alert(err.message); 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /lesson-07/base/src/views/register.js: -------------------------------------------------------------------------------- 1 | import { html } from '../dom.js'; 2 | import { regster } from '../api/data.js'; 3 | 4 | 5 | const registerTemplate = () => html` 6 |
    7 |
    8 |

    Register

    9 |
    10 | 11 | 12 | 13 | 14 |
    15 |
    16 |
    `; 17 | 18 | 19 | export function setupRegister(nav) { 20 | nav.registerForm('registerForm', onSubmit); 21 | return showRegister; 22 | 23 | function showRegister() { 24 | return registerTemplate(); 25 | } 26 | 27 | async function onSubmit(data) { 28 | if (data.password != data.rePass) { 29 | return alert('Passwords don\'t match'); 30 | } 31 | 32 | try { 33 | await regster(data.email, data.password); 34 | nav.setUserNav(); 35 | nav.goTo('catalog'); 36 | } catch (err) { 37 | alert(err.message); 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /lesson-07/base/static/comments.css: -------------------------------------------------------------------------------- 1 | .new-comment textarea { 2 | resize: none; 3 | width: 100%; 4 | height: 200px; 5 | margin-bottom: 32px; 6 | } 7 | 8 | .comments li { 9 | display: block; 10 | width: 720px; 11 | margin: 32px auto 32px auto; 12 | box-shadow: 8px 8px 8px 0 rgba(0, 0, 0, 0.2), 12px 12px 20px 0 rgba(0, 0, 0, 0.19); 13 | border-radius: 16px; 14 | } 15 | 16 | .comment { 17 | background-color: white; 18 | } 19 | 20 | .comment p { 21 | padding: 16px; 22 | } 23 | 24 | .comment header { 25 | border-radius: 16px 16px 0 0; 26 | background-color: #cccccc; 27 | padding: 8px 16px; 28 | } 29 | 30 | .button { 31 | display: block; 32 | border: none; 33 | margin: auto; 34 | background-color: salmon; 35 | padding: 8px 16px; 36 | text-decoration: none; 37 | color: black; 38 | } 39 | 40 | .button:hover { 41 | background-color: #6c8b47; 42 | color: white; 43 | cursor: pointer; 44 | } -------------------------------------------------------------------------------- /lesson-07/base/static/form.css: -------------------------------------------------------------------------------- 1 | form { 2 | padding: 32px; 3 | } 4 | 5 | label { 6 | display: block; 7 | position: relative; 8 | margin: 16px 0; 9 | text-align: right; 10 | padding-right: 520px; 11 | line-height: 48px; 12 | } 13 | 14 | input[type="text"], input[type="password"], .ml textarea { 15 | position: absolute; 16 | right: 0px; 17 | width: 500px; 18 | padding: 8px; 19 | } 20 | 21 | .ml textarea { 22 | height: 300px; 23 | resize: none; 24 | } 25 | 26 | .ml { 27 | height: 300px; 28 | } 29 | 30 | input[type="submit"] { 31 | display: block; 32 | border: none; 33 | margin: auto; 34 | background-color: salmon; 35 | padding: 8px 16px; 36 | text-decoration: none; 37 | color: black; 38 | } 39 | 40 | input[type="submit"]:hover { 41 | background-color: #6c8b47; 42 | color: white; 43 | cursor: pointer; 44 | } -------------------------------------------------------------------------------- /lesson-07/base/static/site.css: -------------------------------------------------------------------------------- 1 | * { 2 | font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif; 3 | font-size: 16pt; 4 | padding: 0; 5 | margin: 0; 6 | box-sizing: border-box; 7 | } 8 | 9 | body { 10 | width: 980px; 11 | margin: 0 auto 0 auto; 12 | } 13 | 14 | body>header { 15 | padding: 32px; 16 | background-color: #cccccc; 17 | position: relative; 18 | } 19 | 20 | h1 { 21 | font-size: 32px; 22 | line-height: 32px; 23 | vertical-align: bottom; 24 | display: inline; 25 | } 26 | 27 | h1 img { 28 | height: 32px; 29 | vertical-align: bottom; 30 | margin-right: 8px; 31 | } 32 | 33 | h1 a { 34 | font-size: 32px; 35 | text-decoration: none; 36 | color: black; 37 | } 38 | 39 | h1 a:visited { 40 | color: black; 41 | } 42 | 43 | main { 44 | background-color: #666666; 45 | padding: 32px; 46 | } 47 | 48 | nav { 49 | display: inline-block; 50 | position: absolute; 51 | right: 16px; 52 | text-align: right; 53 | } 54 | 55 | nav div { 56 | display: inline-block; 57 | } 58 | 59 | nav a { 60 | display: inline-block; 61 | background-color: salmon; 62 | padding: 8px 16px; 63 | margin: 0 8px; 64 | text-decoration: none; 65 | color: black; 66 | } 67 | 68 | nav a:visited { 69 | color: black; 70 | } 71 | 72 | nav a:hover { 73 | background-color: #6c8b47; 74 | color: white; 75 | } 76 | 77 | a.active { 78 | background-color: #6c8b47; 79 | color: white; 80 | } 81 | 82 | #user { 83 | display: none; 84 | } 85 | 86 | #guest { 87 | display: none; 88 | } 89 | 90 | #views { 91 | display: none; 92 | } 93 | 94 | .hero { 95 | display: block; 96 | width: 850px; 97 | margin: 32px auto 32px auto; 98 | background-color: #cccccc; 99 | box-shadow: 8px 8px 8px 0 rgba(0, 0, 0, 0.2), 12px 12px 20px 0 rgba(0, 0, 0, 0.19); 100 | padding: 32px; 101 | } 102 | 103 | .section-title { 104 | display: block; 105 | width: 850px; 106 | margin: 32px auto 32px auto; 107 | background-color: #cccccc; 108 | box-shadow: 8px 8px 8px 0 rgba(0, 0, 0, 0.2), 12px 12px 20px 0 rgba(0, 0, 0, 0.19); 109 | padding: 16px 32px; 110 | } 111 | 112 | a { 113 | color: #40522a; 114 | } 115 | 116 | a:visited { 117 | color: #40522a; 118 | } 119 | 120 | .pager { 121 | margin: 0 8px; 122 | } 123 | 124 | .actionLink { 125 | display: inline-block; 126 | background-color: salmon; 127 | padding: 8px 16px; 128 | margin: 0 8px; 129 | text-decoration: none; 130 | color: black; 131 | } 132 | 133 | .actionLink:visited { 134 | color: black; 135 | } 136 | 137 | .actionLink:hover { 138 | background-color: #6c8b47; 139 | color: white; 140 | } -------------------------------------------------------------------------------- /lesson-07/finished/assets/fish.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-07/finished/assets/fish.jpg -------------------------------------------------------------------------------- /lesson-07/finished/assets/lasagna.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-07/finished/assets/lasagna.jpg -------------------------------------------------------------------------------- /lesson-07/finished/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-07/finished/assets/logo.png -------------------------------------------------------------------------------- /lesson-07/finished/assets/roast.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktorpts/js-apps-workshop/30ee714e349da17eab5eb72a7ba30bcd26b41d4e/lesson-07/finished/assets/roast.jpg -------------------------------------------------------------------------------- /lesson-07/finished/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | My Cookbook 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
    17 |

    My Cookbook

    18 | 29 |
    30 | 31 |
    32 |
    33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /lesson-07/finished/src/api/data.js: -------------------------------------------------------------------------------- 1 | import createApi from './api.js'; 2 | 3 | const api = createApi(null, null, (msg) => alert(msg)); 4 | 5 | const endpoints = { 6 | RECIPE_LIST: 'data/recipes?select=' + encodeURIComponent('_id,name,img'), 7 | RECIPE_COUNT: 'data/recipes?count', 8 | RECENT_RECIPES: 'data/recipes?select=' + encodeURIComponent('_id,name,img') + '&sortBy=' + encodeURIComponent('_createdOn desc'), 9 | RECIPES: 'data/recipes', 10 | RECIPE_BY_ID: 'data/recipes/', 11 | COMMENTS: 'data/comments', 12 | COMMENT_BY_ID: 'data/comments/', 13 | COMMENTS_BY_RECIPE_ID: 'data/comments?where=' + encodeURIComponent('recipeId='), 14 | }; 15 | 16 | export const login = api.login.bind(api); 17 | export const regster = api.register.bind(api); 18 | export const logout = api.logout.bind(api); 19 | 20 | export async function getRecipes(page = 1, search) { 21 | let url = endpoints.RECIPE_LIST + `&offset=${(page - 1) * 5}&pageSize=5`; 22 | if (search) { 23 | url += '&where=' + encodeURIComponent(`name like "${search}"`); 24 | } 25 | return await api.get(url); 26 | } 27 | 28 | export async function getRecipeCount(search) { 29 | let url = endpoints.RECIPE_COUNT; 30 | if (search) { 31 | url += '&where=' + encodeURIComponent(`name like "${search}"`); 32 | } 33 | return await api.get(endpoints.RECIPE_COUNT); 34 | } 35 | 36 | export async function getRecent() { 37 | return await api.get(endpoints.RECENT_RECIPES); 38 | } 39 | 40 | export async function getRecipeById(id) { 41 | return await api.get(endpoints.RECIPE_BY_ID + id); 42 | } 43 | 44 | export async function createRecipe(recipe) { 45 | return await api.post(endpoints.RECIPES, recipe); 46 | } 47 | 48 | export async function editRecipe(id, recipe) { 49 | return await api.put(endpoints.RECIPE_BY_ID + id, recipe); 50 | } 51 | 52 | export async function deleteRecipeById(id) { 53 | return await api.delete(endpoints.RECIPE_BY_ID + id); 54 | } 55 | 56 | export async function getCommentsByRecipeId(recipeId) { 57 | return await api.get(endpoints.COMMENTS_BY_RECIPE_ID + encodeURIComponent(`"${recipeId}"`) + '&load=' + encodeURIComponent('author=_ownerId:users')); 58 | } 59 | 60 | export async function createComment(comment) { 61 | const result = await api.post(endpoints.COMMENTS, comment); 62 | return await api.get(endpoints.COMMENT_BY_ID + result._id + '?load=' + encodeURIComponent('author=_ownerId:users')); 63 | } -------------------------------------------------------------------------------- /lesson-07/finished/src/dom.js: -------------------------------------------------------------------------------- 1 | import { html, render } from 'https://unpkg.com/lit-html?module'; 2 | import { until } from 'https://unpkg.com/lit-html/directives/until?module'; 3 | 4 | 5 | export { 6 | html, 7 | render, 8 | until 9 | }; 10 | 11 | export function e(type, attributes, ...content) { 12 | const result = document.createElement(type); 13 | 14 | for (let [attr, value] of Object.entries(attributes || {})) { 15 | if (attr.substring(0, 2) == 'on') { 16 | result.addEventListener(attr.substring(2).toLocaleLowerCase(), value); 17 | } else { 18 | result[attr] = value; 19 | } 20 | } 21 | 22 | content = content.reduce((a, c) => a.concat(Array.isArray(c) ? c : [c]), []); 23 | 24 | content.forEach(e => { 25 | if (typeof e == 'string' || typeof e == 'number') { 26 | const node = document.createTextNode(e); 27 | result.appendChild(node); 28 | } else { 29 | result.appendChild(e); 30 | } 31 | }); 32 | 33 | return result; 34 | } -------------------------------------------------------------------------------- /lesson-07/finished/src/navigation.js: -------------------------------------------------------------------------------- 1 | import { render } from './dom.js'; 2 | 3 | 4 | export function createNav(main, navbar) { 5 | const views = {}; 6 | const links = {}; 7 | const forms = {}; 8 | 9 | setupForms(); 10 | setUserNav(); 11 | 12 | const navigator = { 13 | registerView, 14 | setUserNav, 15 | registerForm 16 | }; 17 | 18 | return navigator; 19 | 20 | function registerView(name, setup, navId) { 21 | const execute = setup(); 22 | 23 | views[name] = (...params) => { 24 | [...navbar.querySelectorAll('a')].forEach(a => a.classList.remove('active')); 25 | if (navId) { 26 | navbar.querySelector('#' + navId).classList.add('active'); 27 | } 28 | return execute(...params); 29 | }; 30 | if (navId) { 31 | links[navId] = name; 32 | } 33 | 34 | return async (...params) => render(await views[name](...params), main); 35 | } 36 | 37 | function setUserNav() { 38 | if (sessionStorage.getItem('userToken') != null) { 39 | document.getElementById('user').style.display = 'inline-block'; 40 | document.getElementById('guest').style.display = 'none'; 41 | } else { 42 | document.getElementById('user').style.display = 'none'; 43 | document.getElementById('guest').style.display = 'inline-block'; 44 | } 45 | } 46 | 47 | function setupForms() { 48 | document.body.addEventListener('submit', onSubmit); 49 | } 50 | 51 | function registerForm(name, handler, onSuccess) { 52 | forms[name] = { 53 | handler, 54 | onSuccess 55 | }; 56 | } 57 | 58 | function onSubmit(ev) { 59 | const { handler, onSuccess } = forms[ev.target.id] || {}; 60 | if (typeof handler == 'function') { 61 | ev.preventDefault(); 62 | const formData = new FormData(ev.target); 63 | const body = [...formData.entries()].reduce((p, [k, v]) => Object.assign(p, { [k]: v }), {}); 64 | handler(body, onSuccess); 65 | } 66 | } 67 | } 68 | 69 | -------------------------------------------------------------------------------- /lesson-07/finished/src/views/catalog.js: -------------------------------------------------------------------------------- 1 | import { html } from '../dom.js'; 2 | import { getRecipes, getRecipeCount } from '../api/data.js'; 3 | 4 | 5 | const catalogTemplate = (recipes, page, pages, search) => html` 6 |
    7 |
    8 |
    9 | 10 | 11 |
    12 |
    13 |
    ${pager(page, pages, search)}
    14 | ${recipes.map(recipePreview)} 15 | 16 |
    `; 17 | 18 | const recipePreview = (recipe) => html` 19 |
    20 |
    21 |

    ${recipe.name}

    22 |
    23 |
    24 |
    25 |
    `; 26 | 27 | const pager = (page, pages, search) => html` 28 | Page ${page} of ${pages} 29 | ${page > 1 ? html`< 30 | Prev` : ''} 31 | ${page < pages ? html`Next 32 | >` : ''}`; 33 | 34 | export function setupCatalog() { 35 | return showCatalog; 36 | 37 | async function showCatalog(context) { 38 | const search = readSearchParam(context.querystring); 39 | const page = Number(context.params.page) || 1; 40 | const recipes = await getRecipes(page, search); 41 | const count = await getRecipeCount(search); 42 | const pages = Math.ceil(count / 5); 43 | 44 | return catalogTemplate(recipes, page, pages, search); 45 | } 46 | } 47 | 48 | function readSearchParam(query = '') { 49 | return query.split('=')[1]; 50 | } -------------------------------------------------------------------------------- /lesson-07/finished/src/views/comments.js: -------------------------------------------------------------------------------- 1 | import { render, html, until } from '../dom.js'; 2 | import { getCommentsByRecipeId, createComment } from '../api/data.js'; 3 | 4 | 5 | const commentsTemplate = (recipe, commentForm, comments) => html` 6 |
    7 | Comments for ${recipe.name} 8 |
    9 | ${commentForm} 10 |
    11 | ${until((async () => commentsList(await comments))(), 'Loading comments...')} 12 |
    `; 13 | 14 | const commentFormTemplate = (active, toggleForm, onSubmit) => html` 15 |
    16 | ${active 17 | ? html` 18 |

    New comment

    19 |
    20 | 21 | 22 |
    ` 23 | : html`
    `} 24 |
    `; 25 | 26 | const commentsList = (comments) => html` 27 | `; 30 | 31 | const comment = (data) => html` 32 |
  • 33 |
    ${data.author.email}
    34 |

    ${data.content}

    35 |
  • `; 36 | 37 | export function showComments(recipe) { 38 | const recipeId = recipe._id; 39 | let formActive = false; 40 | const commentsPromise = getCommentsByRecipeId(recipeId); 41 | const result = document.createElement('div'); 42 | renderTemplate(commentsPromise); 43 | 44 | return result; 45 | 46 | function renderTemplate(comments) { 47 | render(commentsTemplate(recipe, createForm(formActive, toggleForm, onSubmit), comments), result); 48 | } 49 | 50 | function toggleForm() { 51 | formActive = !formActive; 52 | renderTemplate(commentsPromise); 53 | } 54 | 55 | async function onSubmit(event) { 56 | event.preventDefault(); 57 | const data = new FormData(event.target); 58 | 59 | toggleForm(); 60 | const comments = await commentsPromise; 61 | 62 | const comment = { 63 | content: data.get('content'), 64 | recipeId 65 | }; 66 | console.log(comment); 67 | 68 | const result = await createComment(comment); 69 | 70 | comments.unshift(result); 71 | renderTemplate(comments); 72 | } 73 | } 74 | 75 | function createForm(formActive, toggleForm, onSubmit) { 76 | const userId = sessionStorage.getItem('userId'); 77 | if (userId == null) { 78 | return ''; 79 | } else { 80 | return commentFormTemplate(formActive, toggleForm, onSubmit); 81 | } 82 | } -------------------------------------------------------------------------------- /lesson-07/finished/src/views/create.js: -------------------------------------------------------------------------------- 1 | import { html } from '../dom.js'; 2 | import { createRecipe } from '../api/data.js'; 3 | 4 | 5 | const createTemplate = () => html` 6 |
    7 |
    8 |

    New Recipe

    9 |
    10 | 11 | 12 | 14 | 16 | 17 |
    18 |
    19 |
    `; 20 | 21 | export function setupCreate() { 22 | return showCreate; 23 | 24 | function showCreate() { 25 | return createTemplate(); 26 | } 27 | } 28 | 29 | export async function onCreateSubmit(data, onSuccess) { 30 | const body = { 31 | name: data.name, 32 | img: data.img, 33 | ingredients: data.ingredients.split('\n').map(l => l.trim()).filter(l => l != ''), 34 | steps: data.steps.split('\n').map(l => l.trim()).filter(l => l != '') 35 | }; 36 | 37 | try { 38 | const result = await createRecipe(body); 39 | onSuccess(result._id); 40 | } catch (err) { 41 | alert(err.message); 42 | } 43 | } -------------------------------------------------------------------------------- /lesson-07/finished/src/views/details.js: -------------------------------------------------------------------------------- 1 | import { html } from '../dom.js'; 2 | import { getRecipeById, deleteRecipeById } from '../api/data.js'; 3 | import { showComments } from './comments.js'; 4 | 5 | 6 | const detailsTemplate = (recipe, isOwner, onDelete) => html` 7 |
    8 | ${recipeCard(recipe, isOwner, onDelete)} 9 | ${showComments(recipe)} 10 |
    `; 11 | 12 | const recipeCard = (recipe, isOwner, onDelete) => html` 13 |
    14 |

    ${recipe.name}

    15 |
    16 |
    17 |
    18 |

    Ingredients:

    19 |
      20 | ${recipe.ingredients.map(i => html`
    • ${i}
    • `)} 21 |
    22 |
    23 |
    24 |
    25 |

    Preparation:

    26 | ${recipe.steps.map(s => html`

    ${s}

    `)} 27 |
    28 | ${isOwner 29 | ? html` 30 |
    31 | \u270E Edit 32 | \u2716 Delete 33 |
    ` 34 | : ''} 35 |
    `; 36 | 37 | 38 | export function setupDetails() { 39 | return showDetails; 40 | 41 | async function showDetails(context) { 42 | const id = context.params.id; 43 | const recipe = await getRecipeById(id); 44 | 45 | const userId = sessionStorage.getItem('userId'); 46 | const isOwner = userId != null && recipe._ownerId == userId; 47 | 48 | return detailsTemplate(recipe, isOwner, () => onDelete(recipe, () => context.page.redirect('/deleted/' + id))); 49 | } 50 | 51 | async function onDelete(recipe, onSuccess) { 52 | const confirmed = confirm(`Are you sure you want to delete ${recipe.name}?`); 53 | if (confirmed) { 54 | try { 55 | await deleteRecipeById(recipe._id); 56 | onSuccess(); 57 | } catch (err) { 58 | alert(err.message); 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /lesson-07/finished/src/views/edit.js: -------------------------------------------------------------------------------- 1 | import { html } from '../dom.js'; 2 | import { getRecipeById, editRecipe } from '../api/data.js'; 3 | 4 | 5 | const editTemplate = (recipe) => html` 6 |
    7 |
    8 |

    Edit Recipe

    9 |
    10 | 11 | 12 | 13 | 16 | 19 | 20 |
    21 |
    22 |
    `; 23 | 24 | export function setupEdit() { 25 | return showEdit; 26 | 27 | async function showEdit(context) { 28 | const recipeId = context.params.id; 29 | const recipe = await getRecipeById(recipeId); 30 | 31 | return editTemplate(recipe); 32 | } 33 | } 34 | 35 | export async function onEditSubmit(data, onSuccess) { 36 | const recipeId = data._id; 37 | const body = { 38 | name: data.name, 39 | img: data.img, 40 | ingredients: data.ingredients.split('\n').map(l => l.trim()).filter(l => l != ''), 41 | steps: data.steps.split('\n').map(l => l.trim()).filter(l => l != '') 42 | }; 43 | 44 | try { 45 | await editRecipe(recipeId, body); 46 | onSuccess(recipeId); 47 | } catch (err) { 48 | alert(err.message); 49 | } 50 | } 51 | 52 | export function setupDeleted() { 53 | return () => html` 54 |
    55 |
    56 |

    Recipe deleted

    57 |
    58 |
    `; 59 | } -------------------------------------------------------------------------------- /lesson-07/finished/src/views/home.js: -------------------------------------------------------------------------------- 1 | import { html } from '../dom.js'; 2 | import { getRecent } from '../api/data.js'; 3 | 4 | 5 | const homeTemplate = (recentRecipes) => html` 6 |
    7 |
    8 |

    Welcome to My Cookbook

    9 |
    10 |
    Recently added recipes
    11 |
    12 | ${recentRecipes[0] ? recentRecipe(recentRecipes[0]) : ''} 13 | ${spacer()} 14 | ${recentRecipes[1] ? recentRecipe(recentRecipes[1]) : ''} 15 | ${spacer()} 16 | ${recentRecipes[2] ? recentRecipe(recentRecipes[2]) : ''} 17 |
    18 | 21 |
    `; 22 | 23 | const recentRecipe = (recipe) => html` 24 | 25 |
    26 |
    27 |
    ${recipe.name}
    28 |
    29 |
    `; 30 | 31 | const spacer = () => html`
    `; 32 | 33 | export function setupHome() { 34 | return showHome; 35 | 36 | async function showHome() { 37 | const recipes = await getRecent(); 38 | 39 | return homeTemplate(recipes); 40 | } 41 | } -------------------------------------------------------------------------------- /lesson-07/finished/src/views/login.js: -------------------------------------------------------------------------------- 1 | import { html } from '../dom.js'; 2 | import { login } from '../api/data.js'; 3 | 4 | 5 | const loginTemplate = () => html` 6 |
    7 |
    8 |

    Login

    9 |
    10 | 11 | 12 | 13 |
    14 |
    15 |
    `; 16 | 17 | 18 | export function setupLogin() { 19 | return showLogin; 20 | 21 | function showLogin() { 22 | return loginTemplate(); 23 | } 24 | } 25 | 26 | export async function onLoginSubmit(data, onSuccess) { 27 | try { 28 | await login(data.email, data.password); 29 | onSuccess(); 30 | } catch (err) { 31 | alert(err.message); 32 | } 33 | } -------------------------------------------------------------------------------- /lesson-07/finished/src/views/register.js: -------------------------------------------------------------------------------- 1 | import { html } from '../dom.js'; 2 | import { regster } from '../api/data.js'; 3 | 4 | 5 | const registerTemplate = () => html` 6 |
    7 |
    8 |

    Register

    9 |
    10 | 11 | 12 | 13 | 14 |
    15 |
    16 |
    `; 17 | 18 | 19 | export function setupRegister() { 20 | return showRegister; 21 | 22 | function showRegister() { 23 | return registerTemplate(); 24 | } 25 | } 26 | 27 | export async function onRegisterSubmit(data, onSuccess) { 28 | if (data.password != data.rePass) { 29 | return alert('Passwords don\'t match'); 30 | } 31 | 32 | try { 33 | await regster(data.email, data.password); 34 | onSuccess(); 35 | } catch (err) { 36 | alert(err.message); 37 | } 38 | } -------------------------------------------------------------------------------- /lesson-07/finished/static/comments.css: -------------------------------------------------------------------------------- 1 | .new-comment textarea { 2 | resize: none; 3 | width: 100%; 4 | height: 200px; 5 | margin-bottom: 32px; 6 | } 7 | 8 | .comments li { 9 | display: block; 10 | width: 720px; 11 | margin: 32px auto 32px auto; 12 | box-shadow: 8px 8px 8px 0 rgba(0, 0, 0, 0.2), 12px 12px 20px 0 rgba(0, 0, 0, 0.19); 13 | border-radius: 16px; 14 | } 15 | 16 | .comment { 17 | background-color: white; 18 | } 19 | 20 | .comment p { 21 | padding: 16px; 22 | } 23 | 24 | .comment header { 25 | border-radius: 16px 16px 0 0; 26 | background-color: #cccccc; 27 | padding: 8px 16px; 28 | } 29 | 30 | .button { 31 | display: block; 32 | border: none; 33 | margin: auto; 34 | background-color: salmon; 35 | padding: 8px 16px; 36 | text-decoration: none; 37 | color: black; 38 | } 39 | 40 | .button:hover { 41 | background-color: #6c8b47; 42 | color: white; 43 | cursor: pointer; 44 | } -------------------------------------------------------------------------------- /lesson-07/finished/static/form.css: -------------------------------------------------------------------------------- 1 | form { 2 | padding: 32px; 3 | } 4 | 5 | label { 6 | display: block; 7 | position: relative; 8 | margin: 16px 0; 9 | text-align: right; 10 | padding-right: 520px; 11 | line-height: 48px; 12 | } 13 | 14 | input[type="text"], input[type="password"], .ml textarea { 15 | position: absolute; 16 | right: 0px; 17 | width: 500px; 18 | padding: 8px; 19 | } 20 | 21 | .ml textarea { 22 | height: 300px; 23 | resize: none; 24 | } 25 | 26 | .ml { 27 | height: 300px; 28 | } 29 | 30 | input[type="submit"] { 31 | display: block; 32 | border: none; 33 | margin: auto; 34 | background-color: salmon; 35 | padding: 8px 16px; 36 | text-decoration: none; 37 | color: black; 38 | } 39 | 40 | input[type="submit"]:hover { 41 | background-color: #6c8b47; 42 | color: white; 43 | cursor: pointer; 44 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "js-apps-workshop", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "dependencies": {}, 7 | "devDependencies": { 8 | "@types/mocha": "^8.2.0", 9 | "http-server": "^0.12.3", 10 | "playwright-chromium": "^1.8.1" 11 | }, 12 | "scripts": { 13 | "test": "echo \"Error: no test specified\" && exit 1", 14 | "dev": "http-server lesson-07/finished -a localhost -p 3000 -P http://localhost:3000?", 15 | "server": "server.bat" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/viktorpts/js-apps-workshop.git" 20 | }, 21 | "keywords": [], 22 | "author": "", 23 | "license": "ISC", 24 | "bugs": { 25 | "url": "https://github.com/viktorpts/js-apps-workshop/issues" 26 | }, 27 | "homepage": "https://github.com/viktorpts/js-apps-workshop#readme" 28 | } 29 | -------------------------------------------------------------------------------- /server.bat: -------------------------------------------------------------------------------- 1 | cd C:\VS Projects\softuni-practice-server 2 | npm run start --------------------------------------------------------------------------------