├── .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 |
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 |
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 |
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 |
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 |
20 |
21 |
22 | New Recipe
23 |
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 |
16 | Catalog
17 |
21 |
25 |
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 |
21 |
22 |
23 | Login
24 |
25 | E-mail:
26 | Password:
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 |
21 |
22 |
23 | Register
24 |
25 | E-mail:
26 | Password:
27 | Repeat:
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 |
21 |
22 |
23 | New Recipe
24 |
25 | Name:
26 | Image:
27 | Ingredients:
28 | Preparation:
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 |
16 | Catalog
17 |
21 |
25 |
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 |
22 |
23 |
24 | Login
25 |
26 | E-mail:
27 | Password:
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 |
22 |
23 |
24 | Register
25 |
26 | E-mail:
27 | Password:
28 | Repeat:
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 |
21 |
22 |
23 | New Recipe
24 |
25 | Name:
26 | Image:
27 | Ingredients:
28 | Preparation:
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 |
16 | Catalog
17 |
21 |
25 |
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 |
22 |
23 |
24 | Login
25 |
26 | E-mail:
27 | Password:
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 |
22 |
23 |
24 | Register
25 |
26 | E-mail:
27 | Password:
28 | Repeat:
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 |
17 |
18 | Catalog
19 |
23 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
Welcome to My Cookbook
34 |
35 |
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 |
18 |
19 | Catalog
20 |
24 |
28 |
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 | ${pager(goTo, page, pages)}
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`` : ''}
23 | ${page < pages ? html`` : ''}`;
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 | `;
13 |
14 | const commentFormTemplate = (active, toggleForm) => html`
15 | `;
25 |
26 | const commentsList = (comments) => html`
27 |
28 | ${comments.map(comment)}
29 | `;
30 |
31 | const comment = (data) => html`
32 | `;
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 | `;
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 | goTo('edit', recipe._id)}>\u270E Edit
32 | \u2716 Delete
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 | `;
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 |
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 | `;
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 | `;
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 |
18 |
19 | Catalog
20 |
24 |
28 |
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 | ${pager(goTo, page, pages)}
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`` : ''}
23 | ${page < pages ? html`` : ''}`;
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 | `;
13 |
14 | const commentFormTemplate = (active, toggleForm) => html`
15 | `;
25 |
26 | const commentsList = (comments) => html`
27 |
28 | ${comments.map(comment)}
29 | `;
30 |
31 | const comment = (data) => html`
32 | `;
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 | `;
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 | goTo('edit', recipe._id)}>\u270E Edit
32 | \u2716 Delete
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 | `;
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 |
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 | `;
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 | `;
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 |
18 |
19 | Catalog
20 |
24 |
28 |
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 | `;
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`` : ''}
31 | ${page < pages ? html`` : ''}`;
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 | `;
13 |
14 | const commentFormTemplate = (active, toggleForm, onSubmit) => html`
15 | `;
25 |
26 | const commentsList = (comments) => html`
27 |
28 | ${comments.map(comment)}
29 | `;
30 |
31 | const comment = (data) => html`
32 | `;
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 | `;
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 | `
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 | `;
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 |
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 | `;
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 | `;
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
--------------------------------------------------------------------------------