├── .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 |
9 |

Oops! Server Error... ;ㅁ;

10 |
11 |
12 | 23 |
24 |
25 | <%- include(`_footer.html`) -%> 26 | 27 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%- include(`_head.html`) -%> 5 | 6 | 7 | 10 |
11 |
12 |
13 | <%- include(`_footer.html`) -%> 14 | 15 | -------------------------------------------------------------------------------- /public/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%- include(`_head.html`) -%> 5 | 10 | 11 | 12 |
13 |
14 |

todos

15 |
16 |
17 |
18 |
19 | 22 | 23 |
24 | Please provide a valid username 25 |
26 |
27 |
28 | 31 | 32 |
33 | Please provide a valid password 34 |
35 |
36 |
37 | 38 |
39 |
40 |

unregistered users are automatically joined.

41 |

Server Message

42 |
43 |
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 | 6 | 7 | 55 | 56 | -------------------------------------------------------------------------------- /src/todos/Todos.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 201 | 202 | 251 | -------------------------------------------------------------------------------- /src/todos/UserProfile.vue: -------------------------------------------------------------------------------- 1 | 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 | } --------------------------------------------------------------------------------