├── .README
└── todoapp_client.png
├── .gitignore
├── README.md
├── babel.config.js
├── license.md
├── mock
├── handlers.js
└── todo-db.json
├── package.json
├── public
├── _footer.html
├── _head.html
├── assets
│ ├── css
│ │ ├── todoapp-client.css
│ │ └── todomvc-app-css.css
│ ├── favicon.ico
│ └── img
│ │ └── profile-picture.png
├── error.html
├── index.html
└── login.html
├── src
├── assets
│ ├── loading-mask.css
│ └── tooltip.css
├── commons.js
├── thymeleaf.js
└── todos
│ ├── OnlineUsersCounter.vue
│ ├── Todos.vue
│ ├── UserProfile.vue
│ └── main.js
├── vue.config.js
└── yarn.lock
/.README/todoapp_client.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/springrunner/todoapp-client/57ad1cf5e1451731071d3b4bacec51a8ae386661/.README/todoapp_client.png
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | dist
2 | node_modules
3 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # TodoApp Client
2 |
3 | > 이 프로젝트는 [TodoMVC App Template](https://github.com/tastejs/todomvc-app-template/)을 기반으로, [Thymeleaf](https://www.thymeleaf.org/)와 [Vue.js](https://vuejs.org/)로 개발된 웹 클라이언트입니다.
4 |
5 | *****
6 |
7 |
8 |
9 |
10 |
11 | ## 클라이언트 기능
12 |
13 | 클라이언트는 3개 페이지로 구성되어있습니다.
14 |
15 | * **login.html** : 사용자 로그인 페이지 - HTML 폼(form) 전송으로 사용자 로그인을 시도합니다.
16 | * **todos.html** : 할 일 관리 페이지 - AJAX를 사용해 [Web API](https://en.wikipedia.org/wiki/Web_API)를 호출하고, 결과를 출력합니다.
17 | * **error.html** : 오류 페이지 - 서버 발생오류에 담긴 모델을 출력합니다.
18 |
19 | ### 공통
20 | * 모든 페이지(html)는 Thymeleaf 형식으로 작성되었습니다.
21 | * 모든 페이지 하단에는 사이트 작성자와 설명을 노출하는 기능(푸터, Footer)이 포함되어 있습니다.
22 | - 서버에서 제공된 모델(Model)에 다음 키(Key)에 해당하는 값(Value)이 있으면 출력합니다.
23 | - `site.authour`: 사이트 작성자를 출력합니다.
24 | - `site.description`: 사이트 설명을 출력합니다.
25 |
26 | ### login.html
27 | * HTML 폼으로 사용자이름(username)과 비밀번호(password)를 입력받도록 구성되었습니다.
28 | * 로그인 버튼을 클릭하면 `POST /login`로 사용자 입력값(username, password)을 전송합니다.
29 | * 서버에서 제공된 모델(Model)에 다음 키(Key)에 해당하는 값(Value)이 있으면 출력합니다.
30 | - `bindingResult`: [Spring BindingResult](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/validation/BindingResult.html) 객체에서 오류 내용을 출력합니다.
31 | - `message`: 서버에서 전달한 메시지가 있으면 출력합니다.
32 |
33 | ### todos.html
34 | 이 페이지는 Vue.js 기반으로 작성되어 있습니다. 페이지의 기능이 동작하기 위해서는 다음과 같은 Web API가 필요합니다.
35 |
36 | 할 일(Todo) 관리를 위해 다음 API를 사용합니다.
37 |
38 | * `GET /api/todos`: 할 일 목록 조회
39 | * `POST /api/todos`: 새로운 할 일 등록
40 | * `PUT /api/todos/{todo.id}`: 등록된 할 일 수정 또는 완료
41 | * `DELETE /api/todos/{todo.id}`: 등록된 할 일 삭제
42 | * `GET /api/feature-toggles`: 확장 기능 활성화
43 | * `GET /api/user/profile`: 로그인된 사용자 프로필 정보 조회
44 | * `POST /api/user/profile-picture`: 로그인된 사용자 프로필 이미지 변경
45 | * `GET /stream/online-users-counter`: 접속된 사용자 수 변경 이벤트 결과 출력
46 | - 이벤트 스트림은 [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource)로 연결
47 |
48 | > Web API 응답 상태코드가 40X([클라이언트 오류, Client Error](https://developer.mozilla.org/ko/docs/Web/HTTP/Status#%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8_%EC%97%90%EB%9F%AC_%EC%9D%91%EB%8B%B5)), 50X([서버 오류, Server error](https://developer.mozilla.org/ko/docs/Web/HTTP/Status#%EC%84%9C%EB%B2%84_%EC%97%90%EB%9F%AC_%EC%9D%91%EB%8B%B5))라면, 응답 바디에 담긴 오류 모델을 출력합니다. 보다 자세한 내용은 [error.html](#errorhtml) 을 참조바랍니다.
49 |
50 | > [Todoapp Web APIs Document](https://app.swaggerhub.com/apis-docs/code-rain/todoapp/1.0.0-snapshot)에서 보다 상세한 WEB API 스펙을 확인할 수 있습니다.
51 |
52 | 할 일(Todo) 목록을 [CSV(Comma-separated values)](https://en.wikipedia.org/wiki/Comma-separated_values)형식으로 내려받을 목적으로 다음과 같이 서버를 호출합니다.
53 |
54 | ```
55 | Http URL: /todos
56 | Http Method: GET
57 | Http Headers:
58 | Accept: text/csv
59 | ```
60 |
61 | ### error.html
62 | * 서버에서 제공하는 모델(Model)에 다음 키(Key)에 해당하는 값(Value)이 있으면 출력합니다.
63 | - `path`: 오류가 발생한 URL 경로(path)
64 | - `status`: HTTP 상태코드
65 | - `error`: 오류 발생 이유
66 | - `errors`: 스프링 BindingResult 내부에 모든 ObjectErrors 객체 목록
67 | - `message`: 오류 내용
68 | - `timestamp`: 오류 발생시간
69 |
70 | *****
71 |
72 | ## 프로젝트 구성
73 |
74 | [Thymeleaf](https://www.thymeleaf.org/)와 [Vue.js](https://vuejs.org/)로 개발하고, [Vue CLI 3](https://cli.vuejs.org/)으로 관리하고 있습니다.
75 |
76 | ### 디렉토리 구조
77 |
78 | ```
79 | ├── dist
80 | ├── public
81 | ├── src
82 | ├── mock
83 | ├── babel.config.js
84 | ├── vue.config.js
85 | ├── package.json
86 | └── yarn.lock
87 | ```
88 |
89 | * **src**, **public** : 클라이언트 소스 코드
90 | * **mock** : 클라이언트 개발시 사용된 Mock 서버, [json-server](https://github.com/typicode/json-server)로 구동
91 | * **dist** : 빌드(`yarn build`) 명령으로 생성된 배포본
92 |
93 | ### 의존성 관리
94 |
95 | 패키지 의존성 관리를 위해 [얀(yarn)](https://yarnpkg.com/en/)을 사용하며, 클라이언트 개발에 사용된 의존성은 `package.json` 명세파일에 선언되어 있습니다.
96 |
97 | ### 프로젝트 설정
98 | > 클라이언트 빌드를 위해서 `yarn`이 설치되어 있어야 합니다. 설치관련 내용은 [Yarn Installation](https://yarnpkg.com/en/docs/install)를 참조바랍니다.
99 |
100 | ```
101 | $ git clone git@github.com:springrunner/todoapp-client.git
102 | $ cd todoapp-client
103 | $ yarn install
104 | ```
105 |
106 | ## 참고자료
107 |
108 | * [TodoMVC App Template](https://github.com/tastejs/todomvc-app-template/)
109 | * [Thymeleaf](https://www.thymeleaf.org/)
110 | * [Vue.js](https://vuejs.org/)
111 | * [Yarn Installation](https://yarnpkg.com/en/docs/install)
112 | * [Vue CLI 3](https://cli.vuejs.org/)
113 |
114 | ## 라이선스(License)
115 |
116 | 저장소 내 모든 내용은 [MIT 라이선스](https://en.wikipedia.org/wiki/MIT_License)로 제공됩니다.
117 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | '@vue/app'
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/license.md:
--------------------------------------------------------------------------------
1 | Everything in this repo is MIT License unless otherwise specified.
2 |
3 | Copyright (c) 2018 Arawn Park
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of
6 | this software and associated documentation files (the "Software"), to deal in
7 | the Software without restriction, including without limitation the rights to
8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9 | the Software, and to permit persons to whom the Software is furnished to do so,
10 | subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/mock/handlers.js:
--------------------------------------------------------------------------------
1 | module.exports = (req, res, next) => {
2 | if (req.url === '/feature-toggles') {
3 | return res.status(200).send({ auth: true, onlineUsersCounter: true })
4 | } else if (req.url === '/user/profile') {
5 | return res.status(200).send({ name: 'Arawn Park', profilePictureUrl: '/assets/img/profile-picture.png' })
6 | } else if (req.url === '/user/profile-picture') {
7 | return res.status(200).send({ name: 'Arawn Park', profilePictureUrl: '/assets/img/profile-picture.png' })
8 | } else if (req.url === '/stream/online-users-counter') {
9 | return res.status(200)
10 | }
11 | next()
12 | }
--------------------------------------------------------------------------------
/mock/todo-db.json:
--------------------------------------------------------------------------------
1 | {
2 | "todos": [
3 | {
4 | "id": 1543201430679,
5 | "title": "task one",
6 | "completed": true
7 | },
8 | {
9 | "id": 1543201432709,
10 | "title": "task two",
11 | "completed": false
12 | }
13 | ]
14 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vue",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "lint": "vue-cli-service lint",
7 | "serve": "vue-cli-service serve",
8 | "build": "vue-cli-service build",
9 | "local-deploy": "yarn build && copyfiles -V -u 1 ./dist/favicon.ico ../todoapp-server/src/generated/resources/static && copyfiles -V -u 1 ./dist/assets/**/*.* ../todoapp-server/src/generated/resources/static && copyfiles -V -u 1 ./dist/**/*.html ../todoapp-server/src/generated/resources/templates",
10 | "mock": "json-server --watch mock/todo-db.json --middlewares mock/handlers.js"
11 | },
12 | "dependencies": {
13 | "axios": "^0.18.0",
14 | "todomvc-app-css": "^2.1.2",
15 | "todomvc-common": "^1.0.5",
16 | "v-tooltip": "^2.0.0-rc.33",
17 | "vue": "^2.5.17",
18 | "vue-flash-message": "^0.7.2"
19 | },
20 | "devDependencies": {
21 | "@vue/cli-plugin-babel": "^3.1.1",
22 | "@vue/cli-plugin-eslint": "^3.1.5",
23 | "@vue/cli-service": "^3.1.4",
24 | "babel-eslint": "^10.0.1",
25 | "copy-webpack-plugin": "^4.6.0",
26 | "ejs-html-loader": "^3.1.0",
27 | "eslint": "^5.8.0",
28 | "eslint-plugin-vue": "^5.0.0-0",
29 | "html-loader": "^0.5.5",
30 | "json-server": "^0.14.0",
31 | "vue-template-compiler": "^2.5.17"
32 | },
33 | "eslintConfig": {
34 | "root": true,
35 | "env": {
36 | "node": true
37 | },
38 | "extends": [
39 | "plugin:vue/essential",
40 | "eslint:recommended"
41 | ],
42 | "rules": {
43 | "no-console": "off"
44 | },
45 | "parserOptions": {
46 | "parser": "babel-eslint"
47 | }
48 | },
49 | "postcss": {
50 | "plugins": {
51 | "autoprefixer": {}
52 | }
53 | },
54 | "browserslist": [
55 | "> 1%",
56 | "last 2 versions",
57 | "not ie <= 8"
58 | ]
59 | }
60 |
--------------------------------------------------------------------------------
/public/_footer.html:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/_head.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | <%= TITLE %>
--------------------------------------------------------------------------------
/public/assets/css/todoapp-client.css:
--------------------------------------------------------------------------------
1 | /* login.html */
2 | .auth-form {
3 | padding: 30px 25px;
4 | }
5 | .auth-form label {
6 | display: block;
7 | padding-left: 2px;
8 | margin-bottom: 7px;
9 | }
10 | .auth-form .label-link {
11 | float: right;
12 | font-size: 12px;
13 | color: #0366d6;
14 | text-decoration: none;
15 | }
16 | .auth-form input {
17 | display: block;
18 | width: 100%;
19 | margin-top: 5px;
20 | background-color: #fff;
21 | background-position: right 8px center;
22 | background-repeat: no-repeat;
23 | border: 1px solid #d1d5da;
24 | border-radius: 3px;
25 | color: #24292e;
26 | font-size: 14px;
27 | line-height: 20px;
28 | min-height: 24px;
29 | outline: none;
30 | padding: 6px 8px;
31 | }
32 | .auth-form .form-group {
33 | margin-bottom: 15px;
34 | }
35 | .auth-form .invalid-feedback {
36 | color: #dc3545;
37 | font-size: 0.8em;
38 | margin-top: .1rem;
39 | }
40 | .auth-form .btn {
41 | margin-top: 20px;
42 | text-align: center;
43 | }
44 | .auth-form .btn button {
45 | color: inherit;
46 | line-height: 20px;
47 | padding: 6px 8px;
48 | border: 1px solid transparent;
49 | border-radius: 3px;
50 | border-color: rgba(175, 47, 47, 0.2);
51 | width: 100%;
52 | }
53 | .auth-form .btn button:hover, button:focus {
54 | cursor: pointer;
55 | font-weight: 400;
56 | border-color: rgba(175, 47, 47, 0.35);
57 | }
58 |
59 | .auth-text {
60 | text-align: center;
61 | font-size: 1.1em;
62 | }
63 | .auth-link {
64 | color: rgba(175, 47, 47, 0.58);
65 | }
66 |
67 | .error-title {
68 | text-align: left;
69 | padding-top: 1em;
70 | font-size: 10em;
71 | }
72 | .error-list {
73 | margin: 10px 20px;
74 | }
75 |
76 | /* error.html */
77 | h1.error-title {
78 | position: absolute;
79 | top: -120px;
80 | width: 100%;
81 | font-size: 40px;
82 | font-weight: 400;
83 | text-align: center;
84 | color: rgba(175, 47, 47, 0.15);
85 | -webkit-text-rendering: optimizeLegibility;
86 | -moz-text-rendering: optimizeLegibility;
87 | text-rendering: optimizeLegibility;
88 | }
89 | .error-attributes {
90 | padding: 10px 10px;
91 | }
92 |
--------------------------------------------------------------------------------
/public/assets/css/todomvc-app-css.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | margin: 0;
4 | padding: 0;
5 | }
6 |
7 | button {
8 | margin: 0;
9 | padding: 0;
10 | border: 0;
11 | background: none;
12 | font-size: 100%;
13 | vertical-align: baseline;
14 | font-family: inherit;
15 | font-weight: inherit;
16 | color: inherit;
17 | -webkit-appearance: none;
18 | appearance: none;
19 | -webkit-font-smoothing: antialiased;
20 | -moz-osx-font-smoothing: grayscale;
21 | }
22 |
23 | body {
24 | font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif;
25 | line-height: 1.4em;
26 | background: #f5f5f5;
27 | color: #4d4d4d;
28 | min-width: 230px;
29 | max-width: 550px;
30 | margin: 0 auto;
31 | -webkit-font-smoothing: antialiased;
32 | -moz-osx-font-smoothing: grayscale;
33 | font-weight: 300;
34 | }
35 |
36 | :focus {
37 | outline: 0;
38 | }
39 |
40 | .hidden {
41 | display: none;
42 | }
43 |
44 | .todoapp {
45 | background: #fff;
46 | margin: 130px 0 40px 0;
47 | position: relative;
48 | box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2),
49 | 0 25px 50px 0 rgba(0, 0, 0, 0.1);
50 | }
51 |
52 | .todoapp input::-webkit-input-placeholder {
53 | font-style: italic;
54 | font-weight: 300;
55 | color: #e6e6e6;
56 | }
57 |
58 | .todoapp input::-moz-placeholder {
59 | font-style: italic;
60 | font-weight: 300;
61 | color: #e6e6e6;
62 | }
63 |
64 | .todoapp input::input-placeholder {
65 | font-style: italic;
66 | font-weight: 300;
67 | color: #e6e6e6;
68 | }
69 |
70 | .todoapp h1 {
71 | position: absolute;
72 | top: -155px;
73 | width: 100%;
74 | font-size: 100px;
75 | font-weight: 100;
76 | text-align: center;
77 | color: rgba(175, 47, 47, 0.15);
78 | -webkit-text-rendering: optimizeLegibility;
79 | -moz-text-rendering: optimizeLegibility;
80 | text-rendering: optimizeLegibility;
81 | }
82 |
83 | .new-todo,
84 | .edit {
85 | position: relative;
86 | margin: 0;
87 | width: 100%;
88 | font-size: 24px;
89 | font-family: inherit;
90 | font-weight: inherit;
91 | line-height: 1.4em;
92 | border: 0;
93 | color: inherit;
94 | padding: 6px;
95 | border: 1px solid #999;
96 | box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2);
97 | box-sizing: border-box;
98 | -webkit-font-smoothing: antialiased;
99 | -moz-osx-font-smoothing: grayscale;
100 | }
101 |
102 | .new-todo {
103 | padding: 16px 16px 16px 60px;
104 | border: none;
105 | background: rgba(0, 0, 0, 0.003);
106 | box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03);
107 | }
108 |
109 | .main {
110 | position: relative;
111 | z-index: 2;
112 | border-top: 1px solid #e6e6e6;
113 | }
114 |
115 | .toggle-all {
116 | width: 1px;
117 | height: 1px;
118 | border: none; /* Mobile Safari */
119 | opacity: 0;
120 | position: absolute;
121 | right: 100%;
122 | bottom: 100%;
123 | }
124 |
125 | .toggle-all + label {
126 | width: 60px;
127 | height: 34px;
128 | font-size: 0;
129 | position: absolute;
130 | top: -52px;
131 | left: -13px;
132 | -webkit-transform: rotate(90deg);
133 | transform: rotate(90deg);
134 | }
135 |
136 | .toggle-all + label:before {
137 | content: '❯';
138 | font-size: 22px;
139 | color: #e6e6e6;
140 | padding: 10px 27px 10px 27px;
141 | }
142 |
143 | .toggle-all:checked + label:before {
144 | color: #737373;
145 | }
146 |
147 | .todo-list {
148 | margin: 0;
149 | padding: 0;
150 | list-style: none;
151 | }
152 |
153 | .todo-list li {
154 | position: relative;
155 | font-size: 24px;
156 | border-bottom: 1px solid #ededed;
157 | }
158 |
159 | .todo-list li:last-child {
160 | border-bottom: none;
161 | }
162 |
163 | .todo-list li.editing {
164 | border-bottom: none;
165 | padding: 0;
166 | }
167 |
168 | .todo-list li.editing .edit {
169 | display: block;
170 | width: 506px;
171 | padding: 12px 16px;
172 | margin: 0 0 0 43px;
173 | }
174 |
175 | .todo-list li.editing .view {
176 | display: none;
177 | }
178 |
179 | .todo-list li .toggle {
180 | text-align: center;
181 | width: 40px;
182 | /* auto, since non-WebKit browsers doesn't support input styling */
183 | height: auto;
184 | position: absolute;
185 | top: 0;
186 | bottom: 0;
187 | margin: auto 0;
188 | border: none; /* Mobile Safari */
189 | -webkit-appearance: none;
190 | appearance: none;
191 | }
192 |
193 | .todo-list li .toggle {
194 | opacity: 0;
195 | }
196 |
197 | .todo-list li .toggle + label {
198 | /*
199 | Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433
200 | IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/
201 | */
202 | background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E');
203 | background-repeat: no-repeat;
204 | background-position: center left;
205 | }
206 |
207 | .todo-list li .toggle:checked + label {
208 | background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E');
209 | }
210 |
211 | .todo-list li label {
212 | word-break: break-all;
213 | padding: 15px 15px 15px 60px;
214 | display: block;
215 | line-height: 1.2;
216 | transition: color 0.4s;
217 | }
218 |
219 | .todo-list li.completed label {
220 | color: #d9d9d9;
221 | text-decoration: line-through;
222 | }
223 |
224 | .todo-list li .destroy {
225 | display: none;
226 | position: absolute;
227 | top: 0;
228 | right: 10px;
229 | bottom: 0;
230 | width: 40px;
231 | height: 40px;
232 | margin: auto 0;
233 | font-size: 30px;
234 | color: #cc9a9a;
235 | margin-bottom: 11px;
236 | transition: color 0.2s ease-out;
237 | }
238 |
239 | .todo-list li .destroy:hover {
240 | color: #af5b5e;
241 | }
242 |
243 | .todo-list li .destroy:after {
244 | content: '×';
245 | }
246 |
247 | .todo-list li:hover .destroy {
248 | display: block;
249 | }
250 |
251 | .todo-list li .edit {
252 | display: none;
253 | }
254 |
255 | .todo-list li.editing:last-child {
256 | margin-bottom: -1px;
257 | }
258 |
259 | .footer {
260 | color: #777;
261 | padding: 10px 15px;
262 | height: 20px;
263 | text-align: center;
264 | border-top: 1px solid #e6e6e6;
265 | }
266 |
267 | .footer:before {
268 | content: '';
269 | position: absolute;
270 | right: 0;
271 | bottom: 0;
272 | left: 0;
273 | height: 50px;
274 | overflow: hidden;
275 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2),
276 | 0 8px 0 -3px #f6f6f6,
277 | 0 9px 1px -3px rgba(0, 0, 0, 0.2),
278 | 0 16px 0 -6px #f6f6f6,
279 | 0 17px 2px -6px rgba(0, 0, 0, 0.2);
280 | }
281 |
282 | .todo-count {
283 | float: left;
284 | text-align: left;
285 | }
286 |
287 | .todo-count strong {
288 | font-weight: 300;
289 | }
290 |
291 | .filters {
292 | margin: 0;
293 | padding: 0;
294 | list-style: none;
295 | position: absolute;
296 | right: 0;
297 | left: 0;
298 | }
299 |
300 | .filters li {
301 | display: inline;
302 | }
303 |
304 | .filters li a {
305 | color: inherit;
306 | margin: 3px;
307 | padding: 3px 7px;
308 | text-decoration: none;
309 | border: 1px solid transparent;
310 | border-radius: 3px;
311 | }
312 |
313 | .filters li a:hover {
314 | border-color: rgba(175, 47, 47, 0.1);
315 | }
316 |
317 | .filters li a.selected {
318 | border-color: rgba(175, 47, 47, 0.2);
319 | }
320 |
321 | .clear-completed,
322 | html .clear-completed:active {
323 | float: right;
324 | position: relative;
325 | line-height: 20px;
326 | text-decoration: none;
327 | cursor: pointer;
328 | }
329 |
330 | .clear-completed:hover {
331 | text-decoration: underline;
332 | }
333 |
334 | .info {
335 | margin: 65px auto 0;
336 | color: #bfbfbf;
337 | font-size: 10px;
338 | text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
339 | text-align: center;
340 | }
341 |
342 | .info p {
343 | line-height: 1;
344 | }
345 |
346 | .info a {
347 | color: inherit;
348 | text-decoration: none;
349 | font-weight: 400;
350 | }
351 |
352 | .info a:hover {
353 | text-decoration: underline;
354 | }
355 |
356 | /*
357 | Hack to remove background from Mobile Safari.
358 | Can't use it globally since it destroys checkboxes in Firefox
359 | */
360 | @media screen and (-webkit-min-device-pixel-ratio:0) {
361 | .toggle-all,
362 | .todo-list li .toggle {
363 | background: none;
364 | }
365 |
366 | .todo-list li .toggle {
367 | height: 40px;
368 | }
369 | }
370 |
371 | @media (max-width: 430px) {
372 | .footer {
373 | height: 50px;
374 | }
375 |
376 | .filters {
377 | bottom: 10px;
378 | }
379 | }
380 |
--------------------------------------------------------------------------------
/public/assets/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/springrunner/todoapp-client/57ad1cf5e1451731071d3b4bacec51a8ae386661/public/assets/favicon.ico
--------------------------------------------------------------------------------
/public/assets/img/profile-picture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/springrunner/todoapp-client/57ad1cf5e1451731071d3b4bacec51a8ae386661/public/assets/img/profile-picture.png
--------------------------------------------------------------------------------
/public/error.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | <%- include(`_head.html`) -%>
5 |
6 |
7 |
8 |
11 |
12 |
13 | - path: path
14 | - status: 999
15 | - error: error type
16 |
17 | - error description
18 |
19 |
20 | - message: error message
21 | - timestamp: timestamp
22 |
23 |
24 |
25 | <%- include(`_footer.html`) -%>
26 |
27 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | <%- include(`_head.html`) -%>
5 |
6 |
7 |
10 |
13 | <%- include(`_footer.html`) -%>
14 |
15 |
--------------------------------------------------------------------------------
/public/login.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | <%- include(`_head.html`) -%>
5 |
10 |
11 |
12 |
44 | <%- include(`_footer.html`) -%>
45 |
46 |
--------------------------------------------------------------------------------
/src/assets/loading-mask.css:
--------------------------------------------------------------------------------
1 | .loading-mask {
2 | position: relative;
3 | }
4 | .loading-mask::before {
5 | content: "";
6 | position: absolute;
7 | top: 0;
8 | right: 0;
9 | bottom: 0;
10 | left: 0;
11 | background-color: rgba(0, 0, 0, 0.05);
12 | }
13 | .loading-mask::after {
14 | content: "";
15 | position: absolute;
16 | border-width: 3px;
17 | border-style: solid;
18 | border-color: transparent rgb(0, 0, 0, 0.1) rgb(0, 0, 0, 0.1);
19 | border-radius: 50%;
20 | width: 24px;
21 | height: 24px;
22 | top: calc(50% - 12px);
23 | left: calc(50% - 12px);
24 | animation: 2s linear 0s normal none infinite running spin;
25 | filter: drop-shadow(0 0 2 rgba(0, 0, 0, 0.33));
26 | }
27 | @keyframes spin {
28 | from {
29 | transform: rotate(0deg);
30 | }
31 | to {
32 | transform: rotate(359deg);
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/assets/tooltip.css:
--------------------------------------------------------------------------------
1 | .tooltip {
2 | display: block !important;
3 | z-index: 10000;
4 | }
5 |
6 | .tooltip .tooltip-inner {
7 | background: #ffffff;
8 | border: 1px solid rgb(0, 0, 0, 0.6);
9 | color: #4d4d4d;
10 | font-size: 0.9em;
11 | border-radius: 6px;
12 | padding: 5px 10px 4px;
13 | }
14 |
15 | .tooltip .tooltip-arrow {
16 | width: 0;
17 | height: 0;
18 | border-style: solid;
19 | position: absolute;
20 | margin: 5px;
21 | border-color: rgba(0, 0, 0, 0.6);
22 | z-index: 1;
23 | }
24 |
25 | .tooltip[x-placement^="top"] {
26 | margin-bottom: 5px;
27 | }
28 |
29 | .tooltip[x-placement^="top"] .tooltip-arrow {
30 | border-width: 5px 5px 0 5px;
31 | border-left-color: transparent !important;
32 | border-right-color: transparent !important;
33 | border-bottom-color: transparent !important;
34 | bottom: -5px;
35 | left: calc(50% - 5px);
36 | margin-top: 0;
37 | margin-bottom: 0;
38 | }
39 |
40 | .tooltip[x-placement^="bottom"] {
41 | margin-top: 5px;
42 | }
43 |
44 | .tooltip[x-placement^="bottom"] .tooltip-arrow {
45 | border-width: 0 5px 5px 5px;
46 | border-left-color: transparent !important;
47 | border-right-color: transparent !important;
48 | border-top-color: transparent !important;
49 | top: -5px;
50 | left: calc(50% - 5px);
51 | margin-top: 0;
52 | margin-bottom: 0;
53 | }
54 |
55 | .tooltip[x-placement^="right"] {
56 | margin-left: 5px;
57 | }
58 |
59 | .tooltip[x-placement^="right"] .tooltip-arrow {
60 | border-width: 5px 5px 5px 0;
61 | border-left-color: transparent !important;
62 | border-top-color: transparent !important;
63 | border-bottom-color: transparent !important;
64 | left: -5px;
65 | top: calc(50% - 5px);
66 | margin-left: 0;
67 | margin-right: 0;
68 | }
69 |
70 | .tooltip[x-placement^="left"] {
71 | margin-right: 5px;
72 | }
73 |
74 | .tooltip[x-placement^="left"] .tooltip-arrow {
75 | border-width: 5px 0 5px 5px;
76 | border-top-color: transparent !important;
77 | border-right-color: transparent !important;
78 | border-bottom-color: transparent !important;
79 | right: -5px;
80 | top: calc(50% - 5px);
81 | margin-left: 0;
82 | margin-right: 0;
83 | }
84 |
85 | .tooltip.popover .popover-inner {
86 | background: #f9f9f9;
87 | color: black;
88 | padding: 24px;
89 | border-radius: 5px;
90 | box-shadow: 0 5px 30px rgba(black, 0.1);
91 | }
92 |
93 | .tooltip.popover .popover-arrow {
94 | border-color: #f9f9f9;
95 | }
96 |
97 | .tooltip[aria-hidden="true"] {
98 | visibility: hidden;
99 | opacity: 0;
100 | transition: opacity 0.15s, visibility 0.15s;
101 | }
102 |
103 | .tooltip[aria-hidden="false"] {
104 | visibility: visible;
105 | opacity: 1;
106 | transition: opacity 0.15s;
107 | }
108 |
--------------------------------------------------------------------------------
/src/commons.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const isBlank = function(value) {
4 | return (!value || /^\s*$/.test(value))
5 | }
6 | const containsKey = function(value){
7 | for (let index = 1; index < arguments.length; index++) {
8 | if (value === null || typeof value !== 'object' || !value.hasOwnProperty(arguments[index])) {
9 | return false
10 | }
11 | value = value[arguments[index]]
12 | }
13 | return true
14 | }
15 | const getErrorMessage = function(error, defaultMessage) {
16 | if (containsKey(error.response.data, "error") && containsKey(error.response.data, "message")) {
17 | return error.response.data.message
18 | }
19 | return typeof defaultMessage === 'string' ? defaultMessage : '예기치 않은 서버 오류가 발생했습니다.\n잠시 후 다시 시도해주세요.'
20 | }
21 |
22 | export default {
23 | isBlank, containsKey, getErrorMessage
24 | }
--------------------------------------------------------------------------------
/src/thymeleaf.js:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/springrunner/todoapp-client/57ad1cf5e1451731071d3b4bacec51a8ae386661/src/thymeleaf.js
--------------------------------------------------------------------------------
/src/todos/OnlineUsersCounter.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{ count }} users currently online
4 |
5 |
6 |
7 |
55 |
56 |
--------------------------------------------------------------------------------
/src/todos/Todos.vue:
--------------------------------------------------------------------------------
1 |
2 |
39 |
40 |
41 |
201 |
202 |
251 |
--------------------------------------------------------------------------------
/src/todos/UserProfile.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Please login to write todos
4 |
5 |
![]()
6 |
Welcome your visit to {{ user.name }}
7 |
15 |
16 |
17 |
18 |
19 |
64 |
65 |
--------------------------------------------------------------------------------
/src/todos/main.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import VueTooltip from 'v-tooltip'
3 | import VueFlashMessage from 'vue-flash-message'
4 | import axios from 'axios'
5 | import Todos from './Todos'
6 |
7 | Vue.config.productionTip = false
8 | Vue.prototype.$http = axios
9 | Vue.use(VueTooltip)
10 | Vue.use(VueFlashMessage, {
11 | messageOptions: {
12 | timeout: 3000,
13 | important: true,
14 | pauseOnInteract: true
15 | }
16 | })
17 |
18 | new Vue({
19 | render: h => h(Todos),
20 | }).$mount('#app')
--------------------------------------------------------------------------------
/vue.config.js:
--------------------------------------------------------------------------------
1 | const CopyWebpackPlugin = require('copy-webpack-plugin')
2 | const path = require('path')
3 |
4 | const BASE_URL = '/'
5 | module.exports = {
6 | baseUrl: BASE_URL,
7 | pages: {
8 | todos: 'src/todos/main.js',
9 | login: {
10 | entry: 'src/thymeleaf.js',
11 | template: 'public/login.html'
12 | },
13 | error: {
14 | entry: 'src/thymeleaf.js',
15 | template: 'public/error.html'
16 | }
17 | },
18 | assetsDir: 'assets',
19 | filenameHashing: false,
20 | configureWebpack: {
21 | plugins: [
22 | new CopyWebpackPlugin([{
23 | from: path.resolve(__dirname, `./node_modules/todomvc-app-css/index.css`),
24 | to: path.resolve(__dirname, `./public/assets/css/todomvc-app-css.css`)
25 | }])
26 | ]
27 | },
28 | chainWebpack: config => {
29 | // thymeleaf ${} 평가식이 lodash.template에게 평가되는 것을 회피하기 위해 html, ejs 로더를 활용한다.
30 | // ejs-loader 가 아닌, ejs-html-loader를 사용하는건 include 기능을 사용하기 위해서다.
31 | // https://github.com/lodash/lodash/issues/1009, .set('query', { interpolate: /<%=([\s\S]+?)%>/g })
32 | config.module
33 | .rule('html')
34 | .test(/\.html$/)
35 | .use('html-loader')
36 | .loader('html-loader')
37 | .end()
38 | .use('ejs-html-loader')
39 | .loader('ejs-html-loader')
40 | .options({
41 | 'BASE_URL': BASE_URL,
42 | 'TITLE': 'TodoApp templates for Server-side'
43 | })
44 | .end()
45 | },
46 | devServer: {
47 | proxy: {
48 | "/api": {
49 | target: "http://localhost:3000",
50 | changeOrigin: true,
51 | pathRewrite: {
52 | "^/api": ""
53 | }
54 | },
55 | "/stream": {
56 | target: "http://localhost:3000",
57 | changeOrigin: true
58 | }
59 | }
60 | }
61 | }
--------------------------------------------------------------------------------