-
3.browserslistrc
-
9.editorconfig
-
14.eslintrc.js
-
24.gitignore
-
4.prettierrc
-
21LICENSE
-
22README.md
-
10babel.config.js
-
18jest.config.js
-
54package.json
-
5postcss.config.js
-
BINpublic/favicon.ico
-
BINpublic/img/icons/android-chrome-192x192.png
-
BINpublic/img/icons/android-chrome-512x512.png
-
BINpublic/img/icons/apple-touch-icon-120x120.png
-
BINpublic/img/icons/apple-touch-icon-152x152.png
-
BINpublic/img/icons/apple-touch-icon-180x180.png
-
BINpublic/img/icons/apple-touch-icon-60x60.png
-
BINpublic/img/icons/apple-touch-icon-76x76.png
-
BINpublic/img/icons/apple-touch-icon.png
-
BINpublic/img/icons/favicon-16x16.png
-
BINpublic/img/icons/favicon-32x32.png
-
BINpublic/img/icons/msapplication-icon-144x144.png
-
BINpublic/img/icons/mstile-150x150.png
-
149public/img/icons/safari-pinned-tab.svg
-
28public/index.html
-
20public/manifest.json
-
2public/robots.txt
-
22src/App.vue
-
108src/common/api.service.js
-
5src/common/config.js
-
5src/common/date.filter.js
-
3src/common/error.filter.js
-
15src/common/jwt.service.js
-
101src/components/ArticleActions.vue
-
125src/components/ArticleList.vue
-
78src/components/ArticleMeta.vue
-
49src/components/Comment.vue
-
54src/components/CommentEditor.vue
-
17src/components/ListErrors.vue
-
20src/components/TagList.vue
-
21src/components/TheFooter.vue
-
96src/components/TheHeader.vue
-
37src/components/VArticlePreview.vue
-
43src/components/VPagination.vue
-
22src/components/VTag.vue
-
27src/main.js
-
28src/registerServiceWorker.js
-
76src/router/index.js
-
22src/store/actions.type.js
-
132src/store/article.module.js
-
112src/store/auth.module.js
-
87src/store/home.module.js
-
18src/store/index.js
-
13src/store/mutations.type.js
-
74src/store/profile.module.js
-
45src/store/settings.module.js
-
95src/views/Article.vue
-
150src/views/ArticleEdit.vue
-
80src/views/Home.vue
-
13src/views/HomeGlobal.vue
-
14src/views/HomeMyFeed.vue
-
19src/views/HomeTag.vue
-
67src/views/Login.vue
-
112src/views/Profile.vue
-
21src/views/ProfileArticles.vue
-
22src/views/ProfileFavorited.vue
-
80src/views/Register.vue
-
88src/views/Settings.vue
-
BINstatic/rwv-logo.png
-
5tests/unit/.eslintrc.js
-
47tests/unit/components/ListError.spec.js
-
34tests/unit/components/VPagination.spec.js
-
25tests/unit/components/VTag.spec.js
-
51tests/unit/example.spec.js
-
18tests/unit/store/__snapshots__/article.module.spec.js.snap
-
260tests/unit/store/article.module.spec.js
-
11322yarn.lock
@ -0,0 +1,3 @@ |
|||||
|
> 1% |
||||
|
last 2 versions |
||||
|
not ie <= 8 |
||||
@ -0,0 +1,9 @@ |
|||||
|
root = true |
||||
|
|
||||
|
[*] |
||||
|
charset = utf-8 |
||||
|
indent_style = space |
||||
|
indent_size = 2 |
||||
|
end_of_line = lf |
||||
|
insert_final_newline = true |
||||
|
trim_trailing_whitespace = true |
||||
@ -0,0 +1,14 @@ |
|||||
|
module.exports = { |
||||
|
root: true, |
||||
|
env: { |
||||
|
node: true |
||||
|
}, |
||||
|
extends: ["plugin:vue/essential", "@vue/prettier"], |
||||
|
rules: { |
||||
|
"no-console": process.env.NODE_ENV === "production" ? "error" : "off", |
||||
|
"no-debugger": process.env.NODE_ENV === "production" ? "error" : "off" |
||||
|
}, |
||||
|
parserOptions: { |
||||
|
parser: "babel-eslint" |
||||
|
} |
||||
|
}; |
||||
@ -0,0 +1,24 @@ |
|||||
|
.DS_Store |
||||
|
node_modules |
||||
|
/dist |
||||
|
|
||||
|
# local env files |
||||
|
.env.local |
||||
|
.env.*.local |
||||
|
|
||||
|
# Log files |
||||
|
npm-debug.log* |
||||
|
yarn-debug.log* |
||||
|
yarn-error.log* |
||||
|
|
||||
|
# Editor directories and files |
||||
|
.idea |
||||
|
.vscode |
||||
|
*.suo |
||||
|
*.ntvs* |
||||
|
*.njsproj |
||||
|
*.sln |
||||
|
*.sw* |
||||
|
|
||||
|
# Coverage Reports |
||||
|
coverage |
||||
@ -0,0 +1,4 @@ |
|||||
|
{ |
||||
|
"singleQuote": false, |
||||
|
"trailingComma": "none" |
||||
|
} |
||||
@ -0,0 +1,21 @@ |
|||||
|
The MIT License (MIT) |
||||
|
|
||||
|
Copyright (c) 2017-present all contributors listed here https://github.com/gothinkster/vue-realworld-example-app/graphs/contributors |
||||
|
|
||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy |
||||
|
of this software and associated documentation files (the "Software"), to deal |
||||
|
in the Software without restriction, including without limitation the rights |
||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
||||
|
copies of the Software, and to permit persons to whom the Software is |
||||
|
furnished to do so, subject to the following conditions: |
||||
|
|
||||
|
The above copyright notice and this permission notice shall be included in all |
||||
|
copies or substantial portions of the Software. |
||||
|
|
||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
||||
|
SOFTWARE. |
||||
@ -0,0 +1,22 @@ |
|||||
|
## Getting started |
||||
|
|
||||
|
Before contributing please read the following: |
||||
|
|
||||
|
The stack is built using [vue-cli webpack](https://github.com/vuejs-templates/webpack) so to get started all you have to do is: |
||||
|
|
||||
|
``` bash |
||||
|
# install dependencies |
||||
|
> yarn install |
||||
|
# serve with hot reload at localhost:8080 |
||||
|
> yarn serve |
||||
|
``` |
||||
|
|
||||
|
Other commands available are: |
||||
|
|
||||
|
``` bash |
||||
|
# build for production with minification |
||||
|
yarn run build |
||||
|
|
||||
|
# run unit tests |
||||
|
yarn test |
||||
|
``` |
||||
@ -0,0 +1,10 @@ |
|||||
|
module.exports = { |
||||
|
env: { |
||||
|
dev: { |
||||
|
presets: ["@vue/app"] |
||||
|
}, |
||||
|
test: { |
||||
|
presets: ["@babel/preset-env"] |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
@ -0,0 +1,18 @@ |
|||||
|
module.exports = { |
||||
|
moduleFileExtensions: ["js", "jsx", "json", "vue"], |
||||
|
transform: { |
||||
|
"^.+\\.vue$": "vue-jest", |
||||
|
".+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$": |
||||
|
"jest-transform-stub", |
||||
|
"^.+\\.(js|jsx)?$": "babel-jest" |
||||
|
}, |
||||
|
moduleNameMapper: { |
||||
|
"^@/(.*)$": "<rootDir>/src/$1" |
||||
|
}, |
||||
|
snapshotSerializers: ["jest-serializer-vue"], |
||||
|
testMatch: [ |
||||
|
"<rootDir>/(tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx))" |
||||
|
], |
||||
|
testURL: "http://localhost/", |
||||
|
transformIgnorePatterns: ["<rootDir>/node_modules/"] |
||||
|
}; |
||||
@ -0,0 +1,54 @@ |
|||||
|
{ |
||||
|
"author": "Emmanuel Vilsbol <emmanuel@vilsbol.com>", |
||||
|
"dependencies": { |
||||
|
"axios": "^0.19.0", |
||||
|
"date-fns": "^1.30.1", |
||||
|
"marked": "^0.7.0", |
||||
|
"register-service-worker": "^1.6.2", |
||||
|
"vue": "^2.6.10", |
||||
|
"vue-axios": "^2.1.4", |
||||
|
"vue-router": "^3.1.3", |
||||
|
"vuex": "^3.1.1" |
||||
|
}, |
||||
|
"description": "TodoMVC for the RealWorld™", |
||||
|
"devDependencies": { |
||||
|
"@babel/core": "^7.6.2", |
||||
|
"@babel/preset-env": "^7.6.2", |
||||
|
"@vue/cli-plugin-babel": "^3.12.0", |
||||
|
"@vue/cli-plugin-eslint": "^3.10.0", |
||||
|
"@vue/cli-plugin-pwa": "^3.10.0", |
||||
|
"@vue/cli-plugin-unit-jest": "^3.10.0", |
||||
|
"@vue/cli-service": "^3.10.0", |
||||
|
"@vue/eslint-config-prettier": "^4.0.1", |
||||
|
"@vue/test-utils": "^1.0.0-beta.29", |
||||
|
"babel-core": "7.0.0-bridge.0", |
||||
|
"babel-jest": "^24.9.0", |
||||
|
"cross-env": "^6.0.3", |
||||
|
"lint-staged": "^8.2.1", |
||||
|
"node-sass": "^4.12.0", |
||||
|
"nyc": "^14.1.1", |
||||
|
"sass-loader": "^7.1.0", |
||||
|
"vue-template-compiler": "^2.6.10" |
||||
|
}, |
||||
|
"gitHooks": { |
||||
|
"pre-commit": "lint-staged" |
||||
|
}, |
||||
|
"lint-staged": { |
||||
|
"*.js": [ |
||||
|
"vue-cli-service lint", |
||||
|
"git add" |
||||
|
], |
||||
|
"*.vue": [ |
||||
|
"vue-cli-service lint", |
||||
|
"git add" |
||||
|
] |
||||
|
}, |
||||
|
"name": "realworld-vue", |
||||
|
"scripts": { |
||||
|
"build": "cross-env BABEL_ENV=dev vue-cli-service build", |
||||
|
"lint": "vue-cli-service lint", |
||||
|
"serve": "cross-env BABEL_ENV=dev vue-cli-service serve", |
||||
|
"test": "cross-env BABEL_ENV=test jest --coverage" |
||||
|
}, |
||||
|
"version": "0.1.0" |
||||
|
} |
||||
@ -0,0 +1,5 @@ |
|||||
|
module.exports = { |
||||
|
plugins: { |
||||
|
autoprefixer: {} |
||||
|
} |
||||
|
}; |
||||
|
After Width: 192 | Height: 192 | Size: 9.2 KiB |
|
After Width: 512 | Height: 512 | Size: 29 KiB |
|
After Width: 120 | Height: 120 | Size: 3.3 KiB |
|
After Width: 152 | Height: 152 | Size: 4.0 KiB |
|
After Width: 180 | Height: 180 | Size: 4.6 KiB |
|
After Width: 60 | Height: 60 | Size: 1.5 KiB |
|
After Width: 76 | Height: 76 | Size: 1.8 KiB |
|
After Width: 180 | Height: 180 | Size: 4.6 KiB |
|
After Width: 16 | Height: 16 | Size: 799 B |
|
After Width: 32 | Height: 32 | Size: 1.2 KiB |
|
After Width: 144 | Height: 144 | Size: 1.1 KiB |
|
After Width: 270 | Height: 270 | Size: 4.2 KiB |
@ -0,0 +1,149 @@ |
|||||
|
<?xml version="1.0" standalone="no"?> |
||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" |
||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd"> |
||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg" |
||||
|
width="16.000000pt" height="16.000000pt" viewBox="0 0 16.000000 16.000000" |
||||
|
preserveAspectRatio="xMidYMid meet"> |
||||
|
<metadata> |
||||
|
Created by potrace 1.11, written by Peter Selinger 2001-2013 |
||||
|
</metadata> |
||||
|
<g transform="translate(0.000000,16.000000) scale(0.000320,-0.000320)" |
||||
|
fill="#000000" stroke="none"> |
||||
|
<path d="M18 46618 c45 -75 122 -207 122 -211 0 -2 25 -45 55 -95 30 -50 55 |
||||
|
-96 55 -102 0 -5 5 -10 10 -10 6 0 10 -4 10 -9 0 -5 73 -135 161 -288 89 -153 |
||||
|
173 -298 187 -323 14 -25 32 -57 41 -72 88 -149 187 -324 189 -335 2 -7 8 -13 |
||||
|
13 -13 5 0 9 -4 9 -10 0 -5 46 -89 103 -187 175 -302 490 -846 507 -876 8 -16 |
||||
|
20 -36 25 -45 28 -46 290 -498 339 -585 13 -23 74 -129 136 -236 61 -107 123 |
||||
|
-215 137 -240 14 -25 29 -50 33 -56 5 -5 23 -37 40 -70 18 -33 38 -67 44 -75 |
||||
|
11 -16 21 -33 63 -109 14 -25 29 -50 33 -56 4 -5 21 -35 38 -65 55 -100 261 |
||||
|
-455 269 -465 4 -5 14 -21 20 -35 15 -29 41 -75 103 -180 24 -41 52 -88 60 |
||||
|
-105 9 -16 57 -100 107 -185 112 -193 362 -626 380 -660 8 -14 23 -38 33 -55 |
||||
|
11 -16 23 -37 27 -45 4 -8 26 -46 48 -85 23 -38 53 -90 67 -115 46 -81 64 |
||||
|
-113 178 -310 62 -107 121 -210 132 -227 37 -67 56 -99 85 -148 16 -27 32 -57 |
||||
|
36 -65 4 -8 15 -27 25 -42 9 -15 53 -89 96 -165 44 -76 177 -307 296 -513 120 |
||||
|
-206 268 -463 330 -570 131 -227 117 -203 200 -348 36 -62 73 -125 82 -140 10 |
||||
|
-15 21 -34 25 -42 4 -8 20 -37 36 -65 17 -27 38 -65 48 -82 49 -85 64 -111 87 |
||||
|
-153 13 -25 28 -49 32 -55 4 -5 78 -134 165 -285 87 -151 166 -288 176 -305 |
||||
|
10 -16 26 -43 35 -59 9 -17 125 -217 257 -445 132 -229 253 -441 270 -471 17 |
||||
|
-30 45 -79 64 -108 18 -29 33 -54 33 -57 0 -2 20 -37 44 -77 24 -40 123 -212 |
||||
|
221 -383 97 -170 190 -330 205 -355 16 -25 39 -65 53 -90 13 -25 81 -144 152 |
||||
|
-265 70 -121 137 -238 150 -260 12 -22 37 -65 55 -95 18 -30 43 -73 55 -95 12 |
||||
|
-22 48 -85 80 -140 77 -132 163 -280 190 -330 13 -22 71 -123 130 -225 59 |
||||
|
-102 116 -199 126 -217 10 -17 29 -50 43 -72 15 -22 26 -43 26 -45 0 -2 27 |
||||
|
-50 60 -106 33 -56 60 -103 60 -105 0 -2 55 -98 90 -155 8 -14 182 -316 239 |
||||
|
-414 13 -22 45 -79 72 -124 27 -46 49 -86 49 -89 0 -2 14 -24 30 -48 16 -24 |
||||
|
30 -46 30 -49 0 -5 74 -135 100 -176 5 -8 24 -42 43 -75 50 -88 58 -101 262 |
||||
|
-455 104 -179 199 -345 213 -370 14 -25 28 -49 32 -55 4 -5 17 -26 28 -45 10 |
||||
|
-19 62 -109 114 -200 114 -197 133 -230 170 -295 16 -27 33 -57 38 -65 17 -28 |
||||
|
96 -165 103 -180 4 -8 16 -28 26 -45 10 -16 77 -131 148 -255 72 -124 181 |
||||
|
-313 243 -420 62 -107 121 -209 131 -227 35 -62 323 -560 392 -678 38 -66 83 |
||||
|
-145 100 -175 16 -30 33 -59 37 -65 4 -5 17 -27 29 -47 34 -61 56 -100 90 |
||||
|
-156 17 -29 31 -55 31 -57 0 -2 17 -32 39 -67 21 -35 134 -229 251 -433 117 |
||||
|
-203 235 -407 261 -451 27 -45 49 -85 49 -88 0 -4 8 -19 19 -34 15 -21 200 |
||||
|
-341 309 -533 10 -19 33 -58 51 -87 17 -29 31 -54 31 -56 0 -2 25 -44 55 -94 |
||||
|
30 -50 55 -95 55 -98 0 -4 6 -15 14 -23 7 -9 27 -41 43 -71 17 -30 170 -297 |
||||
|
342 -594 171 -296 311 -542 311 -547 0 -5 5 -9 10 -9 6 0 10 -4 10 -10 0 -5 |
||||
|
22 -47 49 -92 27 -46 58 -99 68 -118 24 -43 81 -140 93 -160 5 -8 66 -114 135 |
||||
|
-235 69 -121 130 -227 135 -235 12 -21 259 -447 283 -490 10 -19 28 -47 38 |
||||
|
-62 11 -14 19 -29 19 -32 0 -3 37 -69 83 -148 99 -170 305 -526 337 -583 13 |
||||
|
-22 31 -53 41 -70 11 -16 22 -37 26 -45 7 -14 82 -146 103 -180 14 -24 181 |
||||
|
-311 205 -355 13 -22 46 -80 75 -130 29 -49 64 -110 78 -135 14 -25 51 -88 82 |
||||
|
-140 31 -52 59 -102 63 -110 4 -8 18 -33 31 -55 205 -353 284 -489 309 -535 |
||||
|
17 -30 45 -78 62 -106 18 -28 36 -60 39 -72 4 -12 12 -22 17 -22 5 0 9 -4 9 |
||||
|
-10 0 -5 109 -197 241 -427 133 -230 250 -431 259 -448 51 -90 222 -385 280 |
||||
|
-485 37 -63 78 -135 92 -160 14 -25 67 -117 118 -205 51 -88 101 -175 111 |
||||
|
-193 34 -58 55 -95 149 -257 51 -88 101 -173 110 -190 9 -16 76 -131 147 -255 |
||||
|
72 -124 140 -241 151 -260 61 -108 281 -489 355 -615 38 -66 77 -133 87 -150 |
||||
|
35 -63 91 -161 100 -175 14 -23 99 -169 128 -220 54 -97 135 -235 142 -245 4 |
||||
|
-5 20 -32 35 -60 26 -48 238 -416 276 -480 10 -16 26 -46 37 -65 30 -53 382 |
||||
|
-661 403 -695 10 -16 22 -37 26 -45 4 -8 26 -48 50 -88 24 -41 43 -75 43 -77 |
||||
|
0 -2 22 -40 50 -85 27 -45 50 -84 50 -86 0 -3 38 -69 83 -147 84 -142 302 |
||||
|
-520 340 -587 10 -19 34 -60 52 -90 18 -30 44 -75 57 -100 14 -25 45 -79 70 |
||||
|
-120 25 -41 56 -96 70 -121 14 -25 77 -133 138 -240 62 -107 122 -210 132 |
||||
|
-229 25 -43 310 -535 337 -581 11 -19 26 -45 34 -59 17 -32 238 -414 266 -460 |
||||
|
11 -19 24 -41 28 -49 3 -7 75 -133 160 -278 84 -146 153 -269 153 -274 0 -5 5 |
||||
|
-9 10 -9 6 0 10 -4 10 -10 0 -5 82 -150 181 -322 182 -314 201 -346 240 -415 |
||||
|
12 -21 80 -139 152 -263 71 -124 141 -245 155 -270 14 -25 28 -49 32 -55 6 -8 |
||||
|
145 -248 220 -380 37 -66 209 -362 229 -395 11 -19 24 -42 28 -49 4 -8 67 |
||||
|
-118 140 -243 73 -125 133 -230 133 -233 0 -2 15 -28 33 -57 19 -29 47 -78 64 |
||||
|
-108 17 -30 53 -93 79 -139 53 -90 82 -141 157 -272 82 -142 115 -199 381 |
||||
|
-659 142 -245 268 -463 281 -485 12 -22 71 -125 132 -230 60 -104 172 -298 |
||||
|
248 -430 76 -132 146 -253 156 -270 11 -16 22 -36 26 -44 3 -8 30 -54 60 -103 |
||||
|
29 -49 53 -91 53 -93 0 -3 18 -34 40 -70 22 -36 40 -67 40 -69 0 -2 37 -66 81 |
||||
|
-142 45 -77 98 -168 119 -204 20 -36 47 -81 58 -100 12 -19 27 -47 33 -62 6 |
||||
|
-16 15 -28 20 -28 5 0 9 -4 9 -9 0 -6 63 -118 140 -251 77 -133 140 -243 140 |
||||
|
-245 0 -2 18 -33 41 -70 22 -37 49 -83 60 -101 10 -19 29 -51 40 -71 25 -45 |
||||
|
109 -189 126 -218 7 -11 17 -29 22 -40 6 -11 22 -38 35 -60 14 -22 37 -62 52 |
||||
|
-90 14 -27 35 -62 45 -77 11 -14 19 -29 19 -32 0 -3 18 -35 40 -71 22 -36 40 |
||||
|
-67 40 -69 0 -2 19 -35 42 -72 23 -38 55 -94 72 -124 26 -47 139 -244 171 |
||||
|
-298 6 -9 21 -36 34 -60 28 -48 37 -51 51 -19 6 12 19 36 29 52 10 17 27 46 |
||||
|
38 65 11 19 104 181 208 360 103 179 199 345 213 370 14 25 42 74 64 109 21 |
||||
|
34 38 65 38 67 0 2 18 33 40 69 22 36 40 67 40 69 0 3 177 310 199 346 16 26 |
||||
|
136 234 140 244 2 5 25 44 52 88 27 44 49 81 49 84 0 2 18 34 40 70 22 36 40 |
||||
|
67 40 69 0 2 20 36 43 77 35 58 169 289 297 513 9 17 50 86 90 155 40 69 86 |
||||
|
150 103 180 16 30 35 62 41 70 6 8 16 24 22 35 35 64 72 129 167 293 59 100 |
||||
|
116 199 127 220 11 20 30 53 41 72 43 72 1070 1850 1121 1940 14 25 65 113 |
||||
|
113 195 48 83 96 166 107 185 10 19 28 50 38 68 11 18 73 124 137 235 64 111 |
||||
|
175 303 246 427 71 124 173 299 225 390 52 91 116 202 143 248 27 45 49 85 49 |
||||
|
89 0 4 6 14 14 22 7 9 28 43 46 76 26 47 251 436 378 655 11 19 29 51 40 70 |
||||
|
11 19 101 176 201 348 99 172 181 317 181 323 0 5 5 9 10 9 6 0 10 5 10 11 0 |
||||
|
6 8 23 18 37 11 15 32 52 49 82 16 30 130 228 253 440 122 212 234 405 248 |
||||
|
430 13 25 39 70 57 100 39 65 69 117 130 225 25 44 50 87 55 95 12 19 78 134 |
||||
|
220 380 61 107 129 224 150 260 161 277 222 382 246 425 15 28 47 83 71 123 |
||||
|
24 41 43 78 43 83 0 5 4 9 8 9 4 0 13 12 19 28 7 15 23 45 36 67 66 110 277 |
||||
|
478 277 483 0 3 6 13 14 21 7 9 27 41 43 71 17 30 45 80 63 110 34 57 375 649 |
||||
|
394 685 6 11 16 27 22 35 6 8 26 42 44 75 18 33 41 74 51 90 10 17 24 41 32 |
||||
|
55 54 97 72 128 88 152 11 14 19 28 19 30 0 3 79 141 175 308 96 167 175 305 |
||||
|
175 308 0 3 6 13 14 21 7 9 26 39 41 66 33 60 276 483 338 587 24 40 46 80 50 |
||||
|
88 4 8 13 24 20 35 14 23 95 163 125 215 11 19 52 91 92 160 40 69 80 139 90 |
||||
|
155 9 17 103 179 207 360 105 182 200 346 211 365 103 181 463 802 489 845 7 |
||||
|
11 15 27 19 35 4 8 29 51 55 95 64 110 828 1433 848 1470 9 17 24 41 33 55 9 |
||||
|
14 29 48 45 77 15 28 52 93 82 145 30 51 62 107 71 123 17 30 231 398 400 690 |
||||
|
51 88 103 179 115 202 12 23 26 48 32 55 6 7 24 38 40 68 17 30 61 107 98 170 |
||||
|
37 63 84 144 103 180 19 36 41 72 48 81 8 8 14 18 14 21 0 4 27 51 59 106 32 |
||||
|
55 72 124 89 154 16 29 71 125 122 213 51 88 104 180 118 205 13 25 28 50 32 |
||||
|
55 4 6 17 26 28 45 11 19 45 80 77 135 31 55 66 116 77 135 11 19 88 152 171 |
||||
|
295 401 694 620 1072 650 1125 11 19 87 152 170 295 83 143 158 273 166 288 9 |
||||
|
16 21 36 26 45 6 9 31 52 55 96 25 43 54 94 66 115 11 20 95 164 186 321 91 |
||||
|
157 173 299 182 315 9 17 26 46 37 65 12 19 66 114 121 210 56 96 108 186 117 |
||||
|
200 8 14 24 40 34 59 24 45 383 664 412 713 5 9 17 29 26 45 15 28 120 210 |
||||
|
241 419 36 61 68 117 72 125 4 8 12 23 19 34 35 57 245 420 262 453 11 20 35 |
||||
|
61 53 90 17 29 32 54 32 56 0 3 28 51 62 108 33 57 70 119 80 138 10 19 23 42 |
||||
|
28 50 5 8 32 53 59 100 27 47 149 258 271 470 122 212 234 405 248 430 30 53 |
||||
|
62 108 80 135 6 11 15 27 19 35 4 8 85 150 181 315 96 165 187 323 202 350 31 |
||||
|
56 116 202 130 225 5 8 25 42 43 75 19 33 92 159 162 280 149 257 157 271 202 |
||||
|
350 19 33 38 67 43 75 9 14 228 392 275 475 12 22 55 96 95 165 40 69 80 139 |
||||
|
90 155 24 42 202 350 221 383 9 15 27 47 41 72 14 25 75 131 136 236 61 106 |
||||
|
121 210 134 232 99 172 271 470 279 482 5 8 23 40 40 70 18 30 81 141 142 245 |
||||
|
60 105 121 210 135 235 14 25 71 124 127 220 56 96 143 247 194 335 51 88 96 |
||||
|
167 102 175 14 24 180 311 204 355 23 43 340 590 356 615 5 8 50 87 101 175 |
||||
|
171 301 517 898 582 1008 25 43 46 81 46 83 0 2 12 23 27 47 14 23 40 67 56 |
||||
|
97 16 30 35 62 42 70 7 8 15 22 18 30 4 8 20 38 37 65 16 28 33 57 37 65 6 12 |
||||
|
111 196 143 250 5 8 55 95 112 193 57 98 113 195 126 215 12 20 27 46 32 57 6 |
||||
|
11 14 27 20 35 5 8 76 130 156 270 80 140 165 287 187 325 23 39 52 90 66 115 |
||||
|
13 25 30 52 37 61 8 8 14 18 14 21 0 4 41 77 92 165 50 87 175 302 276 478 |
||||
|
101 176 208 360 236 408 28 49 67 117 86 152 19 35 41 70 48 77 6 6 12 15 12 |
||||
|
19 0 7 124 224 167 291 12 21 23 40 23 42 0 2 21 40 46 83 26 43 55 92 64 109 |
||||
|
54 95 327 568 354 614 19 30 45 75 59 100 71 128 82 145 89 148 4 2 8 8 8 13 |
||||
|
0 5 42 82 94 172 311 538 496 858 518 897 14 25 40 70 58 100 18 30 42 71 53 |
||||
|
90 10 19 79 139 152 265 73 127 142 246 153 265 10 19 43 76 72 125 29 50 63 |
||||
|
108 75 130 65 116 80 140 87 143 4 2 8 8 8 12 0 8 114 212 140 250 6 8 14 24 |
||||
|
20 35 5 11 54 97 108 190 l100 170 -9611 3 c-5286 1 -9614 -1 -9618 -5 -5 -6 |
||||
|
-419 -719 -619 -1068 -89 -155 -267 -463 -323 -560 -38 -66 -81 -140 -95 -165 |
||||
|
-31 -56 -263 -457 -526 -910 -110 -190 -224 -388 -254 -440 -29 -52 -61 -109 |
||||
|
-71 -125 -23 -39 -243 -420 -268 -465 -11 -19 -204 -352 -428 -740 -224 -388 |
||||
|
-477 -826 -563 -975 -85 -148 -185 -322 -222 -385 -37 -63 -120 -207 -185 |
||||
|
-320 -65 -113 -177 -306 -248 -430 -72 -124 -172 -297 -222 -385 -51 -88 -142 |
||||
|
-245 -202 -350 -131 -226 -247 -427 -408 -705 -65 -113 -249 -432 -410 -710 |
||||
|
-160 -278 -388 -673 -506 -877 -118 -205 -216 -373 -219 -373 -3 0 -52 82 |
||||
|
-109 183 -58 100 -144 250 -192 332 -95 164 -402 696 -647 1120 -85 149 -228 |
||||
|
396 -317 550 -212 365 -982 1700 -1008 1745 -10 19 -43 76 -72 125 -29 50 -64 |
||||
|
110 -77 135 -14 25 -63 110 -110 190 -47 80 -96 165 -110 190 -14 25 -99 171 |
||||
|
-188 325 -89 154 -174 300 -188 325 -13 25 -64 113 -112 195 -48 83 -140 242 |
||||
|
-205 355 -65 113 -183 317 -263 454 -79 137 -152 264 -163 282 -50 89 -335 |
||||
|
583 -354 614 -12 19 -34 58 -50 85 -15 28 -129 226 -253 440 -124 215 -235 |
||||
|
408 -247 430 -12 22 -69 121 -127 220 -58 99 -226 389 -373 645 -148 256 -324 |
||||
|
561 -392 678 -67 117 -134 232 -147 255 -13 23 -33 59 -46 80 l-22 37 -9615 0 |
||||
|
-9615 0 20 -32z"/> |
||||
|
</g> |
||||
|
</svg> |
||||
@ -0,0 +1,28 @@ |
|||||
|
<!DOCTYPE html> |
||||
|
<html lang="en"> |
||||
|
|
||||
|
<head> |
||||
|
<meta charset="utf-8"> |
||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge"> |
||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0"> |
||||
|
<link rel="icon" href="<%= BASE_URL %>favicon.ico"> |
||||
|
<title>Conduit</title> |
||||
|
<!-- Import Ionicon icons & Google Fonts our Bootstrap theme relies on --> |
||||
|
<link href="//code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css" rel="stylesheet" type="text/css"> |
||||
|
<link href="//fonts.googleapis.com/css?family=Titillium+Web:700|Source+Serif+Pro:400,700|Merriweather+Sans:400,700|Source+Sans+Pro:400,300,600,700,300italic,400italic,600italic,700italic" |
||||
|
rel="stylesheet" type="text/css"> |
||||
|
<!-- Import the custom Bootstrap 4 theme from our hosted CDN --> |
||||
|
<link rel="stylesheet" href="https://demo.productionready.io/main.css"> |
||||
|
</head> |
||||
|
|
||||
|
<body> |
||||
|
<noscript> |
||||
|
<strong>We're sorry but vue-realworld-example-app doesn't work properly without JavaScript enabled. Please enable |
||||
|
it to continue.</strong> |
||||
|
</noscript> |
||||
|
<div id="app"></div> |
||||
|
<!-- built files will be auto injected --> |
||||
|
<script src="https://cdn.polyfill.io/v2/polyfill.min.js?features=default,Symbol "></script> |
||||
|
</body> |
||||
|
|
||||
|
</html> |
||||
@ -0,0 +1,20 @@ |
|||||
|
{ |
||||
|
"name": "vue-realworld-example-app", |
||||
|
"short_name": "vue-realworld-example-app", |
||||
|
"icons": [ |
||||
|
{ |
||||
|
"src": "/img/icons/android-chrome-192x192.png", |
||||
|
"sizes": "192x192", |
||||
|
"type": "image/png" |
||||
|
}, |
||||
|
{ |
||||
|
"src": "/img/icons/android-chrome-512x512.png", |
||||
|
"sizes": "512x512", |
||||
|
"type": "image/png" |
||||
|
} |
||||
|
], |
||||
|
"start_url": "/index.html", |
||||
|
"display": "standalone", |
||||
|
"background_color": "#000000", |
||||
|
"theme_color": "#4DBA87" |
||||
|
} |
||||
@ -0,0 +1,2 @@ |
|||||
|
User-agent: * |
||||
|
Disallow: |
||||
@ -0,0 +1,22 @@ |
|||||
|
<template> |
||||
|
<div id="app"> |
||||
|
<RwvHeader /> |
||||
|
<router-view></router-view> |
||||
|
<RwvFooter /> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
import RwvHeader from "@/components/TheHeader"; |
||||
|
import RwvFooter from "@/components/TheFooter"; |
||||
|
|
||||
|
export default { |
||||
|
name: "App", |
||||
|
components: { |
||||
|
RwvHeader, |
||||
|
RwvFooter |
||||
|
} |
||||
|
}; |
||||
|
</script> |
||||
|
|
||||
|
<style></style> |
||||
@ -0,0 +1,108 @@ |
|||||
|
import Vue from "vue"; |
||||
|
import axios from "axios"; |
||||
|
import VueAxios from "vue-axios"; |
||||
|
import JwtService from "@/common/jwt.service"; |
||||
|
import { API_URL } from "@/common/config"; |
||||
|
|
||||
|
const ApiService = { |
||||
|
init() { |
||||
|
Vue.use(VueAxios, axios); |
||||
|
Vue.axios.defaults.baseURL = API_URL; |
||||
|
}, |
||||
|
|
||||
|
setHeader() { |
||||
|
Vue.axios.defaults.headers.common[ |
||||
|
"Authorization" |
||||
|
] = `Token ${JwtService.getToken()}`; |
||||
|
}, |
||||
|
|
||||
|
query(resource, params) { |
||||
|
return Vue.axios.get(resource, params).catch(error => { |
||||
|
throw new Error(`[RWV] ApiService ${error}`); |
||||
|
}); |
||||
|
}, |
||||
|
|
||||
|
get(resource, slug = "") { |
||||
|
return Vue.axios.get(`${resource}/${slug}`).catch(error => { |
||||
|
throw new Error(`[RWV] ApiService ${error}`); |
||||
|
}); |
||||
|
}, |
||||
|
|
||||
|
post(resource, params) { |
||||
|
return Vue.axios.post(`${resource}`, params); |
||||
|
}, |
||||
|
|
||||
|
update(resource, slug, params) { |
||||
|
return Vue.axios.put(`${resource}/${slug}`, params); |
||||
|
}, |
||||
|
|
||||
|
put(resource, params) { |
||||
|
return Vue.axios.put(`${resource}`, params); |
||||
|
}, |
||||
|
|
||||
|
delete(resource) { |
||||
|
return Vue.axios.delete(resource).catch(error => { |
||||
|
throw new Error(`[RWV] ApiService ${error}`); |
||||
|
}); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
export default ApiService; |
||||
|
|
||||
|
export const TagsService = { |
||||
|
get() { |
||||
|
return ApiService.get("tags"); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
export const ArticlesService = { |
||||
|
query(type, params) { |
||||
|
return ApiService.query("articles" + (type === "feed" ? "/feed" : ""), { |
||||
|
params: params |
||||
|
}); |
||||
|
}, |
||||
|
get(slug) { |
||||
|
return ApiService.get("articles", slug); |
||||
|
}, |
||||
|
create(params) { |
||||
|
delete params.author |
||||
|
return ApiService.post("articles", { article: params }); |
||||
|
}, |
||||
|
update(slug, params) { |
||||
|
delete params.author |
||||
|
return ApiService.update("articles", slug, { article: params }); |
||||
|
}, |
||||
|
destroy(slug) { |
||||
|
return ApiService.delete(`articles/${slug}`); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
export const CommentsService = { |
||||
|
get(slug) { |
||||
|
if (typeof slug !== "string") { |
||||
|
throw new Error( |
||||
|
"[RWV] CommentsService.get() article slug required to fetch comments" |
||||
|
); |
||||
|
} |
||||
|
return ApiService.get("articles", `${slug}/comments`); |
||||
|
}, |
||||
|
|
||||
|
post(slug, payload) { |
||||
|
return ApiService.post(`articles/${slug}/comments`, { |
||||
|
comment: { body: payload } |
||||
|
}); |
||||
|
}, |
||||
|
|
||||
|
destroy(slug, commentId) { |
||||
|
return ApiService.delete(`articles/${slug}/comments/${commentId}`); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
export const FavoriteService = { |
||||
|
add(slug) { |
||||
|
return ApiService.post(`articles/${slug}/favorite`); |
||||
|
}, |
||||
|
remove(slug) { |
||||
|
return ApiService.delete(`articles/${slug}/favorite`); |
||||
|
} |
||||
|
}; |
||||
@ -0,0 +1,5 @@ |
|||||
|
export const API_URL = |
||||
|
process.env.NODE_ENV === "production" |
||||
|
? "https://tobedefined.io/api" |
||||
|
: "http://localhost:2020/api"; |
||||
|
export default API_URL; |
||||
@ -0,0 +1,5 @@ |
|||||
|
import { default as format } from "date-fns/format"; |
||||
|
|
||||
|
export default date => { |
||||
|
return format(new Date(date), "MMMM D, YYYY"); |
||||
|
}; |
||||
@ -0,0 +1,3 @@ |
|||||
|
export default errorValue => { |
||||
|
return `${errorValue[0]}`; |
||||
|
}; |
||||
@ -0,0 +1,15 @@ |
|||||
|
const ID_TOKEN_KEY = "id_token"; |
||||
|
|
||||
|
export const getToken = () => { |
||||
|
return window.localStorage.getItem(ID_TOKEN_KEY); |
||||
|
}; |
||||
|
|
||||
|
export const saveToken = token => { |
||||
|
window.localStorage.setItem(ID_TOKEN_KEY, token); |
||||
|
}; |
||||
|
|
||||
|
export const destroyToken = () => { |
||||
|
window.localStorage.removeItem(ID_TOKEN_KEY); |
||||
|
}; |
||||
|
|
||||
|
export default { getToken, saveToken, destroyToken }; |
||||
@ -0,0 +1,101 @@ |
|||||
|
<template> |
||||
|
<!-- Used when user is also author --> |
||||
|
<span v-if="canModify"> |
||||
|
<router-link class="btn btn-sm btn-outline-secondary" :to="editArticleLink"> |
||||
|
<i class="ion-edit"></i> <span> Edit Article</span> |
||||
|
</router-link> |
||||
|
<span> </span> |
||||
|
<button class="btn btn-outline-danger btn-sm" @click="deleteArticle"> |
||||
|
<i class="ion-trash-a"></i> <span> Delete Article</span> |
||||
|
</button> |
||||
|
</span> |
||||
|
<!-- Used in ArticleView when not author --> |
||||
|
<span v-else> |
||||
|
<button class="btn btn-sm btn-outline-secondary" @click="toggleFollow"> |
||||
|
<i class="ion-plus-round"></i> <span> </span> |
||||
|
<span v-text="followUserLabel" /> |
||||
|
</button> |
||||
|
<span> </span> |
||||
|
<button |
||||
|
class="btn btn-sm" |
||||
|
@click="toggleFavorite" |
||||
|
:class="toggleFavoriteButtonClasses" |
||||
|
> |
||||
|
<i class="ion-heart"></i> <span> </span> |
||||
|
<span v-text="favoriteArticleLabel" /> <span> </span> |
||||
|
<span class="counter" v-text="favoriteCounter" /> |
||||
|
</button> |
||||
|
</span> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
import { mapGetters } from "vuex"; |
||||
|
import { |
||||
|
FAVORITE_ADD, |
||||
|
FAVORITE_REMOVE, |
||||
|
ARTICLE_DELETE, |
||||
|
FETCH_PROFILE_FOLLOW, |
||||
|
FETCH_PROFILE_UNFOLLOW |
||||
|
} from "@/store/actions.type"; |
||||
|
|
||||
|
export default { |
||||
|
name: "RwvArticleActions", |
||||
|
props: { |
||||
|
article: { type: Object, required: true }, |
||||
|
canModify: { type: Boolean, required: true } |
||||
|
}, |
||||
|
computed: { |
||||
|
...mapGetters(["profile", "isAuthenticated"]), |
||||
|
editArticleLink() { |
||||
|
return { name: "article-edit", params: { slug: this.article.slug } }; |
||||
|
}, |
||||
|
toggleFavoriteButtonClasses() { |
||||
|
return { |
||||
|
"btn-primary": this.article.favorited, |
||||
|
"btn-outline-primary": !this.article.favorited |
||||
|
}; |
||||
|
}, |
||||
|
followUserLabel() { |
||||
|
return `${this.profile.following ? "Unfollow" : "Follow"} ${ |
||||
|
this.article.author.username |
||||
|
}`; |
||||
|
}, |
||||
|
favoriteArticleLabel() { |
||||
|
return this.article.favorited ? "Unfavorite Article" : "Favorite Article"; |
||||
|
}, |
||||
|
favoriteCounter() { |
||||
|
return `(${this.article.favoritesCount})`; |
||||
|
} |
||||
|
}, |
||||
|
methods: { |
||||
|
toggleFavorite() { |
||||
|
if (!this.isAuthenticated) { |
||||
|
this.$router.push({ name: "login" }); |
||||
|
return; |
||||
|
} |
||||
|
const action = this.article.favorited ? FAVORITE_REMOVE : FAVORITE_ADD; |
||||
|
this.$store.dispatch(action, this.article.slug); |
||||
|
}, |
||||
|
toggleFollow() { |
||||
|
if (!this.isAuthenticated) { |
||||
|
this.$router.push({ name: "login" }); |
||||
|
return; |
||||
|
} |
||||
|
const action = this.article.following |
||||
|
? FETCH_PROFILE_UNFOLLOW |
||||
|
: FETCH_PROFILE_FOLLOW; |
||||
|
this.$store.dispatch(action, { |
||||
|
username: this.profile.username |
||||
|
}); |
||||
|
}, |
||||
|
async deleteArticle() { |
||||
|
try { |
||||
|
await this.$store.dispatch(ARTICLE_DELETE, this.article.slug); |
||||
|
this.$router.push("/"); |
||||
|
} catch (err) { |
||||
|
console.error(err); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
</script> |
||||
@ -0,0 +1,125 @@ |
|||||
|
<template> |
||||
|
<div> |
||||
|
<div v-if="isLoading" class="article-preview">Loading articles...</div> |
||||
|
<div v-else> |
||||
|
<div v-if="articles.length === 0" class="article-preview"> |
||||
|
No articles are here... yet. |
||||
|
</div> |
||||
|
<RwvArticlePreview |
||||
|
v-for="(article, index) in articles" |
||||
|
:article="article" |
||||
|
:key="article.title + index" |
||||
|
/> |
||||
|
<VPagination :pages="pages" :currentPage.sync="currentPage" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
import { mapGetters } from "vuex"; |
||||
|
import RwvArticlePreview from "./VArticlePreview"; |
||||
|
import VPagination from "./VPagination"; |
||||
|
import { FETCH_ARTICLES } from "../store/actions.type"; |
||||
|
|
||||
|
export default { |
||||
|
name: "RwvArticleList", |
||||
|
components: { |
||||
|
RwvArticlePreview, |
||||
|
VPagination |
||||
|
}, |
||||
|
props: { |
||||
|
type: { |
||||
|
type: String, |
||||
|
required: false, |
||||
|
default: "all" |
||||
|
}, |
||||
|
author: { |
||||
|
type: String, |
||||
|
required: false |
||||
|
}, |
||||
|
tag: { |
||||
|
type: String, |
||||
|
required: false |
||||
|
}, |
||||
|
favorited: { |
||||
|
type: String, |
||||
|
required: false |
||||
|
}, |
||||
|
itemsPerPage: { |
||||
|
type: Number, |
||||
|
required: false, |
||||
|
default: 10 |
||||
|
} |
||||
|
}, |
||||
|
data() { |
||||
|
return { |
||||
|
currentPage: 1 |
||||
|
}; |
||||
|
}, |
||||
|
computed: { |
||||
|
listConfig() { |
||||
|
const { type } = this; |
||||
|
const filters = { |
||||
|
offset: (this.currentPage - 1) * this.itemsPerPage, |
||||
|
limit: this.itemsPerPage |
||||
|
}; |
||||
|
if (this.author) { |
||||
|
filters.author = this.author; |
||||
|
} |
||||
|
if (this.tag) { |
||||
|
filters.tag = this.tag; |
||||
|
} |
||||
|
if (this.favorited) { |
||||
|
filters.favorited = this.favorited; |
||||
|
} |
||||
|
return { |
||||
|
type, |
||||
|
filters |
||||
|
}; |
||||
|
}, |
||||
|
pages() { |
||||
|
if (this.isLoading || this.articlesCount <= this.itemsPerPage) { |
||||
|
return []; |
||||
|
} |
||||
|
return [ |
||||
|
...Array(Math.ceil(this.articlesCount / this.itemsPerPage)).keys() |
||||
|
].map(e => e + 1); |
||||
|
}, |
||||
|
...mapGetters(["articlesCount", "isLoading", "articles"]) |
||||
|
}, |
||||
|
watch: { |
||||
|
currentPage(newValue) { |
||||
|
this.listConfig.filters.offset = (newValue - 1) * this.itemsPerPage; |
||||
|
this.fetchArticles(); |
||||
|
}, |
||||
|
type() { |
||||
|
this.resetPagination(); |
||||
|
this.fetchArticles(); |
||||
|
}, |
||||
|
author() { |
||||
|
this.resetPagination(); |
||||
|
this.fetchArticles(); |
||||
|
}, |
||||
|
tag() { |
||||
|
this.resetPagination(); |
||||
|
this.fetchArticles(); |
||||
|
}, |
||||
|
favorited() { |
||||
|
this.resetPagination(); |
||||
|
this.fetchArticles(); |
||||
|
} |
||||
|
}, |
||||
|
mounted() { |
||||
|
this.fetchArticles(); |
||||
|
}, |
||||
|
methods: { |
||||
|
fetchArticles() { |
||||
|
this.$store.dispatch(FETCH_ARTICLES, this.listConfig); |
||||
|
}, |
||||
|
resetPagination() { |
||||
|
this.listConfig.offset = 0; |
||||
|
this.currentPage = 1; |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
</script> |
||||
@ -0,0 +1,78 @@ |
|||||
|
<template> |
||||
|
<div class="article-meta"> |
||||
|
<router-link |
||||
|
:to="{ name: 'profile', params: { username: article.author.username } }" |
||||
|
> |
||||
|
<img :src="article.author.image" /> |
||||
|
</router-link> |
||||
|
<div class="info"> |
||||
|
<router-link |
||||
|
:to="{ name: 'profile', params: { username: article.author.username } }" |
||||
|
class="author" |
||||
|
> |
||||
|
{{ article.author.username }} |
||||
|
</router-link> |
||||
|
<span class="date">{{ article.createdAt | date }}</span> |
||||
|
</div> |
||||
|
<rwv-article-actions |
||||
|
v-if="actions" |
||||
|
:article="article" |
||||
|
:canModify="isCurrentUser()" |
||||
|
></rwv-article-actions> |
||||
|
<button |
||||
|
v-else |
||||
|
class="btn btn-sm pull-xs-right" |
||||
|
@click="toggleFavorite" |
||||
|
:class="{ |
||||
|
'btn-primary': article.favorited, |
||||
|
'btn-outline-primary': !article.favorited |
||||
|
}" |
||||
|
> |
||||
|
<i class="ion-heart"></i> |
||||
|
<span class="counter"> {{ article.favoritesCount }} </span> |
||||
|
</button> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
import { mapGetters } from "vuex"; |
||||
|
import RwvArticleActions from "@/components/ArticleActions"; |
||||
|
import { FAVORITE_ADD, FAVORITE_REMOVE } from "@/store/actions.type"; |
||||
|
|
||||
|
export default { |
||||
|
name: "RwvArticleMeta", |
||||
|
components: { |
||||
|
RwvArticleActions |
||||
|
}, |
||||
|
props: { |
||||
|
article: { |
||||
|
type: Object, |
||||
|
required: true |
||||
|
}, |
||||
|
actions: { |
||||
|
type: Boolean, |
||||
|
required: false, |
||||
|
default: false |
||||
|
} |
||||
|
}, |
||||
|
computed: { |
||||
|
...mapGetters(["currentUser", "isAuthenticated"]) |
||||
|
}, |
||||
|
methods: { |
||||
|
isCurrentUser() { |
||||
|
if (this.currentUser.username && this.article.author.username) { |
||||
|
return this.currentUser.username === this.article.author.username; |
||||
|
} |
||||
|
return false; |
||||
|
}, |
||||
|
toggleFavorite() { |
||||
|
if (!this.isAuthenticated) { |
||||
|
this.$router.push({ name: "login" }); |
||||
|
return; |
||||
|
} |
||||
|
const action = this.article.favorited ? FAVORITE_REMOVE : FAVORITE_ADD; |
||||
|
this.$store.dispatch(action, this.article.slug); |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
</script> |
||||
@ -0,0 +1,49 @@ |
|||||
|
<template> |
||||
|
<div class="card"> |
||||
|
<div class="card-block"> |
||||
|
<p class="card-text">{{ comment.body }}</p> |
||||
|
</div> |
||||
|
<div class="card-footer"> |
||||
|
<a href="" class="comment-author"> |
||||
|
<img :src="comment.author.image" class="comment-author-img" /> |
||||
|
</a> |
||||
|
<router-link |
||||
|
class="comment-author" |
||||
|
:to="{ name: 'profile', params: { username: comment.author.username } }" |
||||
|
> |
||||
|
{{ comment.author.username }} |
||||
|
</router-link> |
||||
|
<span class="date-posted">{{ comment.createdAt | date }}</span> |
||||
|
<span v-if="isCurrentUser" class="mod-options"> |
||||
|
<i class="ion-trash-a" @click="destroy(slug, comment.id)"></i> |
||||
|
</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
import { mapGetters } from "vuex"; |
||||
|
import { COMMENT_DESTROY } from "@/store/actions.type"; |
||||
|
|
||||
|
export default { |
||||
|
name: "RwvComment", |
||||
|
props: { |
||||
|
slug: { type: String, required: true }, |
||||
|
comment: { type: Object, required: true } |
||||
|
}, |
||||
|
computed: { |
||||
|
isCurrentUser() { |
||||
|
if (this.currentUser.username && this.comment.author.username) { |
||||
|
return this.comment.author.username === this.currentUser.username; |
||||
|
} |
||||
|
return false; |
||||
|
}, |
||||
|
...mapGetters(["currentUser"]) |
||||
|
}, |
||||
|
methods: { |
||||
|
destroy(slug, commentId) { |
||||
|
this.$store.dispatch(COMMENT_DESTROY, { slug, commentId }); |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
</script> |
||||
@ -0,0 +1,54 @@ |
|||||
|
<template> |
||||
|
<div> |
||||
|
<RwvListErrors :errors="errors" /> |
||||
|
<form class="card comment-form" @submit.prevent="onSubmit(slug, comment)"> |
||||
|
<div class="card-block"> |
||||
|
<textarea |
||||
|
class="form-control" |
||||
|
v-model="comment" |
||||
|
placeholder="Write a comment..." |
||||
|
rows="3" |
||||
|
> |
||||
|
</textarea> |
||||
|
</div> |
||||
|
<div class="card-footer"> |
||||
|
<img :src="userImage" class="comment-author-img" /> |
||||
|
<button class="btn btn-sm btn-primary">Post Comment</button> |
||||
|
</div> |
||||
|
</form> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
import RwvListErrors from "./ListErrors.vue"; |
||||
|
import { COMMENT_CREATE } from "../store/actions.type.js"; |
||||
|
|
||||
|
export default { |
||||
|
name: "RwvCommentEditor", |
||||
|
components: { RwvListErrors }, |
||||
|
props: { |
||||
|
slug: { type: String, required: true }, |
||||
|
content: { type: String, required: false }, |
||||
|
userImage: { type: String, required: false } |
||||
|
}, |
||||
|
data() { |
||||
|
return { |
||||
|
comment: this.content || null, |
||||
|
errors: {} |
||||
|
}; |
||||
|
}, |
||||
|
methods: { |
||||
|
onSubmit(slug, comment) { |
||||
|
this.$store |
||||
|
.dispatch(COMMENT_CREATE, { slug, comment }) |
||||
|
.then(() => { |
||||
|
this.comment = null; |
||||
|
this.errors = {}; |
||||
|
}) |
||||
|
.catch(({ response }) => { |
||||
|
this.errors = response.data.errors; |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
</script> |
||||
@ -0,0 +1,17 @@ |
|||||
|
<template v-show="errors"> |
||||
|
<ul class="error-messages"> |
||||
|
<li v-for="(value, key) in errors" :key="key"> |
||||
|
<span v-text="key" /> |
||||
|
<span v-for="err in value" :key="err" v-text="err" /> |
||||
|
</li> |
||||
|
</ul> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
export default { |
||||
|
name: "RwvListErorrs", |
||||
|
props: { |
||||
|
errors: { type: Object, required: true } |
||||
|
} |
||||
|
}; |
||||
|
</script> |
||||
@ -0,0 +1,20 @@ |
|||||
|
<template> |
||||
|
<ul class="tag-list"> |
||||
|
<li |
||||
|
class="tag-default tag-pill tag-outline" |
||||
|
v-for="(tag, index) of tags" |
||||
|
:key="index" |
||||
|
> |
||||
|
<span v-text="tag" /> |
||||
|
</li> |
||||
|
</ul> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
export default { |
||||
|
name: "TagList", |
||||
|
props: { |
||||
|
tags: Array |
||||
|
} |
||||
|
}; |
||||
|
</script> |
||||
@ -0,0 +1,21 @@ |
|||||
|
<template> |
||||
|
<footer> |
||||
|
<div class="container"> |
||||
|
<router-link class="logo-font" :to="{ name: 'home', params: {} }"> |
||||
|
conduit |
||||
|
</router-link> |
||||
|
<span class="attribution"> |
||||
|
An interactive learning project from |
||||
|
<a rel="noopener noreferrer" target="blank" href="https://thinkster.io" |
||||
|
>Thinkster</a |
||||
|
>. Code & design licensed under MIT. |
||||
|
</span> |
||||
|
</div> |
||||
|
</footer> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
export default { |
||||
|
name: "RwvFooter" |
||||
|
}; |
||||
|
</script> |
||||
@ -0,0 +1,96 @@ |
|||||
|
<template> |
||||
|
<nav class="navbar navbar-light"> |
||||
|
<div class="container"> |
||||
|
<router-link class="navbar-brand" :to="{ name: 'home' }"> |
||||
|
conduit |
||||
|
</router-link> |
||||
|
<ul v-if="!isAuthenticated" class="nav navbar-nav pull-xs-right"> |
||||
|
<li class="nav-item"> |
||||
|
<router-link |
||||
|
class="nav-link" |
||||
|
active-class="active" |
||||
|
exact |
||||
|
:to="{ name: 'home' }" |
||||
|
> |
||||
|
Home |
||||
|
</router-link> |
||||
|
</li> |
||||
|
<li class="nav-item"> |
||||
|
<router-link |
||||
|
class="nav-link" |
||||
|
active-class="active" |
||||
|
exact |
||||
|
:to="{ name: 'login' }" |
||||
|
> |
||||
|
<i class="ion-compose"></i>Sign in |
||||
|
</router-link> |
||||
|
</li> |
||||
|
<li class="nav-item"> |
||||
|
<router-link |
||||
|
class="nav-link" |
||||
|
active-class="active" |
||||
|
exact |
||||
|
:to="{ name: 'register' }" |
||||
|
> |
||||
|
<i class="ion-compose"></i>Sign up |
||||
|
</router-link> |
||||
|
</li> |
||||
|
</ul> |
||||
|
<ul v-else class="nav navbar-nav pull-xs-right"> |
||||
|
<li class="nav-item"> |
||||
|
<router-link |
||||
|
class="nav-link" |
||||
|
active-class="active" |
||||
|
exact |
||||
|
:to="{ name: 'home' }" |
||||
|
> |
||||
|
Home |
||||
|
</router-link> |
||||
|
</li> |
||||
|
<li class="nav-item"> |
||||
|
<router-link |
||||
|
class="nav-link" |
||||
|
active-class="active" |
||||
|
:to="{ name: 'article-edit' }" |
||||
|
> |
||||
|
<i class="ion-compose"></i> New Article |
||||
|
</router-link> |
||||
|
</li> |
||||
|
<li class="nav-item"> |
||||
|
<router-link |
||||
|
class="nav-link" |
||||
|
active-class="active" |
||||
|
exact |
||||
|
:to="{ name: 'settings' }" |
||||
|
> |
||||
|
<i class="ion-gear-a"></i> Settings |
||||
|
</router-link> |
||||
|
</li> |
||||
|
<li class="nav-item" v-if="currentUser.username"> |
||||
|
<router-link |
||||
|
class="nav-link" |
||||
|
active-class="active" |
||||
|
exact |
||||
|
:to="{ |
||||
|
name: 'profile', |
||||
|
params: { username: currentUser.username } |
||||
|
}" |
||||
|
> |
||||
|
{{ currentUser.username }} |
||||
|
</router-link> |
||||
|
</li> |
||||
|
</ul> |
||||
|
</div> |
||||
|
</nav> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
import { mapGetters } from "vuex"; |
||||
|
|
||||
|
export default { |
||||
|
name: "RwvHeader", |
||||
|
computed: { |
||||
|
...mapGetters(["currentUser", "isAuthenticated"]) |
||||
|
} |
||||
|
}; |
||||
|
</script> |
||||
@ -0,0 +1,37 @@ |
|||||
|
<template> |
||||
|
<div class="article-preview"> |
||||
|
<RwvArticleMeta isPreview :article="article" /> |
||||
|
<router-link :to="articleLink" class="preview-link"> |
||||
|
<h1 v-text="article.title" /> |
||||
|
<p v-text="article.description" /> |
||||
|
<span>Read more...</span> |
||||
|
<TagList :tags="article.tagList" /> |
||||
|
</router-link> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
import RwvArticleMeta from "./ArticleMeta"; |
||||
|
import TagList from "./TagList"; |
||||
|
|
||||
|
export default { |
||||
|
name: "RwvArticlePreview", |
||||
|
components: { |
||||
|
RwvArticleMeta, |
||||
|
TagList |
||||
|
}, |
||||
|
props: { |
||||
|
article: { type: Object, required: true } |
||||
|
}, |
||||
|
computed: { |
||||
|
articleLink() { |
||||
|
return { |
||||
|
name: "article", |
||||
|
params: { |
||||
|
slug: this.article.slug |
||||
|
} |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
</script> |
||||
@ -0,0 +1,43 @@ |
|||||
|
<template> |
||||
|
<nav> |
||||
|
<ul class="pagination"> |
||||
|
<li |
||||
|
v-for="page in pages" |
||||
|
:data-test="`page-link-${page}`" |
||||
|
:key="page" |
||||
|
:class="paginationClass(page)" |
||||
|
@click.prevent="changePage(page)" |
||||
|
> |
||||
|
<a class="page-link" href v-text="page" /> |
||||
|
</li> |
||||
|
</ul> |
||||
|
</nav> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
export default { |
||||
|
name: "Pagination", |
||||
|
props: { |
||||
|
pages: { |
||||
|
type: Array, |
||||
|
required: true |
||||
|
}, |
||||
|
currentPage: { |
||||
|
type: Number, |
||||
|
required: true |
||||
|
} |
||||
|
}, |
||||
|
methods: { |
||||
|
changePage(goToPage) { |
||||
|
if (goToPage === this.currentPage) return; |
||||
|
this.$emit("update:currentPage", goToPage); |
||||
|
}, |
||||
|
paginationClass(page) { |
||||
|
return { |
||||
|
"page-item": true, |
||||
|
active: this.currentPage === page |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
</script> |
||||
@ -0,0 +1,22 @@ |
|||||
|
<template> |
||||
|
<router-link :to="homeRoute" :class="className" v-text="name"></router-link> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
export default { |
||||
|
name: "RwvTag", |
||||
|
props: { |
||||
|
name: { |
||||
|
type: String, |
||||
|
required: true |
||||
|
}, |
||||
|
className: { |
||||
|
type: String, |
||||
|
default: "tag-pill tag-default" |
||||
|
} |
||||
|
}, |
||||
|
computed: { |
||||
|
homeRoute: () => ({ name: "home-tag", params: { tag: name } }) |
||||
|
} |
||||
|
}; |
||||
|
</script> |
||||
@ -0,0 +1,27 @@ |
|||||
|
import Vue from "vue"; |
||||
|
import App from "./App.vue"; |
||||
|
import router from "./router"; |
||||
|
import store from "./store"; |
||||
|
import "./registerServiceWorker"; |
||||
|
|
||||
|
import { CHECK_AUTH } from "./store/actions.type"; |
||||
|
import ApiService from "./common/api.service"; |
||||
|
import DateFilter from "./common/date.filter"; |
||||
|
import ErrorFilter from "./common/error.filter"; |
||||
|
|
||||
|
Vue.config.productionTip = false; |
||||
|
Vue.filter("date", DateFilter); |
||||
|
Vue.filter("error", ErrorFilter); |
||||
|
|
||||
|
ApiService.init(); |
||||
|
|
||||
|
// Ensure we checked auth before each page load.
|
||||
|
router.beforeEach((to, from, next) => |
||||
|
Promise.all([store.dispatch(CHECK_AUTH)]).then(next) |
||||
|
); |
||||
|
|
||||
|
new Vue({ |
||||
|
router, |
||||
|
store, |
||||
|
render: h => h(App) |
||||
|
}).$mount("#app"); |
||||
@ -0,0 +1,28 @@ |
|||||
|
/* eslint-disable no-console */ |
||||
|
|
||||
|
import { register } from "register-service-worker"; |
||||
|
|
||||
|
if (process.env.NODE_ENV === "production") { |
||||
|
register(`${process.env.BASE_URL}service-worker.js`, { |
||||
|
ready() { |
||||
|
console.log( |
||||
|
"App is being served from cache by a service worker.\n" + |
||||
|
"For more details, visit https://goo.gl/AFskqB" |
||||
|
); |
||||
|
}, |
||||
|
cached() { |
||||
|
console.log("Content has been cached for offline use."); |
||||
|
}, |
||||
|
updated() { |
||||
|
console.log("New content is available; please refresh."); |
||||
|
}, |
||||
|
offline() { |
||||
|
console.log( |
||||
|
"No internet connection found. App is running in offline mode." |
||||
|
); |
||||
|
}, |
||||
|
error(error) { |
||||
|
console.error("Error during service worker registration:", error); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
@ -0,0 +1,76 @@ |
|||||
|
import Vue from "vue"; |
||||
|
import Router from "vue-router"; |
||||
|
|
||||
|
Vue.use(Router); |
||||
|
|
||||
|
export default new Router({ |
||||
|
routes: [ |
||||
|
{ |
||||
|
path: "/", |
||||
|
component: () => import("@/views/Home"), |
||||
|
children: [ |
||||
|
{ |
||||
|
path: "", |
||||
|
name: "home", |
||||
|
component: () => import("@/views/HomeGlobal") |
||||
|
}, |
||||
|
{ |
||||
|
path: "my-feed", |
||||
|
name: "home-my-feed", |
||||
|
component: () => import("@/views/HomeMyFeed") |
||||
|
}, |
||||
|
{ |
||||
|
path: "tag/:tag", |
||||
|
name: "home-tag", |
||||
|
component: () => import("@/views/HomeTag") |
||||
|
} |
||||
|
] |
||||
|
}, |
||||
|
{ |
||||
|
name: "login", |
||||
|
path: "/login", |
||||
|
component: () => import("@/views/Login") |
||||
|
}, |
||||
|
{ |
||||
|
name: "register", |
||||
|
path: "/register", |
||||
|
component: () => import("@/views/Register") |
||||
|
}, |
||||
|
{ |
||||
|
name: "settings", |
||||
|
path: "/settings", |
||||
|
component: () => import("@/views/Settings") |
||||
|
}, |
||||
|
// Handle child routes with a default, by giving the name to the
|
||||
|
// child.
|
||||
|
// SO: https://github.com/vuejs/vue-router/issues/777
|
||||
|
{ |
||||
|
path: "/@:username", |
||||
|
component: () => import("@/views/Profile"), |
||||
|
children: [ |
||||
|
{ |
||||
|
path: "", |
||||
|
name: "profile", |
||||
|
component: () => import("@/views/ProfileArticles") |
||||
|
}, |
||||
|
{ |
||||
|
name: "profile-favorites", |
||||
|
path: "favorites", |
||||
|
component: () => import("@/views/ProfileFavorited") |
||||
|
} |
||||
|
] |
||||
|
}, |
||||
|
{ |
||||
|
name: "article", |
||||
|
path: "/articles/:slug", |
||||
|
component: () => import("@/views/Article"), |
||||
|
props: true |
||||
|
}, |
||||
|
{ |
||||
|
name: "article-edit", |
||||
|
path: "/editor/:slug?", |
||||
|
props: true, |
||||
|
component: () => import("@/views/ArticleEdit") |
||||
|
} |
||||
|
] |
||||
|
}); |
||||
@ -0,0 +1,22 @@ |
|||||
|
export const ARTICLE_PUBLISH = "publishArticle"; |
||||
|
export const ARTICLE_DELETE = "deleteArticle"; |
||||
|
export const ARTICLE_EDIT = "editArticle"; |
||||
|
export const ARTICLE_EDIT_ADD_TAG = "addTagToArticle"; |
||||
|
export const ARTICLE_EDIT_REMOVE_TAG = "removeTagFromArticle"; |
||||
|
export const ARTICLE_RESET_STATE = "resetArticleState"; |
||||
|
export const CHECK_AUTH = "checkAuth"; |
||||
|
export const COMMENT_CREATE = "createComment"; |
||||
|
export const COMMENT_DESTROY = "destroyComment"; |
||||
|
export const FAVORITE_ADD = "addFavorite"; |
||||
|
export const FAVORITE_REMOVE = "removeFavorite"; |
||||
|
export const FETCH_ARTICLE = "fetchArticle"; |
||||
|
export const FETCH_ARTICLES = "fetchArticles"; |
||||
|
export const FETCH_COMMENTS = "fetchComments"; |
||||
|
export const FETCH_PROFILE = "fetchProfile"; |
||||
|
export const FETCH_PROFILE_FOLLOW = "fetchProfileFollow"; |
||||
|
export const FETCH_PROFILE_UNFOLLOW = "fetchProfileUnfollow"; |
||||
|
export const FETCH_TAGS = "fetchTags"; |
||||
|
export const LOGIN = "login"; |
||||
|
export const LOGOUT = "logout"; |
||||
|
export const REGISTER = "register"; |
||||
|
export const UPDATE_USER = "updateUser"; |
||||
@ -0,0 +1,132 @@ |
|||||
|
import Vue from "vue"; |
||||
|
import { |
||||
|
ArticlesService, |
||||
|
CommentsService, |
||||
|
FavoriteService |
||||
|
} from "@/common/api.service"; |
||||
|
import { |
||||
|
FETCH_ARTICLE, |
||||
|
FETCH_COMMENTS, |
||||
|
COMMENT_CREATE, |
||||
|
COMMENT_DESTROY, |
||||
|
FAVORITE_ADD, |
||||
|
FAVORITE_REMOVE, |
||||
|
ARTICLE_PUBLISH, |
||||
|
ARTICLE_EDIT, |
||||
|
ARTICLE_EDIT_ADD_TAG, |
||||
|
ARTICLE_EDIT_REMOVE_TAG, |
||||
|
ARTICLE_DELETE, |
||||
|
ARTICLE_RESET_STATE |
||||
|
} from "./actions.type"; |
||||
|
import { |
||||
|
RESET_STATE, |
||||
|
SET_ARTICLE, |
||||
|
SET_COMMENTS, |
||||
|
TAG_ADD, |
||||
|
TAG_REMOVE, |
||||
|
UPDATE_ARTICLE_IN_LIST |
||||
|
} from "./mutations.type"; |
||||
|
|
||||
|
const initialState = { |
||||
|
article: { |
||||
|
author: {}, |
||||
|
title: "", |
||||
|
description: "", |
||||
|
body: "", |
||||
|
tagList: [] |
||||
|
}, |
||||
|
comments: [] |
||||
|
}; |
||||
|
|
||||
|
export const state = { ...initialState }; |
||||
|
|
||||
|
export const actions = { |
||||
|
async [FETCH_ARTICLE](context, articleSlug, prevArticle) { |
||||
|
// avoid extronuous network call if article exists
|
||||
|
if (prevArticle !== undefined) { |
||||
|
return context.commit(SET_ARTICLE, prevArticle); |
||||
|
} |
||||
|
const { data } = await ArticlesService.get(articleSlug); |
||||
|
context.commit(SET_ARTICLE, data.article); |
||||
|
return data; |
||||
|
}, |
||||
|
async [FETCH_COMMENTS](context, articleSlug) { |
||||
|
const { data } = await CommentsService.get(articleSlug); |
||||
|
context.commit(SET_COMMENTS, data.comments); |
||||
|
return data.comments; |
||||
|
}, |
||||
|
async [COMMENT_CREATE](context, payload) { |
||||
|
await CommentsService.post(payload.slug, payload.comment); |
||||
|
context.dispatch(FETCH_COMMENTS, payload.slug); |
||||
|
}, |
||||
|
async [COMMENT_DESTROY](context, payload) { |
||||
|
await CommentsService.destroy(payload.slug, payload.commentId); |
||||
|
context.dispatch(FETCH_COMMENTS, payload.slug); |
||||
|
}, |
||||
|
async [FAVORITE_ADD](context, slug) { |
||||
|
const { data } = await FavoriteService.add(slug); |
||||
|
context.commit(UPDATE_ARTICLE_IN_LIST, data.article, { root: true }); |
||||
|
context.commit(SET_ARTICLE, data.article); |
||||
|
}, |
||||
|
async [FAVORITE_REMOVE](context, slug) { |
||||
|
const { data } = await FavoriteService.remove(slug); |
||||
|
// Update list as well. This allows us to favorite an article in the Home view.
|
||||
|
context.commit(UPDATE_ARTICLE_IN_LIST, data.article, { root: true }); |
||||
|
context.commit(SET_ARTICLE, data.article); |
||||
|
}, |
||||
|
[ARTICLE_PUBLISH]({ state }) { |
||||
|
return ArticlesService.create(state.article); |
||||
|
}, |
||||
|
[ARTICLE_DELETE](context, slug) { |
||||
|
return ArticlesService.destroy(slug); |
||||
|
}, |
||||
|
[ARTICLE_EDIT]({ state }) { |
||||
|
return ArticlesService.update(state.article.slug, state.article); |
||||
|
}, |
||||
|
[ARTICLE_EDIT_ADD_TAG](context, tag) { |
||||
|
context.commit(TAG_ADD, tag); |
||||
|
}, |
||||
|
[ARTICLE_EDIT_REMOVE_TAG](context, tag) { |
||||
|
context.commit(TAG_REMOVE, tag); |
||||
|
}, |
||||
|
[ARTICLE_RESET_STATE]({ commit }) { |
||||
|
commit(RESET_STATE); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
/* eslint no-param-reassign: ["error", { "props": false }] */ |
||||
|
export const mutations = { |
||||
|
[SET_ARTICLE](state, article) { |
||||
|
state.article = article; |
||||
|
}, |
||||
|
[SET_COMMENTS](state, comments) { |
||||
|
state.comments = comments; |
||||
|
}, |
||||
|
[TAG_ADD](state, tag) { |
||||
|
state.article.tagList = state.article.tagList.concat([tag]); |
||||
|
}, |
||||
|
[TAG_REMOVE](state, tag) { |
||||
|
state.article.tagList = state.article.tagList.filter(t => t !== tag); |
||||
|
}, |
||||
|
[RESET_STATE]() { |
||||
|
for (let f in state) { |
||||
|
Vue.set(state, f, initialState[f]); |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const getters = { |
||||
|
article(state) { |
||||
|
return state.article; |
||||
|
}, |
||||
|
comments(state) { |
||||
|
return state.comments; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
export default { |
||||
|
state, |
||||
|
actions, |
||||
|
mutations, |
||||
|
getters |
||||
|
}; |
||||
@ -0,0 +1,112 @@ |
|||||
|
import ApiService from "@/common/api.service"; |
||||
|
import JwtService from "@/common/jwt.service"; |
||||
|
import { |
||||
|
LOGIN, |
||||
|
LOGOUT, |
||||
|
REGISTER, |
||||
|
CHECK_AUTH, |
||||
|
UPDATE_USER |
||||
|
} from "./actions.type"; |
||||
|
import { SET_AUTH, PURGE_AUTH, SET_ERROR } from "./mutations.type"; |
||||
|
|
||||
|
const state = { |
||||
|
errors: null, |
||||
|
user: {}, |
||||
|
isAuthenticated: !!JwtService.getToken() |
||||
|
}; |
||||
|
|
||||
|
const getters = { |
||||
|
currentUser(state) { |
||||
|
return state.user; |
||||
|
}, |
||||
|
isAuthenticated(state) { |
||||
|
return state.isAuthenticated; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const actions = { |
||||
|
[LOGIN](context, credentials) { |
||||
|
return new Promise(resolve => { |
||||
|
ApiService.post("users/login", { user: credentials }) |
||||
|
.then(({ data }) => { |
||||
|
context.commit(SET_AUTH, data.user); |
||||
|
resolve(data); |
||||
|
}) |
||||
|
.catch(({ response }) => { |
||||
|
context.commit(SET_ERROR, response.data.errors); |
||||
|
}); |
||||
|
}); |
||||
|
}, |
||||
|
[LOGOUT](context) { |
||||
|
context.commit(PURGE_AUTH); |
||||
|
}, |
||||
|
[REGISTER](context, credentials) { |
||||
|
return new Promise((resolve, reject) => { |
||||
|
ApiService.post("users", { user: credentials }) |
||||
|
.then(({ data }) => { |
||||
|
context.commit(SET_AUTH, data.user); |
||||
|
resolve(data); |
||||
|
}) |
||||
|
.catch(({ response }) => { |
||||
|
context.commit(SET_ERROR, response.data.errors); |
||||
|
reject(response); |
||||
|
}); |
||||
|
}); |
||||
|
}, |
||||
|
[CHECK_AUTH](context) { |
||||
|
if (JwtService.getToken()) { |
||||
|
ApiService.setHeader(); |
||||
|
ApiService.get("user") |
||||
|
.then(({ data }) => { |
||||
|
context.commit(SET_AUTH, data.user); |
||||
|
}) |
||||
|
.catch(({ response }) => { |
||||
|
context.commit(SET_ERROR, response.data.errors); |
||||
|
}); |
||||
|
} else { |
||||
|
context.commit(PURGE_AUTH); |
||||
|
} |
||||
|
}, |
||||
|
[UPDATE_USER](context, payload) { |
||||
|
const { email, username, password, image, bio } = payload; |
||||
|
const user = { |
||||
|
email, |
||||
|
username, |
||||
|
bio, |
||||
|
image |
||||
|
}; |
||||
|
if (password) { |
||||
|
user.password = password; |
||||
|
} |
||||
|
|
||||
|
return ApiService.put("user", user).then(({ data }) => { |
||||
|
context.commit(SET_AUTH, data.user); |
||||
|
return data; |
||||
|
}); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const mutations = { |
||||
|
[SET_ERROR](state, error) { |
||||
|
state.errors = error; |
||||
|
}, |
||||
|
[SET_AUTH](state, user) { |
||||
|
state.isAuthenticated = true; |
||||
|
state.user = user; |
||||
|
state.errors = {}; |
||||
|
JwtService.saveToken(state.user.token); |
||||
|
}, |
||||
|
[PURGE_AUTH](state) { |
||||
|
state.isAuthenticated = false; |
||||
|
state.user = {}; |
||||
|
state.errors = {}; |
||||
|
JwtService.destroyToken(); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
export default { |
||||
|
state, |
||||
|
actions, |
||||
|
mutations, |
||||
|
getters |
||||
|
}; |
||||
@ -0,0 +1,87 @@ |
|||||
|
import { TagsService, ArticlesService } from "@/common/api.service"; |
||||
|
import { FETCH_ARTICLES, FETCH_TAGS } from "./actions.type"; |
||||
|
import { |
||||
|
FETCH_START, |
||||
|
FETCH_END, |
||||
|
SET_TAGS, |
||||
|
UPDATE_ARTICLE_IN_LIST |
||||
|
} from "./mutations.type"; |
||||
|
|
||||
|
const state = { |
||||
|
tags: [], |
||||
|
articles: [], |
||||
|
isLoading: true, |
||||
|
articlesCount: 0 |
||||
|
}; |
||||
|
|
||||
|
const getters = { |
||||
|
articlesCount(state) { |
||||
|
return state.articlesCount; |
||||
|
}, |
||||
|
articles(state) { |
||||
|
return state.articles; |
||||
|
}, |
||||
|
isLoading(state) { |
||||
|
return state.isLoading; |
||||
|
}, |
||||
|
tags(state) { |
||||
|
return state.tags; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const actions = { |
||||
|
[FETCH_ARTICLES]({ commit }, params) { |
||||
|
commit(FETCH_START); |
||||
|
return ArticlesService.query(params.type, params.filters) |
||||
|
.then(({ data }) => { |
||||
|
commit(FETCH_END, data); |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
throw new Error(error); |
||||
|
}); |
||||
|
}, |
||||
|
[FETCH_TAGS]({ commit }) { |
||||
|
return TagsService.get() |
||||
|
.then(({ data }) => { |
||||
|
commit(SET_TAGS, data.tags); |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
throw new Error(error); |
||||
|
}); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
/* eslint no-param-reassign: ["error", { "props": false }] */ |
||||
|
const mutations = { |
||||
|
[FETCH_START](state) { |
||||
|
state.isLoading = true; |
||||
|
}, |
||||
|
[FETCH_END](state, { articles, articlesCount }) { |
||||
|
state.articles = articles; |
||||
|
state.articlesCount = articlesCount; |
||||
|
state.isLoading = false; |
||||
|
}, |
||||
|
[SET_TAGS](state, tags) { |
||||
|
state.tags = tags; |
||||
|
}, |
||||
|
[UPDATE_ARTICLE_IN_LIST](state, data) { |
||||
|
state.articles = state.articles.map(article => { |
||||
|
if (article.slug !== data.slug) { |
||||
|
return article; |
||||
|
} |
||||
|
// We could just return data, but it seems dangerous to
|
||||
|
// mix the results of different api calls, so we
|
||||
|
// protect ourselves by copying the information.
|
||||
|
article.favorited = data.favorited; |
||||
|
article.favoritesCount = data.favoritesCount; |
||||
|
return article; |
||||
|
}); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
export default { |
||||
|
state, |
||||
|
getters, |
||||
|
actions, |
||||
|
mutations |
||||
|
}; |
||||
@ -0,0 +1,18 @@ |
|||||
|
import Vue from "vue"; |
||||
|
import Vuex from "vuex"; |
||||
|
|
||||
|
import home from "./home.module"; |
||||
|
import auth from "./auth.module"; |
||||
|
import article from "./article.module"; |
||||
|
import profile from "./profile.module"; |
||||
|
|
||||
|
Vue.use(Vuex); |
||||
|
|
||||
|
export default new Vuex.Store({ |
||||
|
modules: { |
||||
|
home, |
||||
|
auth, |
||||
|
article, |
||||
|
profile |
||||
|
} |
||||
|
}); |
||||
@ -0,0 +1,13 @@ |
|||||
|
export const FETCH_END = "setArticles"; |
||||
|
export const FETCH_START = "setLoading"; |
||||
|
export const PURGE_AUTH = "logOut"; |
||||
|
export const SET_ARTICLE = "setArticle"; |
||||
|
export const SET_AUTH = "setUser"; |
||||
|
export const SET_COMMENTS = "setComments"; |
||||
|
export const SET_ERROR = "setError"; |
||||
|
export const SET_PROFILE = "setProfile"; |
||||
|
export const SET_TAGS = "setTags"; |
||||
|
export const TAG_ADD = "addTag"; |
||||
|
export const TAG_REMOVE = "removeTag"; |
||||
|
export const UPDATE_ARTICLE_IN_LIST = "updateArticleInList"; |
||||
|
export const RESET_STATE = "resetModuleState"; |
||||
@ -0,0 +1,74 @@ |
|||||
|
import ApiService from "@/common/api.service"; |
||||
|
import { |
||||
|
FETCH_PROFILE, |
||||
|
FETCH_PROFILE_FOLLOW, |
||||
|
FETCH_PROFILE_UNFOLLOW |
||||
|
} from "./actions.type"; |
||||
|
import { SET_PROFILE } from "./mutations.type"; |
||||
|
|
||||
|
const state = { |
||||
|
errors: {}, |
||||
|
profile: {} |
||||
|
}; |
||||
|
|
||||
|
const getters = { |
||||
|
profile(state) { |
||||
|
return state.profile; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const actions = { |
||||
|
[FETCH_PROFILE](context, payload) { |
||||
|
const { username } = payload; |
||||
|
return ApiService.get("profiles", username) |
||||
|
.then(({ data }) => { |
||||
|
context.commit(SET_PROFILE, data.profile); |
||||
|
return data; |
||||
|
}) |
||||
|
.catch(() => { |
||||
|
// #todo SET_ERROR cannot work in multiple states
|
||||
|
// context.commit(SET_ERROR, response.data.errors)
|
||||
|
}); |
||||
|
}, |
||||
|
[FETCH_PROFILE_FOLLOW](context, payload) { |
||||
|
const { username } = payload; |
||||
|
return ApiService.post(`profiles/${username}/follow`) |
||||
|
.then(({ data }) => { |
||||
|
context.commit(SET_PROFILE, data.profile); |
||||
|
return data; |
||||
|
}) |
||||
|
.catch(() => { |
||||
|
// #todo SET_ERROR cannot work in multiple states
|
||||
|
// context.commit(SET_ERROR, response.data.errors)
|
||||
|
}); |
||||
|
}, |
||||
|
[FETCH_PROFILE_UNFOLLOW](context, payload) { |
||||
|
const { username } = payload; |
||||
|
return ApiService.delete(`profiles/${username}/follow`) |
||||
|
.then(({ data }) => { |
||||
|
context.commit(SET_PROFILE, data.profile); |
||||
|
return data; |
||||
|
}) |
||||
|
.catch(() => { |
||||
|
// #todo SET_ERROR cannot work in multiple states
|
||||
|
// context.commit(SET_ERROR, response.data.errors)
|
||||
|
}); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const mutations = { |
||||
|
// [SET_ERROR] (state, error) {
|
||||
|
// state.errors = error
|
||||
|
// },
|
||||
|
[SET_PROFILE](state, profile) { |
||||
|
state.profile = profile; |
||||
|
state.errors = {}; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
export default { |
||||
|
state, |
||||
|
actions, |
||||
|
mutations, |
||||
|
getters |
||||
|
}; |
||||
@ -0,0 +1,45 @@ |
|||||
|
import { ArticlesService, CommentsService } from "@/common/api.service"; |
||||
|
import { FETCH_ARTICLE, FETCH_COMMENTS } from "./actions.type"; |
||||
|
import { SET_ARTICLE, SET_COMMENTS } from "./mutations.type"; |
||||
|
|
||||
|
export const state = { |
||||
|
article: {}, |
||||
|
comments: [] |
||||
|
}; |
||||
|
|
||||
|
export const actions = { |
||||
|
[FETCH_ARTICLE](context, articleSlug) { |
||||
|
return ArticlesService.get(articleSlug) |
||||
|
.then(({ data }) => { |
||||
|
context.commit(SET_ARTICLE, data.article); |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
throw new Error(error); |
||||
|
}); |
||||
|
}, |
||||
|
[FETCH_COMMENTS](context, articleSlug) { |
||||
|
return CommentsService.get(articleSlug) |
||||
|
.then(({ data }) => { |
||||
|
context.commit(SET_COMMENTS, data.comments); |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
throw new Error(error); |
||||
|
}); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
/* eslint no-param-reassign: ["error", { "props": false }] */ |
||||
|
export const mutations = { |
||||
|
[SET_ARTICLE](state, article) { |
||||
|
state.article = article; |
||||
|
}, |
||||
|
[SET_COMMENTS](state, comments) { |
||||
|
state.comments = comments; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
export default { |
||||
|
state, |
||||
|
actions, |
||||
|
mutations |
||||
|
}; |
||||
@ -0,0 +1,95 @@ |
|||||
|
<template> |
||||
|
<div class="article-page"> |
||||
|
<div class="banner"> |
||||
|
<div class="container"> |
||||
|
<h1>{{ article.title }}</h1> |
||||
|
<RwvArticleMeta :article="article" :actions="true"></RwvArticleMeta> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="container page"> |
||||
|
<div class="row article-content"> |
||||
|
<div class="col-xs-12"> |
||||
|
<div v-html="parseMarkdown(article.body)"></div> |
||||
|
<ul class="tag-list"> |
||||
|
<li v-for="(tag, index) of article.tagList" :key="tag + index"> |
||||
|
<RwvTag |
||||
|
:name="tag" |
||||
|
className="tag-default tag-pill tag-outline" |
||||
|
></RwvTag> |
||||
|
</li> |
||||
|
</ul> |
||||
|
</div> |
||||
|
</div> |
||||
|
<hr /> |
||||
|
<div class="article-actions"> |
||||
|
<RwvArticleMeta :article="article" :actions="true"></RwvArticleMeta> |
||||
|
</div> |
||||
|
<div class="row"> |
||||
|
<div class="col-xs-12 col-md-8 offset-md-2"> |
||||
|
<RwvCommentEditor |
||||
|
v-if="isAuthenticated" |
||||
|
:slug="slug" |
||||
|
:userImage="currentUser.image" |
||||
|
> |
||||
|
</RwvCommentEditor> |
||||
|
<p v-else> |
||||
|
<router-link :to="{ name: 'login' }">Sign in</router-link> |
||||
|
or |
||||
|
<router-link :to="{ name: 'register' }">sign up</router-link> |
||||
|
to add comments on this article. |
||||
|
</p> |
||||
|
<RwvComment |
||||
|
v-for="(comment, index) in comments" |
||||
|
:slug="slug" |
||||
|
:comment="comment" |
||||
|
:key="index" |
||||
|
> |
||||
|
</RwvComment> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
import { mapGetters } from "vuex"; |
||||
|
import marked from "marked"; |
||||
|
import store from "@/store"; |
||||
|
import RwvArticleMeta from "@/components/ArticleMeta"; |
||||
|
import RwvComment from "@/components/Comment"; |
||||
|
import RwvCommentEditor from "@/components/CommentEditor"; |
||||
|
import RwvTag from "@/components/VTag"; |
||||
|
import { FETCH_ARTICLE, FETCH_COMMENTS } from "@/store/actions.type"; |
||||
|
|
||||
|
export default { |
||||
|
name: "rwv-article", |
||||
|
props: { |
||||
|
slug: { |
||||
|
type: String, |
||||
|
required: true |
||||
|
} |
||||
|
}, |
||||
|
components: { |
||||
|
RwvArticleMeta, |
||||
|
RwvComment, |
||||
|
RwvCommentEditor, |
||||
|
RwvTag |
||||
|
}, |
||||
|
beforeRouteEnter(to, from, next) { |
||||
|
Promise.all([ |
||||
|
store.dispatch(FETCH_ARTICLE, to.params.slug), |
||||
|
store.dispatch(FETCH_COMMENTS, to.params.slug) |
||||
|
]).then(() => { |
||||
|
next(); |
||||
|
}); |
||||
|
}, |
||||
|
computed: { |
||||
|
...mapGetters(["article", "currentUser", "comments", "isAuthenticated"]) |
||||
|
}, |
||||
|
methods: { |
||||
|
parseMarkdown(content) { |
||||
|
return marked(content); |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
</script> |
||||
@ -0,0 +1,150 @@ |
|||||
|
<template> |
||||
|
<div class="editor-page"> |
||||
|
<div class="container page"> |
||||
|
<div class="row"> |
||||
|
<div class="col-md-10 offset-md-1 col-xs-12"> |
||||
|
<RwvListErrors :errors="errors" /> |
||||
|
<form @submit.prevent="onPublish(article.slug)"> |
||||
|
<fieldset :disabled="inProgress"> |
||||
|
<fieldset class="form-group"> |
||||
|
<input |
||||
|
type="text" |
||||
|
class="form-control form-control-lg" |
||||
|
v-model="article.title" |
||||
|
placeholder="Article Title" |
||||
|
/> |
||||
|
</fieldset> |
||||
|
<fieldset class="form-group"> |
||||
|
<input |
||||
|
type="text" |
||||
|
class="form-control" |
||||
|
v-model="article.description" |
||||
|
placeholder="What's this article about?" |
||||
|
/> |
||||
|
</fieldset> |
||||
|
<fieldset class="form-group"> |
||||
|
<textarea |
||||
|
class="form-control" |
||||
|
rows="8" |
||||
|
v-model="article.body" |
||||
|
placeholder="Write your article (in markdown)" |
||||
|
> |
||||
|
</textarea> |
||||
|
</fieldset> |
||||
|
<fieldset class="form-group"> |
||||
|
<input |
||||
|
type="text" |
||||
|
class="form-control" |
||||
|
placeholder="Enter tags" |
||||
|
v-model="tagInput" |
||||
|
@keypress.enter.prevent="addTag(tagInput)" |
||||
|
/> |
||||
|
<div class="tag-list"> |
||||
|
<span |
||||
|
class="tag-default tag-pill" |
||||
|
v-for="(tag, index) of article.tagList" |
||||
|
:key="tag + index" |
||||
|
> |
||||
|
<i class="ion-close-round" @click="removeTag(tag)"> </i> |
||||
|
{{ tag }} |
||||
|
</span> |
||||
|
</div> |
||||
|
</fieldset> |
||||
|
</fieldset> |
||||
|
<button |
||||
|
:disabled="inProgress" |
||||
|
class="btn btn-lg pull-xs-right btn-primary" |
||||
|
type="submit" |
||||
|
> |
||||
|
Publish Article |
||||
|
</button> |
||||
|
</form> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
import { mapGetters } from "vuex"; |
||||
|
import store from "@/store"; |
||||
|
import RwvListErrors from "@/components/ListErrors"; |
||||
|
import { |
||||
|
ARTICLE_PUBLISH, |
||||
|
ARTICLE_EDIT, |
||||
|
FETCH_ARTICLE, |
||||
|
ARTICLE_EDIT_ADD_TAG, |
||||
|
ARTICLE_EDIT_REMOVE_TAG, |
||||
|
ARTICLE_RESET_STATE |
||||
|
} from "@/store/actions.type"; |
||||
|
|
||||
|
export default { |
||||
|
name: "RwvArticleEdit", |
||||
|
components: { RwvListErrors }, |
||||
|
props: { |
||||
|
previousArticle: { |
||||
|
type: Object, |
||||
|
required: false |
||||
|
} |
||||
|
}, |
||||
|
async beforeRouteUpdate(to, from, next) { |
||||
|
// Reset state if user goes from /editor/:id to /editor |
||||
|
// The component is not recreated so we use to hook to reset the state. |
||||
|
await store.dispatch(ARTICLE_RESET_STATE); |
||||
|
return next(); |
||||
|
}, |
||||
|
async beforeRouteEnter(to, from, next) { |
||||
|
// SO: https://github.com/vuejs/vue-router/issues/1034 |
||||
|
// If we arrive directly to this url, we need to fetch the article |
||||
|
await store.dispatch(ARTICLE_RESET_STATE); |
||||
|
if (to.params.slug !== undefined) { |
||||
|
await store.dispatch( |
||||
|
FETCH_ARTICLE, |
||||
|
to.params.slug, |
||||
|
to.params.previousArticle |
||||
|
); |
||||
|
} |
||||
|
return next(); |
||||
|
}, |
||||
|
async beforeRouteLeave(to, from, next) { |
||||
|
await store.dispatch(ARTICLE_RESET_STATE); |
||||
|
next(); |
||||
|
}, |
||||
|
data() { |
||||
|
return { |
||||
|
tagInput: null, |
||||
|
inProgress: false, |
||||
|
errors: {} |
||||
|
}; |
||||
|
}, |
||||
|
computed: { |
||||
|
...mapGetters(["article"]) |
||||
|
}, |
||||
|
methods: { |
||||
|
onPublish(slug) { |
||||
|
let action = slug ? ARTICLE_EDIT : ARTICLE_PUBLISH; |
||||
|
this.inProgress = true; |
||||
|
this.$store |
||||
|
.dispatch(action) |
||||
|
.then(({ data }) => { |
||||
|
this.inProgress = false; |
||||
|
this.$router.push({ |
||||
|
name: "article", |
||||
|
params: { slug: data.article.slug } |
||||
|
}); |
||||
|
}) |
||||
|
.catch(({ response }) => { |
||||
|
this.inProgress = false; |
||||
|
this.errors = response.data.errors; |
||||
|
}); |
||||
|
}, |
||||
|
removeTag(tag) { |
||||
|
this.$store.dispatch(ARTICLE_EDIT_REMOVE_TAG, tag); |
||||
|
}, |
||||
|
addTag(tag) { |
||||
|
this.$store.dispatch(ARTICLE_EDIT_ADD_TAG, tag); |
||||
|
this.tagInput = null; |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
</script> |
||||
@ -0,0 +1,80 @@ |
|||||
|
<template> |
||||
|
<div class="home-page"> |
||||
|
<div class="banner"> |
||||
|
<div class="container"> |
||||
|
<h1 class="logo-font">conduit</h1> |
||||
|
<p>A place to share your knowledge.</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="container page"> |
||||
|
<div class="row"> |
||||
|
<div class="col-md-9"> |
||||
|
<div class="feed-toggle"> |
||||
|
<ul class="nav nav-pills outline-active"> |
||||
|
<li v-if="isAuthenticated" class="nav-item"> |
||||
|
<router-link |
||||
|
:to="{ name: 'home-my-feed' }" |
||||
|
class="nav-link" |
||||
|
active-class="active" |
||||
|
> |
||||
|
Your Feed |
||||
|
</router-link> |
||||
|
</li> |
||||
|
<li class="nav-item"> |
||||
|
<router-link |
||||
|
:to="{ name: 'home' }" |
||||
|
exact |
||||
|
class="nav-link" |
||||
|
active-class="active" |
||||
|
> |
||||
|
Global Feed |
||||
|
</router-link> |
||||
|
</li> |
||||
|
<li class="nav-item" v-if="tag"> |
||||
|
<router-link |
||||
|
:to="{ name: 'home-tag', params: { tag } }" |
||||
|
class="nav-link" |
||||
|
active-class="active" |
||||
|
> |
||||
|
<i class="ion-pound"></i> {{ tag }} |
||||
|
</router-link> |
||||
|
</li> |
||||
|
</ul> |
||||
|
</div> |
||||
|
<router-view></router-view> |
||||
|
</div> |
||||
|
<div class="col-md-3"> |
||||
|
<div class="sidebar"> |
||||
|
<p>Popular Tags</p> |
||||
|
<div class="tag-list"> |
||||
|
<RwvTag v-for="(tag, index) in tags" :name="tag" :key="index"> |
||||
|
</RwvTag> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
import { mapGetters } from "vuex"; |
||||
|
import RwvTag from "@/components/VTag"; |
||||
|
import { FETCH_TAGS } from "@/store/actions.type"; |
||||
|
|
||||
|
export default { |
||||
|
name: "home", |
||||
|
components: { |
||||
|
RwvTag |
||||
|
}, |
||||
|
mounted() { |
||||
|
this.$store.dispatch(FETCH_TAGS); |
||||
|
}, |
||||
|
computed: { |
||||
|
...mapGetters(["isAuthenticated", "tags"]), |
||||
|
tag() { |
||||
|
return this.$route.params.tag; |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
</script> |
||||
@ -0,0 +1,13 @@ |
|||||
|
<template> |
||||
|
<div class="home-global"><RwvArticleList type="all" /></div> |
||||
|
</template> |
||||
|
<script> |
||||
|
import RwvArticleList from "@/components/ArticleList"; |
||||
|
|
||||
|
export default { |
||||
|
name: "rwv-home-global", |
||||
|
components: { |
||||
|
RwvArticleList |
||||
|
} |
||||
|
}; |
||||
|
</script> |
||||
@ -0,0 +1,14 @@ |
|||||
|
<template> |
||||
|
<div class="home-my-feed"><RwvArticleList type="feed" /></div> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
import RwvArticleList from "@/components/ArticleList"; |
||||
|
|
||||
|
export default { |
||||
|
name: "rwv-home-my-feed", |
||||
|
components: { |
||||
|
RwvArticleList |
||||
|
} |
||||
|
}; |
||||
|
</script> |
||||
@ -0,0 +1,19 @@ |
|||||
|
<template> |
||||
|
<div class="home-tag"><RwvArticleList :tag="tag"></RwvArticleList></div> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
import RwvArticleList from "@/components/ArticleList"; |
||||
|
|
||||
|
export default { |
||||
|
name: "RwvHomeTag", |
||||
|
components: { |
||||
|
RwvArticleList |
||||
|
}, |
||||
|
computed: { |
||||
|
tag() { |
||||
|
return this.$route.params.tag; |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
</script> |
||||
@ -0,0 +1,67 @@ |
|||||
|
<template> |
||||
|
<div class="auth-page"> |
||||
|
<div class="container page"> |
||||
|
<div class="row"> |
||||
|
<div class="col-md-6 offset-md-3 col-xs-12"> |
||||
|
<h1 class="text-xs-center">Sign in</h1> |
||||
|
<p class="text-xs-center"> |
||||
|
<router-link :to="{ name: 'register' }"> |
||||
|
Need an account? |
||||
|
</router-link> |
||||
|
</p> |
||||
|
<ul v-if="errors" class="error-messages"> |
||||
|
<li v-for="(v, k) in errors" :key="k">{{ k }} {{ v | error }}</li> |
||||
|
</ul> |
||||
|
<form @submit.prevent="onSubmit(email, password)"> |
||||
|
<fieldset class="form-group"> |
||||
|
<input |
||||
|
class="form-control form-control-lg" |
||||
|
type="text" |
||||
|
v-model="email" |
||||
|
placeholder="Email" |
||||
|
/> |
||||
|
</fieldset> |
||||
|
<fieldset class="form-group"> |
||||
|
<input |
||||
|
class="form-control form-control-lg" |
||||
|
type="password" |
||||
|
v-model="password" |
||||
|
placeholder="Password" |
||||
|
/> |
||||
|
</fieldset> |
||||
|
<button class="btn btn-lg btn-primary pull-xs-right"> |
||||
|
Sign in |
||||
|
</button> |
||||
|
</form> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
import { mapState } from "vuex"; |
||||
|
import { LOGIN } from "@/store/actions.type"; |
||||
|
|
||||
|
export default { |
||||
|
name: "RwvLogin", |
||||
|
data() { |
||||
|
return { |
||||
|
email: null, |
||||
|
password: null |
||||
|
}; |
||||
|
}, |
||||
|
methods: { |
||||
|
onSubmit(email, password) { |
||||
|
this.$store |
||||
|
.dispatch(LOGIN, { email, password }) |
||||
|
.then(() => this.$router.push({ name: "home" })); |
||||
|
} |
||||
|
}, |
||||
|
computed: { |
||||
|
...mapState({ |
||||
|
errors: state => state.auth.errors |
||||
|
}) |
||||
|
} |
||||
|
}; |
||||
|
</script> |
||||
@ -0,0 +1,112 @@ |
|||||
|
<template> |
||||
|
<div class="profile-page"> |
||||
|
<div class="user-info"> |
||||
|
<div class="container"> |
||||
|
<div class="row"> |
||||
|
<div class="col-xs-12 col-md-10 offset-md-1"> |
||||
|
<img :src="profile.image" class="user-img" /> |
||||
|
<h4>{{ profile.username }}</h4> |
||||
|
<p>{{ profile.bio }}</p> |
||||
|
<div v-if="isCurrentUser()"> |
||||
|
<router-link |
||||
|
class="btn btn-sm btn-outline-secondary action-btn" |
||||
|
:to="{ name: 'settings' }" |
||||
|
> |
||||
|
<i class="ion-gear-a"></i> Edit Profile Settings |
||||
|
</router-link> |
||||
|
</div> |
||||
|
<div v-else> |
||||
|
<button |
||||
|
class="btn btn-sm btn-secondary action-btn" |
||||
|
v-if="profile.following" |
||||
|
@click.prevent="unfollow()" |
||||
|
> |
||||
|
<i class="ion-plus-round"></i> Unfollow |
||||
|
{{ profile.username }} |
||||
|
</button> |
||||
|
<button |
||||
|
class="btn btn-sm btn-outline-secondary action-btn" |
||||
|
v-if="!profile.following" |
||||
|
@click.prevent="follow()" |
||||
|
> |
||||
|
<i class="ion-plus-round"></i> Follow |
||||
|
{{ profile.username }} |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="container"> |
||||
|
<div class="row"> |
||||
|
<div class="col-xs-12 col-md-10 offset-md-1"> |
||||
|
<div class="articles-toggle"> |
||||
|
<ul class="nav nav-pills outline-active"> |
||||
|
<li class="nav-item"> |
||||
|
<router-link |
||||
|
class="nav-link" |
||||
|
active-class="active" |
||||
|
exact |
||||
|
:to="{ name: 'profile' }" |
||||
|
> |
||||
|
My Articles |
||||
|
</router-link> |
||||
|
</li> |
||||
|
<li class="nav-item"> |
||||
|
<router-link |
||||
|
class="nav-link" |
||||
|
active-class="active" |
||||
|
exact |
||||
|
:to="{ name: 'profile-favorites' }" |
||||
|
> |
||||
|
Favorited Articles |
||||
|
</router-link> |
||||
|
</li> |
||||
|
</ul> |
||||
|
</div> |
||||
|
<router-view></router-view> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
import { mapGetters } from "vuex"; |
||||
|
import { |
||||
|
FETCH_PROFILE, |
||||
|
FETCH_PROFILE_FOLLOW, |
||||
|
FETCH_PROFILE_UNFOLLOW |
||||
|
} from "@/store/actions.type"; |
||||
|
|
||||
|
export default { |
||||
|
name: "RwvProfile", |
||||
|
mounted() { |
||||
|
this.$store.dispatch(FETCH_PROFILE, this.$route.params); |
||||
|
}, |
||||
|
computed: { |
||||
|
...mapGetters(["currentUser", "profile", "isAuthenticated"]) |
||||
|
}, |
||||
|
methods: { |
||||
|
isCurrentUser() { |
||||
|
if (this.currentUser.username && this.profile.username) { |
||||
|
return this.currentUser.username === this.profile.username; |
||||
|
} |
||||
|
return false; |
||||
|
}, |
||||
|
follow() { |
||||
|
if (!this.isAuthenticated) return; |
||||
|
this.$store.dispatch(FETCH_PROFILE_FOLLOW, this.$route.params); |
||||
|
}, |
||||
|
unfollow() { |
||||
|
this.$store.dispatch(FETCH_PROFILE_UNFOLLOW, this.$route.params); |
||||
|
} |
||||
|
}, |
||||
|
watch: { |
||||
|
$route(to) { |
||||
|
this.$store.dispatch(FETCH_PROFILE, to.params); |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
</script> |
||||
@ -0,0 +1,21 @@ |
|||||
|
<template> |
||||
|
<div class="profile-page"> |
||||
|
<RwvArticleList :author="author" :items-per-page="5"></RwvArticleList> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
import RwvArticleList from "@/components/ArticleList"; |
||||
|
|
||||
|
export default { |
||||
|
name: "RwvProfileArticles", |
||||
|
components: { |
||||
|
RwvArticleList |
||||
|
}, |
||||
|
computed: { |
||||
|
author() { |
||||
|
return this.$route.params.username; |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
</script> |
||||
@ -0,0 +1,22 @@ |
|||||
|
<template> |
||||
|
<div class="profile-page"> |
||||
|
<RwvArticleList :favorited="favorited" :items-per-page="5"> |
||||
|
</RwvArticleList> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
import RwvArticleList from "@/components/ArticleList"; |
||||
|
|
||||
|
export default { |
||||
|
name: "RwvProfileFavorited", |
||||
|
components: { |
||||
|
RwvArticleList |
||||
|
}, |
||||
|
computed: { |
||||
|
favorited() { |
||||
|
return this.$route.params.username; |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
</script> |
||||
@ -0,0 +1,80 @@ |
|||||
|
<template> |
||||
|
<div class="auth-page"> |
||||
|
<div class="container page"> |
||||
|
<div class="row"> |
||||
|
<div class="col-md-6 offset-md-3 col-xs-12"> |
||||
|
<h1 class="text-xs-center">Sign up</h1> |
||||
|
<p class="text-xs-center"> |
||||
|
<router-link :to="{ name: 'login' }"> |
||||
|
Have an account? |
||||
|
</router-link> |
||||
|
</p> |
||||
|
<ul v-if="errors" class="error-messages"> |
||||
|
<li v-for="(v, k) in errors" :key="k">{{ k }} {{ v | error }}</li> |
||||
|
</ul> |
||||
|
<form @submit.prevent="onSubmit"> |
||||
|
<fieldset class="form-group"> |
||||
|
<input |
||||
|
class="form-control form-control-lg" |
||||
|
type="text" |
||||
|
v-model="username" |
||||
|
placeholder="Username" |
||||
|
/> |
||||
|
</fieldset> |
||||
|
<fieldset class="form-group"> |
||||
|
<input |
||||
|
class="form-control form-control-lg" |
||||
|
type="text" |
||||
|
v-model="email" |
||||
|
placeholder="Email" |
||||
|
/> |
||||
|
</fieldset> |
||||
|
<fieldset class="form-group"> |
||||
|
<input |
||||
|
class="form-control form-control-lg" |
||||
|
type="password" |
||||
|
v-model="password" |
||||
|
placeholder="Password" |
||||
|
/> |
||||
|
</fieldset> |
||||
|
<button class="btn btn-lg btn-primary pull-xs-right"> |
||||
|
Sign up |
||||
|
</button> |
||||
|
</form> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
import { mapState } from "vuex"; |
||||
|
import { REGISTER } from "@/store/actions.type"; |
||||
|
|
||||
|
export default { |
||||
|
name: "RwvRegister", |
||||
|
data() { |
||||
|
return { |
||||
|
username: "", |
||||
|
email: "", |
||||
|
password: "" |
||||
|
}; |
||||
|
}, |
||||
|
computed: { |
||||
|
...mapState({ |
||||
|
errors: state => state.auth.errors |
||||
|
}) |
||||
|
}, |
||||
|
methods: { |
||||
|
onSubmit() { |
||||
|
this.$store |
||||
|
.dispatch(REGISTER, { |
||||
|
email: this.email, |
||||
|
password: this.password, |
||||
|
username: this.username |
||||
|
}) |
||||
|
.then(() => this.$router.push({ name: "home" })); |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
</script> |
||||
@ -0,0 +1,88 @@ |
|||||
|
<template> |
||||
|
<div class="settings-page"> |
||||
|
<div class="container page"> |
||||
|
<div class="row"> |
||||
|
<div class="col-md-6 offset-md-3 col-xs-12"> |
||||
|
<h1 class="text-xs-center">Your Settings</h1> |
||||
|
<form @submit.prevent="updateSettings()"> |
||||
|
<fieldset> |
||||
|
<fieldset class="form-group"> |
||||
|
<input |
||||
|
class="form-control" |
||||
|
type="text" |
||||
|
v-model="currentUser.image" |
||||
|
placeholder="URL of profile picture" |
||||
|
/> |
||||
|
</fieldset> |
||||
|
<fieldset class="form-group"> |
||||
|
<input |
||||
|
class="form-control form-control-lg" |
||||
|
type="text" |
||||
|
v-model="currentUser.username" |
||||
|
placeholder="Your username" |
||||
|
/> |
||||
|
</fieldset> |
||||
|
<fieldset class="form-group"> |
||||
|
<textarea |
||||
|
class="form-control form-control-lg" |
||||
|
rows="8" |
||||
|
v-model="currentUser.bio" |
||||
|
placeholder="Short bio about you" |
||||
|
></textarea> |
||||
|
</fieldset> |
||||
|
<fieldset class="form-group"> |
||||
|
<input |
||||
|
class="form-control form-control-lg" |
||||
|
type="text" |
||||
|
v-model="currentUser.email" |
||||
|
placeholder="Email" |
||||
|
/> |
||||
|
</fieldset> |
||||
|
<fieldset class="form-group"> |
||||
|
<input |
||||
|
class="form-control form-control-lg" |
||||
|
type="password" |
||||
|
v-model="currentUser.password" |
||||
|
placeholder="Password" |
||||
|
/> |
||||
|
</fieldset> |
||||
|
<button class="btn btn-lg btn-primary pull-xs-right"> |
||||
|
Update Settings |
||||
|
</button> |
||||
|
</fieldset> |
||||
|
</form> |
||||
|
<!-- Line break for logout button --> |
||||
|
<hr /> |
||||
|
<button @click="logout" class="btn btn-outline-danger"> |
||||
|
Or click here to logout. |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
import { mapGetters } from "vuex"; |
||||
|
import { LOGOUT, UPDATE_USER } from "@/store/actions.type"; |
||||
|
|
||||
|
export default { |
||||
|
name: "RwvSettings", |
||||
|
computed: { |
||||
|
...mapGetters(["currentUser"]) |
||||
|
}, |
||||
|
methods: { |
||||
|
updateSettings() { |
||||
|
this.$store.dispatch(UPDATE_USER, this.currentUser).then(() => { |
||||
|
// #todo, nice toast and no redirect |
||||
|
this.$router.push({ name: "home" }); |
||||
|
}); |
||||
|
}, |
||||
|
logout() { |
||||
|
this.$store.dispatch(LOGOUT).then(() => { |
||||
|
this.$router.push({ name: "home" }); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
</script> |
||||
|
After Width: 1669 | Height: 257 | Size: 36 KiB |
@ -0,0 +1,5 @@ |
|||||
|
module.exports = { |
||||
|
env: { |
||||
|
jest: true |
||||
|
} |
||||
|
}; |
||||
@ -0,0 +1,47 @@ |
|||||
|
import { mount } from "@vue/test-utils"; |
||||
|
|
||||
|
import ListErrors from "../../../src/components/ListErrors.vue"; |
||||
|
|
||||
|
const createWrapper = ({ errors }) => { |
||||
|
return mount(ListErrors, { |
||||
|
propsData: { |
||||
|
errors |
||||
|
} |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
describe("ListErrors", () => { |
||||
|
let errors; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
errors = { |
||||
|
title: ["Title Error"], |
||||
|
body: ["can't be blank"], |
||||
|
description: ["can't be blank"] |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
it("should display the correct error messages based on object from props", () => { |
||||
|
const wrapper = createWrapper({ errors }); |
||||
|
|
||||
|
const errorMessages = wrapper.findAll("li"); |
||||
|
expect(errorMessages.length).toEqual(3); |
||||
|
expect(errorMessages.at(0).text()).toContain(errors.title); |
||||
|
expect(errorMessages.at(1).text()).toContain(errors.body); |
||||
|
expect(errorMessages.at(2).text()).toContain(errors.description); |
||||
|
}); |
||||
|
|
||||
|
it("should have props with errors as type object", () => { |
||||
|
const wrapper = createWrapper({ errors }); |
||||
|
expect(typeof wrapper.props().errors).toBe("object"); |
||||
|
}); |
||||
|
|
||||
|
it("should have no errors if no errors are passed into the props", () => { |
||||
|
errors = {}; |
||||
|
|
||||
|
const wrapper = createWrapper({ errors }); |
||||
|
|
||||
|
const errorMessages = wrapper.findAll("li"); |
||||
|
expect(errorMessages.length).toEqual(0); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,34 @@ |
|||||
|
import { mount } from "@vue/test-utils"; |
||||
|
|
||||
|
import VPagination from "../../../src/components/VPagination.vue"; |
||||
|
|
||||
|
const createWrapper = ({ currentPage = 1 }) => { |
||||
|
return mount(VPagination, { |
||||
|
propsData: { |
||||
|
pages: [1, 2, 3, 4], |
||||
|
currentPage |
||||
|
} |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
describe("VPagination", () => { |
||||
|
it("should render active class to right element", () => { |
||||
|
const wrapper = createWrapper({ currentPage: 2 }); |
||||
|
const activeItem = wrapper.find(".active"); |
||||
|
expect(activeItem.text()).toBe("2"); |
||||
|
}); |
||||
|
|
||||
|
it("should emit an event if page is clicked which is not active", () => { |
||||
|
const wrapper = createWrapper({ currentPage: 1 }); |
||||
|
const pageItem = wrapper.find('[data-test="page-link-2"]'); |
||||
|
pageItem.trigger("click"); |
||||
|
expect(wrapper.emitted("update:currentPage")).toBeTruthy(); |
||||
|
}); |
||||
|
|
||||
|
it("should have the right payload when event is emitted", () => { |
||||
|
const wrapper = createWrapper({ currentPage: 1 }); |
||||
|
const pageItem = wrapper.find('[data-test="page-link-2"]'); |
||||
|
pageItem.trigger("click"); |
||||
|
expect(wrapper.emitted("update:currentPage")[0][0]).toBe(2); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,25 @@ |
|||||
|
import { mount, createLocalVue } from "@vue/test-utils"; |
||||
|
|
||||
|
import router from "../../../src/router/index"; |
||||
|
import VTag from "../../../src/components/VTag"; |
||||
|
|
||||
|
const localVue = createLocalVue(); |
||||
|
const createWrapper = () => { |
||||
|
return mount(VTag, { |
||||
|
localVue, |
||||
|
router, |
||||
|
propsData: { |
||||
|
name: "Foo" |
||||
|
} |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
describe("VTag", () => { |
||||
|
it("should update the route on click", async () => { |
||||
|
const wrapper = createWrapper(); |
||||
|
const routerBefore = wrapper.vm.$route.path; |
||||
|
wrapper.find("a").trigger("click"); |
||||
|
await localVue.nextTick(); |
||||
|
expect(wrapper.vm.$route.path).not.toBe(routerBefore); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,51 @@ |
|||||
|
import { createLocalVue, mount } from "@vue/test-utils"; |
||||
|
import Vuex from "vuex"; |
||||
|
import VueRouter from "vue-router"; |
||||
|
|
||||
|
import Comment from "../../src/components/Comment.vue"; |
||||
|
import DateFilter from "../../src/common/date.filter"; |
||||
|
|
||||
|
const localVue = createLocalVue(); |
||||
|
localVue.filter("date", DateFilter); |
||||
|
localVue.use(Vuex); |
||||
|
localVue.use(VueRouter); |
||||
|
|
||||
|
describe("Comment", () => { |
||||
|
it("should render correct contents", () => { |
||||
|
const router = new VueRouter({ |
||||
|
routes: [ |
||||
|
{ |
||||
|
name: "profile", |
||||
|
path: "/profile", |
||||
|
component: null |
||||
|
} |
||||
|
] |
||||
|
}); |
||||
|
let store = new Vuex.Store({ |
||||
|
getters: { |
||||
|
currentUser: () => ({ |
||||
|
username: "user-3518518" |
||||
|
}) |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
const wrapper = mount(Comment, { |
||||
|
localVue, |
||||
|
store, |
||||
|
router, |
||||
|
propsData: { |
||||
|
slug: "super-cool-comment-slug-1245781274", |
||||
|
comment: { |
||||
|
body: "body of comment", |
||||
|
author: { |
||||
|
image: "https://vuejs.org/images/logo.png", |
||||
|
username: "user-3518518" |
||||
|
}, |
||||
|
createdAt: "", |
||||
|
id: 1245781274 |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
expect(wrapper.isVueInstance()).toBeTruthy(); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,18 @@ |
|||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP |
||||
|
|
||||
|
exports[`Vuex Article Module should return the data of the api call when calling the function 1`] = ` |
||||
|
Object { |
||||
|
"article": Object { |
||||
|
"author": Object {}, |
||||
|
"body": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed dictum efficitur justo, nec aliquam quam rutrum in. Pellentesque vulputate augue quis vulputate finibus. Phasellus auctor semper sapien sit amet interdum. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Maecenas placerat auctor metus. Integer blandit lacinia volutpat.", |
||||
|
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur sed cursus nisl. Morbi pulvinar nisl urna, tincidunt mattis tortor sollicitudin eget. Nulla viverra justo quis.", |
||||
|
"tagList": Array [ |
||||
|
"lorem", |
||||
|
"ipsum", |
||||
|
"javascript", |
||||
|
"vue", |
||||
|
], |
||||
|
"title": "Lorem ipsum dolor sit amet", |
||||
|
}, |
||||
|
} |
||||
|
`; |
||||
@ -0,0 +1,260 @@ |
|||||
|
import { actions } from "../../../src/store/article.module"; |
||||
|
import { |
||||
|
FETCH_ARTICLE, |
||||
|
FETCH_COMMENTS, |
||||
|
COMMENT_CREATE, |
||||
|
COMMENT_DESTROY, |
||||
|
FAVORITE_ADD, |
||||
|
FAVORITE_REMOVE |
||||
|
} from "../../../src/store/actions.type"; |
||||
|
|
||||
|
jest.mock("vue", () => { |
||||
|
return { |
||||
|
axios: { |
||||
|
get: jest.fn().mockImplementation(async articleSlug => { |
||||
|
if (articleSlug.includes("8371b051-cffc-4ff0-887c-2c477615a28e")) { |
||||
|
return { |
||||
|
data: { |
||||
|
article: { |
||||
|
author: {}, |
||||
|
title: "Lorem ipsum dolor sit amet", |
||||
|
description: |
||||
|
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur sed cursus nisl. Morbi pulvinar nisl urna, tincidunt mattis tortor sollicitudin eget. Nulla viverra justo quis.", |
||||
|
body: |
||||
|
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed dictum efficitur justo, nec aliquam quam rutrum in. Pellentesque vulputate augue quis vulputate finibus. Phasellus auctor semper sapien sit amet interdum. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Maecenas placerat auctor metus. Integer blandit lacinia volutpat.", |
||||
|
tagList: ["lorem", "ipsum", "javascript", "vue"] |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
} |
||||
|
if (articleSlug.includes("f986b3d6-95c2-4c4f-a6b9-fbbf79d8cb0c")) { |
||||
|
return { |
||||
|
data: { |
||||
|
comments: [ |
||||
|
{ |
||||
|
id: 1, |
||||
|
createdAt: "2018-12-01T15:43:41.235Z", |
||||
|
updatedAt: "2018-12-01T15:43:41.235Z", |
||||
|
body: "Lorem ipsum dolor sit amet.", |
||||
|
author: { |
||||
|
username: "dccf649a-5e7b-4040-b8c3-ecf74598eba2", |
||||
|
bio: null, |
||||
|
image: "https://via.placeholder.com/350x150", |
||||
|
following: false |
||||
|
} |
||||
|
}, |
||||
|
{ |
||||
|
id: 2, |
||||
|
createdAt: "2018-12-01T15:43:39.077Z", |
||||
|
updatedAt: "2018-12-01T15:43:39.077Z", |
||||
|
body: |
||||
|
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse aliquet.", |
||||
|
author: { |
||||
|
username: "8568a50a-9656-4d55-a023-632029513a2d", |
||||
|
bio: null, |
||||
|
image: "https://via.placeholder.com/350x150", |
||||
|
following: false |
||||
|
} |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
}; |
||||
|
} |
||||
|
throw new Error("Article not existing"); |
||||
|
}), |
||||
|
post: jest.fn().mockImplementation(async articleSlug => { |
||||
|
if (articleSlug.includes("582e1e46-6b8b-4f4d-8848-f07b57e015a0")) { |
||||
|
return null; |
||||
|
} |
||||
|
if (articleSlug.includes("5611ee1b-0b95-417f-a917-86687176a627")) { |
||||
|
return { |
||||
|
data: { |
||||
|
article: { |
||||
|
author: {}, |
||||
|
title: "Lorem ipsum dolor sit amet", |
||||
|
description: |
||||
|
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur sed cursus nisl. Morbi pulvinar nisl urna, tincidunt mattis tortor sollicitudin eget. Nulla viverra justo quis.", |
||||
|
body: |
||||
|
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed dictum efficitur justo, nec aliquam quam rutrum in. Pellentesque vulputate augue quis vulputate finibus. Phasellus auctor semper sapien sit amet interdum. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Maecenas placerat auctor metus. Integer blandit lacinia volutpat.", |
||||
|
tagList: ["lorem", "ipsum", "javascript", "vue"] |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
} |
||||
|
throw new Error("Article not existing"); |
||||
|
}), |
||||
|
delete: jest.fn().mockImplementation(async articleSlug => { |
||||
|
if (articleSlug.includes("657a6075-d269-4aec-83fa-b14f579a3e78")) { |
||||
|
return null; |
||||
|
} |
||||
|
if (articleSlug.includes("480fdaf8-027c-43b1-8952-8403f90dcdab")) { |
||||
|
return { |
||||
|
data: { |
||||
|
article: { |
||||
|
author: {}, |
||||
|
title: "Lorem ipsum dolor sit amet", |
||||
|
description: |
||||
|
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur sed cursus nisl. Morbi pulvinar nisl urna, tincidunt mattis tortor sollicitudin eget. Nulla viverra justo quis.", |
||||
|
body: |
||||
|
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed dictum efficitur justo, nec aliquam quam rutrum in. Pellentesque vulputate augue quis vulputate finibus. Phasellus auctor semper sapien sit amet interdum. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Maecenas placerat auctor metus. Integer blandit lacinia volutpat.", |
||||
|
tagList: ["lorem", "ipsum", "javascript", "vue"] |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
} |
||||
|
throw new Error("Article not existing"); |
||||
|
}) |
||||
|
} |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
describe("Vuex Article Module", () => { |
||||
|
it("should commit the previous article if it is given", async () => { |
||||
|
const commitFunction = jest.fn(); |
||||
|
const context = { commit: commitFunction }; |
||||
|
const articleSlug = "8371b051-cffc-4ff0-887c-2c477615a28e"; |
||||
|
const prevArticle = { |
||||
|
author: {}, |
||||
|
title: "Aye up, she's a reight bobby dazzler", |
||||
|
description: |
||||
|
"Yer flummoxed. Fair t' middlin, this is. Off f'r a sup down t'pub, to'neet. Ee bye ecky thump!", |
||||
|
body: |
||||
|
"Tha's better bi careful, lass - yer on a Scarborough warning! Tha meks a better door than a winder. Do I 'eckers like, You're in luck m'boy! Am proper knackered, aye I am that is I say.", |
||||
|
tagList: ["aye", "ipsum", "javascript", "vue"] |
||||
|
}; |
||||
|
await actions[FETCH_ARTICLE](context, articleSlug, prevArticle); |
||||
|
expect(commitFunction.mock.calls[0][0]).toBe("setArticle"); |
||||
|
expect(commitFunction.mock.calls[0][1]).toBe(prevArticle); |
||||
|
}); |
||||
|
|
||||
|
it("should return the data of the api call when calling the function", async () => { |
||||
|
const context = { commit: () => {} }; |
||||
|
const articleSlug = "8371b051-cffc-4ff0-887c-2c477615a28e"; |
||||
|
const prevArticle = undefined; |
||||
|
const actionCall = await actions[FETCH_ARTICLE]( |
||||
|
context, |
||||
|
articleSlug, |
||||
|
prevArticle |
||||
|
); |
||||
|
expect(actionCall).toMatchSnapshot(); |
||||
|
}); |
||||
|
|
||||
|
it("should commit the right name when fetching comments for an existing article", async () => { |
||||
|
const commitFunction = jest.fn(); |
||||
|
const context = { commit: commitFunction }; |
||||
|
const articleSlug = "f986b3d6-95c2-4c4f-a6b9-fbbf79d8cb0c"; |
||||
|
await actions[FETCH_COMMENTS](context, articleSlug); |
||||
|
expect(commitFunction.mock.calls[0][0]).toBe("setComments"); |
||||
|
}); |
||||
|
|
||||
|
it("should commit the exact size of comments", async () => { |
||||
|
const commitFunction = jest.fn(); |
||||
|
const context = { commit: commitFunction }; |
||||
|
const articleSlug = "f986b3d6-95c2-4c4f-a6b9-fbbf79d8cb0c"; |
||||
|
await actions[FETCH_COMMENTS](context, articleSlug); |
||||
|
expect(commitFunction.mock.calls[0][1]).toHaveLength(2); |
||||
|
}); |
||||
|
|
||||
|
it("should return the comments from the fetch comments action", async () => { |
||||
|
const context = { commit: () => {} }; |
||||
|
const articleSlug = "f986b3d6-95c2-4c4f-a6b9-fbbf79d8cb0c"; |
||||
|
const comments = await actions[FETCH_COMMENTS](context, articleSlug); |
||||
|
expect(comments).toHaveLength(2); |
||||
|
}); |
||||
|
|
||||
|
it("should dispatch a fetching comment action after successfully creating a comment", async () => { |
||||
|
const dispatchFunction = jest.fn(); |
||||
|
const context = { dispatch: dispatchFunction }; |
||||
|
const payload = { |
||||
|
slug: "582e1e46-6b8b-4f4d-8848-f07b57e015a0", |
||||
|
comment: "Lorem Ipsum" |
||||
|
}; |
||||
|
await actions[COMMENT_CREATE](context, payload); |
||||
|
expect(dispatchFunction).toHaveBeenLastCalledWith( |
||||
|
"fetchComments", |
||||
|
"582e1e46-6b8b-4f4d-8848-f07b57e015a0" |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
it("should dispatch a fetching comment action after successfully deleting a comment", async () => { |
||||
|
const dispatchFunction = jest.fn(); |
||||
|
const context = { dispatch: dispatchFunction }; |
||||
|
const payload = { |
||||
|
slug: "657a6075-d269-4aec-83fa-b14f579a3e78", |
||||
|
commentId: 1 |
||||
|
}; |
||||
|
await actions[COMMENT_DESTROY](context, payload); |
||||
|
expect(dispatchFunction).toHaveBeenLastCalledWith( |
||||
|
"fetchComments", |
||||
|
"657a6075-d269-4aec-83fa-b14f579a3e78" |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
it("should commit updating the article in the list action favorize an article", async () => { |
||||
|
const commitFunction = jest.fn(); |
||||
|
const context = { commit: commitFunction }; |
||||
|
const payload = "5611ee1b-0b95-417f-a917-86687176a627"; |
||||
|
await actions[FAVORITE_ADD](context, payload); |
||||
|
expect(commitFunction.mock.calls[0][0]).toBe("updateArticleInList"); |
||||
|
expect(commitFunction.mock.calls[0][1]).toEqual({ |
||||
|
author: {}, |
||||
|
title: "Lorem ipsum dolor sit amet", |
||||
|
description: |
||||
|
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur sed cursus nisl. Morbi pulvinar nisl urna, tincidunt mattis tortor sollicitudin eget. Nulla viverra justo quis.", |
||||
|
body: |
||||
|
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed dictum efficitur justo, nec aliquam quam rutrum in. Pellentesque vulputate augue quis vulputate finibus. Phasellus auctor semper sapien sit amet interdum. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Maecenas placerat auctor metus. Integer blandit lacinia volutpat.", |
||||
|
tagList: ["lorem", "ipsum", "javascript", "vue"] |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it("should commit setting the article", async () => { |
||||
|
const commitFunction = jest.fn(); |
||||
|
const context = { commit: commitFunction }; |
||||
|
const payload = "5611ee1b-0b95-417f-a917-86687176a627"; |
||||
|
await actions[FAVORITE_ADD](context, payload); |
||||
|
expect(commitFunction.mock.calls[1][0]).toBe("setArticle"); |
||||
|
expect(commitFunction.mock.calls[1][1]).toEqual({ |
||||
|
author: {}, |
||||
|
title: "Lorem ipsum dolor sit amet", |
||||
|
description: |
||||
|
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur sed cursus nisl. Morbi pulvinar nisl urna, tincidunt mattis tortor sollicitudin eget. Nulla viverra justo quis.", |
||||
|
body: |
||||
|
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed dictum efficitur justo, nec aliquam quam rutrum in. Pellentesque vulputate augue quis vulputate finibus. Phasellus auctor semper sapien sit amet interdum. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Maecenas placerat auctor metus. Integer blandit lacinia volutpat.", |
||||
|
tagList: ["lorem", "ipsum", "javascript", "vue"] |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it("should commit updating the article in the list action favorize an article", async () => { |
||||
|
const commitFunction = jest.fn(); |
||||
|
const context = { commit: commitFunction }; |
||||
|
const payload = "480fdaf8-027c-43b1-8952-8403f90dcdab"; |
||||
|
await actions[FAVORITE_REMOVE](context, payload); |
||||
|
expect(commitFunction.mock.calls[0][0]).toBe("updateArticleInList"); |
||||
|
expect(commitFunction.mock.calls[0][1]).toEqual({ |
||||
|
author: {}, |
||||
|
title: "Lorem ipsum dolor sit amet", |
||||
|
description: |
||||
|
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur sed cursus nisl. Morbi pulvinar nisl urna, tincidunt mattis tortor sollicitudin eget. Nulla viverra justo quis.", |
||||
|
body: |
||||
|
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed dictum efficitur justo, nec aliquam quam rutrum in. Pellentesque vulputate augue quis vulputate finibus. Phasellus auctor semper sapien sit amet interdum. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Maecenas placerat auctor metus. Integer blandit lacinia volutpat.", |
||||
|
tagList: ["lorem", "ipsum", "javascript", "vue"] |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it("should commit setting the article", async () => { |
||||
|
const commitFunction = jest.fn(); |
||||
|
const context = { commit: commitFunction }; |
||||
|
const payload = "480fdaf8-027c-43b1-8952-8403f90dcdab"; |
||||
|
await actions[FAVORITE_REMOVE](context, payload); |
||||
|
expect(commitFunction.mock.calls[1][0]).toBe("setArticle"); |
||||
|
expect(commitFunction.mock.calls[1][1]).toEqual({ |
||||
|
author: {}, |
||||
|
title: "Lorem ipsum dolor sit amet", |
||||
|
description: |
||||
|
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur sed cursus nisl. Morbi pulvinar nisl urna, tincidunt mattis tortor sollicitudin eget. Nulla viverra justo quis.", |
||||
|
body: |
||||
|
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed dictum efficitur justo, nec aliquam quam rutrum in. Pellentesque vulputate augue quis vulputate finibus. Phasellus auctor semper sapien sit amet interdum. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Maecenas placerat auctor metus. Integer blandit lacinia volutpat.", |
||||
|
tagList: ["lorem", "ipsum", "javascript", "vue"] |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||