├── .eslintrc
├── .gitignore
├── .lintstagedrc
├── LICENSE
├── README.md
├── index.html
├── package.json
├── public
└── favicon.ico
├── server-dev.js
├── server-prod.js
├── src
├── App.vue
├── components
│ ├── AppFooter.vue
│ ├── AppLink.vue
│ ├── AppNavigation.vue
│ ├── AppPagination.vue
│ ├── ArticleDetail.vue
│ ├── ArticleDetailComment.vue
│ ├── ArticleDetailComments.vue
│ ├── ArticleDetailCommentsForm.vue
│ ├── ArticleDetailMeta.vue
│ ├── ArticlesList.vue
│ ├── ArticlesListArticlePreview.vue
│ ├── ArticlesListNavigation.vue
│ └── PopularTags.vue
├── composable
│ ├── useArticles.ts
│ ├── useFavoriteArticle.ts
│ ├── useFollowProfile.ts
│ ├── useProfile.ts
│ └── useTags.ts
├── config.ts
├── entry-client.ts
├── entry-server.ts
├── pages
│ ├── Article.vue
│ ├── EditArticle.vue
│ ├── Home.vue
│ ├── Login.vue
│ ├── Profile.vue
│ ├── Register.vue
│ └── Settings.vue
├── plugins
│ ├── global-components.ts
│ ├── marked.ts
│ └── set-authorization-token.ts
├── router.ts
├── services
│ ├── article
│ │ ├── deleteArticle.ts
│ │ ├── favoriteArticle.ts
│ │ ├── getArticle.ts
│ │ ├── getArticles.ts
│ │ └── postArticle.ts
│ ├── auth
│ │ ├── postLogin.ts
│ │ └── postRegister.ts
│ ├── comment
│ │ ├── getComments.ts
│ │ └── postComment.ts
│ ├── index.ts
│ ├── profile
│ │ ├── followProfile.ts
│ │ ├── getProfile.ts
│ │ └── putProfile.ts
│ └── tag
│ │ └── getTags.ts
├── shimes-vue.d.ts
├── store
│ ├── init.ts
│ └── user.ts
├── types
│ ├── app-routes.d.ts
│ ├── article.d.ts
│ ├── comment.d.ts
│ ├── error.ts
│ ├── global.d.ts
│ ├── response.d.ts
│ └── user.d.ts
└── utils
│ ├── cookie.ts
│ ├── create-async-process.ts
│ ├── either.ts
│ ├── filters.ts
│ ├── map-checkable-response.ts
│ ├── params-to-query.ts
│ ├── request.ts
│ └── storage.ts
├── tsconfig.json
├── vite.config.js
└── yarn.lock
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "parser": "vue-eslint-parser",
4 | "parserOptions": {
5 | "parser": "@typescript-eslint/parser",
6 | "project": "./tsconfig.json",
7 | "sourceType": "module",
8 | "extraFileExtensions": [".vue", ".d.ts"]
9 | },
10 | "extends": [
11 | "standard-with-typescript",
12 | "plugin:@typescript-eslint/eslint-recommended",
13 | "plugin:@typescript-eslint/recommended",
14 | "plugin:vue/vue3-recommended"
15 | ],
16 | "rules": {
17 | "no-undef": "off",
18 | "no-unused-vars": "off",
19 | "comma-dangle": ["warn", "always-multiline"],
20 | "@typescript-eslint/promise-function-async": "off",
21 | "@typescript-eslint/strict-boolean-expressions": "off",
22 | "@typescript-eslint/no-unused-vars": "off"
23 | },
24 | "overrides": [
25 | {
26 | "files": [ "src/**/*.spec.ts" ],
27 | "rules": {
28 | "@typescript-eslint/no-non-null-assertion": "off",
29 | "@typescript-eslint/no-unnecessary-type-assertion": "off",
30 | "@typescript-eslint/no-explicit-any": "off"
31 | }
32 | }
33 | ]
34 | }
35 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .DS_Store
3 | dist
4 | *.local
5 | .idea
6 | *.iml
7 | cypress/videos
8 | cypress/screenshots
9 | coverage
10 | .vscode
11 | yarn-error.log
12 | .husky
13 | .clinic
--------------------------------------------------------------------------------
/.lintstagedrc:
--------------------------------------------------------------------------------
1 | {
2 | "src/**/*.{ts,vue}": "eslint --fix"
3 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Dongsen
4 | Copyright (c) 2021 levchak0910
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the "Software"), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is
11 | furnished to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in all
14 | copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | SOFTWARE.
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Vue 3 Realword app with Server Side Sendering (SSR)
2 |
3 | This is an experiment with a try to clone the [original repo](https://github.com/mutoe/vue3-realworld-example-app) and add SSR using latest [Vite2](https://github.com/vitejs/vite) features
4 |
5 |
6 |
7 | # What works?
8 |
9 | - [x] [Vite](https://github.com/vitejs/vite)
10 | - [x] [Composition API](https://composition-api.vuejs.org/)
11 | - [x] [Suspense](https://v3.vuejs.org/guide/component-dynamic-async.html#using-with-suspense)
12 | - [x] [TypeScript](https://www.typescriptlang.org/)
13 | - [x] [ESLint](https://eslint.vuejs.org/)
14 | - [x] [Vue router](https://next.router.vuejs.org/)
15 | - [x] [Harlem](https://github.com/andrewcourtice/harlem)
16 | - [x] Vetur Tools: [VTI](https://vuejs.github.io/vetur/guide/vti.html) and [Interpolation](https://vuejs.github.io/vetur/guide/interpolation.html)
17 | - [x] [SSR](https://v3.vuejs.org/guide/ssr/introduction.html) with [Vite2 HMR](https://vitejs.dev/guide/ssr.html)
18 |
19 | ## Getting started
20 |
21 | ```shell script
22 | git clone https://github.com/levchak0910/vue3-ssr-realworld-example-app.git
23 | cd vue3-ssr-realworld-example-app
24 | yarn install
25 | yarn build
26 | yarn serve
27 | ```
28 |
29 | ### For development
30 | ```shell script
31 | yarn dev
32 | ```
33 |
34 | ### Test performance
35 | ```shell script
36 | yarn perf
37 | ```
38 |
39 | ## Acknowledges
40 |
41 | - [@mutoe](https://github.com/mutoe) and [contributors](https://github.com/mutoe/vue3-realworld-example-app#contributors) - for original repo
42 | - [@andrewcourtice](https://github.com/andrewcourtice) - for [state manager](https://github.com/andrewcourtice/harlem) with [ssr support](https://github.com/andrewcourtice/harlem/blob/main/plugins/ssr)
43 | - [@tbgse](https://github.com/tbgse) - for [example](https://github.com/tbgse/vue3-vite-ssr-example) how to use vite for creating ssr bundles
44 | - [@yyx990803](https://github.com/yyx990803) - for another [example](https://github.com/vitejs/vite/tree/main/packages/playground/ssr-vue) how to use vite2 for creating ssr apps
45 |
46 | ## Vue related implementations of the Realworld app
47 | [gothinkster/vue-realworld-example-app](https://github.com/gothinkster/vue-realworld-example-app) - vue2, js
48 | [AlexBrohshtut/vue-ts-realworld-app](https://github.com/AlexBrohshtut/vue-ts-realworld-app) - vue2, ts, class-component
49 | [devJang/nuxt-realworld](https://github.com/devJang/nuxt-realworld) - nuxt, ts, composition api
50 | [mutoe/vue3-realworld-example-app](https://github.com/mutoe/vue3-realworld-example-app) - vue3, vite, ts, composition api
51 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Conduit
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vue3-ssr-realworld-example-app",
3 | "version": "1.1.0",
4 | "license": "MIT",
5 | "private": true,
6 | "scripts": {
7 | "build": "yarn build:client && yarn build:server",
8 | "build:client": "vite build --ssrManifest --outDir dist/client",
9 | "build:server": "vite build --ssr src/entry-server --outDir dist/server",
10 | "dev": "node server-dev.js",
11 | "serve": "node server-prod.js",
12 | "check-ts-errors": "yarn tsc --noEmit && yarn vti diagnostics",
13 | "lint": "eslint \"src/**/*.{js,ts,vue}\"",
14 | "test": "yarn check-ts-errors && yarn lint",
15 | "perf": "yarn build && clinic doctor --on-port=\"sleep 5 && autocannon http://localhost:8080 -a 500 -c 50 && sleep 5 && autocannon http://localhost:8080 -a 10 -c 2 && sleep 5\" -- node server-prod.js",
16 | "prepare": "husky install"
17 | },
18 | "dependencies": {
19 | "@harlem/core": "^1.3.1",
20 | "@harlem/plugin-ssr": "^1.3.1",
21 | "@vue/server-renderer": "3.2.27",
22 | "clinic": "^11.0.0",
23 | "cross-fetch": "^3.1.4",
24 | "insane": "^2.6.2",
25 | "js-cookie": "^2.2.1",
26 | "koa": "^2.13.1",
27 | "koa-connect": "^2.1.0",
28 | "koa-send": "^5.0.1",
29 | "marked": "^2.0.3",
30 | "vue": "3.2.27",
31 | "vue-router": "4.0.6"
32 | },
33 | "devDependencies": {
34 | "@babel/core": "^7.13.16",
35 | "@types/js-cookie": "^2.2.6",
36 | "@types/koa-send": "^4.1.2",
37 | "@types/marked": "^2.0.2",
38 | "@typescript-eslint/eslint-plugin": "^4.22.0",
39 | "@typescript-eslint/parser": "^4.22.0",
40 | "@vitejs/plugin-vue": "^1.2.2",
41 | "@vue/compiler-sfc": "3.2.27",
42 | "eslint": "^7.25.0",
43 | "eslint-config-standard-with-typescript": "^20.0.0",
44 | "eslint-plugin-import": "^2.22.1",
45 | "eslint-plugin-node": "^11.1.0",
46 | "eslint-plugin-promise": "^5.1.0",
47 | "eslint-plugin-vue": "^7.9.0",
48 | "husky": "^6.0.0",
49 | "lint-staged": "^10.5.4",
50 | "rollup-plugin-analyzer": "^4.0.0",
51 | "typescript": "^4.2.4",
52 | "vite": "^2.2.3",
53 | "vti": "^0.1.1"
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/levchak0910/vue3-ssr-realworld-example-app/49bf4737b5921d247fe0cacf94771d77aaa936d9/public/favicon.ico
--------------------------------------------------------------------------------
/server-dev.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-var-requires */
2 |
3 | const fs = require('fs')
4 | const path = require('path')
5 |
6 | const Koa = require('koa')
7 | const koaConnect = require('koa-connect')
8 |
9 | const vite = require('vite')
10 |
11 | const root = process.cwd()
12 | const resolve = (p) => path.resolve(__dirname, p)
13 |
14 | ;(async () => {
15 | const app = new Koa()
16 |
17 | const viteServer = await vite.createServer({
18 | root,
19 | logLevel: 'error',
20 | server: {
21 | middlewareMode: true,
22 | },
23 | })
24 | app.use(koaConnect(viteServer.middlewares))
25 |
26 | app.use(async ctx => {
27 | try {
28 | let template = fs.readFileSync(resolve('index.html'), 'utf-8')
29 | template = await viteServer.transformIndexHtml(ctx.path, template)
30 | const { render } = await viteServer.ssrLoadModule('/src/entry-server.ts')
31 |
32 | const [appHtml] = await render(ctx, {})
33 |
34 | const html = template.replace('', appHtml)
35 |
36 | ctx.type = 'text/html'
37 | ctx.body = html
38 | } catch (e) {
39 | viteServer && viteServer.ssrFixStacktrace(e)
40 | console.log(e.stack)
41 | ctx.throw(500, e.stack)
42 | }
43 | })
44 |
45 | app.listen(3000, () => console.log('http://localhost:3000'))
46 | })()
47 |
--------------------------------------------------------------------------------
/server-prod.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-var-requires */
2 |
3 | const Koa = require('koa')
4 | const sendFile = require('koa-send')
5 |
6 | const path = require('path')
7 | const fs = require('fs')
8 |
9 | const resolve = (p) => path.resolve(__dirname, p)
10 |
11 | const clientRoot = resolve('dist/client')
12 | const template = fs.readFileSync(resolve('dist/client/index.html'), 'utf-8')
13 | const render = require('./dist/server/entry-server.js').render
14 | const manifest = require('./dist/client/ssr-manifest.json')
15 |
16 | ;(async () => {
17 | const app = new Koa()
18 |
19 | app.use(async (ctx) => {
20 | // send static file
21 | if (ctx.path.startsWith('/assets')) {
22 | await sendFile(ctx, ctx.path, { root: clientRoot })
23 | return
24 | }
25 |
26 | const [appHtml, preloadLinks] = await render(ctx, manifest)
27 |
28 | const html = template
29 | .replace('', preloadLinks)
30 | .replace('', appHtml)
31 |
32 | ctx.type = 'text/html'
33 | ctx.body = html
34 | })
35 |
36 | app.listen(8080, () => console.log('started server on http://localhost:8080'))
37 | })()
38 |
--------------------------------------------------------------------------------
/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
20 |
--------------------------------------------------------------------------------
/src/components/AppFooter.vue:
--------------------------------------------------------------------------------
1 |
2 |
23 |
24 |
25 |
32 |
--------------------------------------------------------------------------------
/src/components/AppLink.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 |
10 |
11 |
42 |
--------------------------------------------------------------------------------
/src/components/AppNavigation.vue:
--------------------------------------------------------------------------------
1 |
2 |
32 |
33 |
34 |
100 |
--------------------------------------------------------------------------------
/src/components/AppPagination.vue:
--------------------------------------------------------------------------------
1 |
2 |
15 |
16 |
17 |
45 |
--------------------------------------------------------------------------------
/src/components/ArticleDetail.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
{{ article.title }}
5 |
6 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
20 |
21 |
22 |
23 |
24 | -
29 | {{ tag }}
30 |
31 |
32 |
33 |
34 |
35 |
36 |
42 |
43 |
44 |
45 |
77 |
--------------------------------------------------------------------------------
/src/components/ArticleDetailComment.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{ comment.body }}
6 |
7 |
8 |
9 |
43 |
44 |
45 |
46 |
67 |
--------------------------------------------------------------------------------
/src/components/ArticleDetailComments.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 | removeComment(comment.id)"
13 | />
14 |
15 |
16 |
63 |
--------------------------------------------------------------------------------
/src/components/ArticleDetailCommentsForm.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Sign in
5 | or
6 | sign up
7 | to add comments on this article.
8 |
9 |
38 |
39 |
40 |
77 |
--------------------------------------------------------------------------------
/src/components/ArticleDetailMeta.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 |
9 |
10 |
11 |
16 | {{ article.author.username }}
17 |
18 |
19 |
{{ (new Date(article.createdAt)).toLocaleDateString() }}
20 |
21 |
22 |
32 |
33 |
44 |
45 |
52 | Edit Article
53 |
54 |
55 |
63 |
64 |
65 |
66 |
124 |
125 |
130 |
--------------------------------------------------------------------------------
/src/components/ArticlesList.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
12 | Articles are downloading...
13 |
14 |
18 | No articles are here... yet.
19 |
20 |
21 | updateArticle(index, newArticle)"
26 | />
27 |
28 |
33 |
34 |
35 |
36 |
81 |
--------------------------------------------------------------------------------
/src/components/ArticlesListArticlePreview.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
8 |
9 |
10 |
11 |
16 | {{ article.author.username }}
17 |
18 |
{{ new Date(article.createdAt).toDateString() }}
19 |
20 |
21 |
30 |
31 |
32 |
37 | {{ article.title }}
38 | {{ article.description }}
39 | Read more...
40 |
41 | -
46 | {{ tag }}
47 |
48 |
49 |
50 |
51 |
52 |
53 |
85 |
--------------------------------------------------------------------------------
/src/components/ArticlesListNavigation.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | -
9 |
15 | {{ link.title }}
19 |
20 |
21 |
22 |
23 |
24 |
25 |
101 |
--------------------------------------------------------------------------------
/src/components/PopularTags.vue:
--------------------------------------------------------------------------------
1 |
2 | Popular Tags
3 |
4 |
5 |
12 | {{ tag }}
13 |
14 |
15 |
16 |
17 |
34 |
--------------------------------------------------------------------------------
/src/composable/useArticles.ts:
--------------------------------------------------------------------------------
1 | import { computed, ComputedRef, ref, watch } from 'vue'
2 | import { useRoute } from 'vue-router'
3 |
4 | import createAsyncProcess from '../utils/create-async-process'
5 |
6 | import {
7 | getArticles,
8 | getFavoritedArticles,
9 | getProfileArticles,
10 | getFeeds,
11 | getArticlesByTag,
12 | } from '../services/article/getArticles'
13 |
14 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type
15 | export function useArticles () {
16 | const { articlesType, tag, username, metaChanged } = getArticlesMeta()
17 |
18 | const articles = ref([])
19 | const articlesCount = ref(0)
20 | const page = ref(1)
21 |
22 | async function fetchArticles (): Promise {
23 | articles.value = []
24 | let responsePromise: null | Promise = null
25 |
26 | if (articlesType.value === 'my-feed') {
27 | responsePromise = getFeeds(page.value)
28 | }
29 | if (articlesType.value === 'tag-feed' && tag.value !== undefined) {
30 | responsePromise = getArticlesByTag(tag.value, page.value)
31 | }
32 | if (articlesType.value === 'user-feed' && username.value !== undefined) {
33 | responsePromise = getProfileArticles(username.value, page.value)
34 | }
35 | if (articlesType.value === 'user-favorites-feed' && username.value !== undefined) {
36 | responsePromise = getFavoritedArticles(username.value, page.value)
37 | }
38 | if (articlesType.value === 'global-feed') {
39 | responsePromise = getArticles(page.value)
40 | }
41 |
42 | if (responsePromise !== null) {
43 | const response = await responsePromise
44 | articles.value = response.articles
45 | articlesCount.value = response.articlesCount
46 | } else {
47 | throw new Error(`Articles type "${articlesType.value}" not supported`)
48 | }
49 | }
50 |
51 | const changePage = (value: number): void => {
52 | page.value = value
53 | }
54 |
55 | const updateArticle = (index: number, article: Article): void => {
56 | articles.value[index] = article
57 | }
58 |
59 | const { active: articlesDownloading, run: runWrappedFetchArticles } = createAsyncProcess(fetchArticles)
60 |
61 | watch(metaChanged, async () => {
62 | if (page.value !== 1) changePage(1)
63 | else await runWrappedFetchArticles()
64 | })
65 |
66 | watch(page, runWrappedFetchArticles)
67 |
68 | return {
69 | fetchArticles: runWrappedFetchArticles,
70 | articlesDownloading,
71 | articles,
72 | articlesCount,
73 | page,
74 | changePage,
75 | updateArticle,
76 | tag,
77 | username,
78 | }
79 | }
80 |
81 | export type ArticlesType = 'global-feed' | 'my-feed' | 'tag-feed' | 'user-feed' | 'user-favorites-feed'
82 |
83 | export const articlesTypes: ArticlesType[] = ['global-feed', 'my-feed', 'tag-feed', 'user-feed', 'user-favorites-feed']
84 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
85 | export const isArticlesType = (type: any): type is ArticlesType => articlesTypes.includes(type)
86 |
87 | const routeNameToArticlesType: Partial> = ({
88 | 'global-feed': 'global-feed',
89 | 'my-feed': 'my-feed',
90 | tag: 'tag-feed',
91 | profile: 'user-feed',
92 | 'profile-favorites': 'user-favorites-feed',
93 | })
94 |
95 | interface GetArticlesMetaReturn {
96 | tag: ComputedRef
97 | username: ComputedRef
98 | articlesType: ComputedRef
99 | metaChanged: ComputedRef
100 | }
101 | function getArticlesMeta (): GetArticlesMetaReturn {
102 | const route = useRoute()
103 |
104 | const tag = ref('')
105 | const username = ref('')
106 | const articlesType = ref('global-feed')
107 |
108 | watch(
109 | () => route.name,
110 | routeName => {
111 | const possibleArticlesType = routeNameToArticlesType[routeName as AppRouteNames]
112 | if (!isArticlesType(possibleArticlesType)) return
113 |
114 | articlesType.value = possibleArticlesType
115 | },
116 | { immediate: true },
117 | )
118 |
119 | watch(
120 | () => route.params.username,
121 | usernameParam => {
122 | if (usernameParam !== username.value) {
123 | username.value = typeof usernameParam === 'string' ? usernameParam : ''
124 | }
125 | },
126 | { immediate: true },
127 | )
128 |
129 | watch(
130 | () => route.params.tag,
131 | tagParam => {
132 | if (tagParam !== tag.value) {
133 | tag.value = typeof tagParam === 'string' ? tagParam : ''
134 | }
135 | },
136 | { immediate: true },
137 | )
138 |
139 | return {
140 | tag: computed(() => tag.value),
141 | username: computed(() => username.value),
142 | articlesType: computed(() => articlesType.value),
143 | metaChanged: computed(() => `${articlesType.value}-${username.value}-${tag.value}`),
144 | }
145 | }
146 |
--------------------------------------------------------------------------------
/src/composable/useFavoriteArticle.ts:
--------------------------------------------------------------------------------
1 | import { ComputedRef } from 'vue'
2 | import { useRouter } from 'vue-router'
3 |
4 | import type { AuthorizationError } from '../types/error'
5 |
6 | import { deleteFavoriteArticle, postFavoriteArticle } from '../services/article/favoriteArticle'
7 |
8 | import type { Either } from '../utils/either'
9 | import createAsyncProcess from '../utils/create-async-process'
10 |
11 | interface useFavoriteArticleProps {
12 | isFavorited: ComputedRef
13 | articleSlug: ComputedRef
14 | onUpdate: (newArticle: Article) => void
15 | }
16 |
17 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type
18 | export const useFavoriteArticle = ({ isFavorited, articleSlug, onUpdate }: useFavoriteArticleProps) => {
19 | const router = useRouter()
20 |
21 | const favoriteArticle = async (): Promise => {
22 | let response: Either
23 | if (isFavorited.value) {
24 | response = await deleteFavoriteArticle(articleSlug.value)
25 | } else {
26 | response = await postFavoriteArticle(articleSlug.value)
27 | }
28 |
29 | if (response.isOk()) onUpdate(response.value)
30 | else await router.push({ name: 'login' })
31 | }
32 |
33 | const { active, run } = createAsyncProcess(favoriteArticle)
34 |
35 | return {
36 | favoriteProcessGoing: active,
37 | favoriteArticle: run,
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/composable/useFollowProfile.ts:
--------------------------------------------------------------------------------
1 | import type { ComputedRef } from 'vue'
2 | import { useRouter } from 'vue-router'
3 |
4 | import type { AuthorizationError } from '../types/error'
5 |
6 | import type { Either } from '../utils/either'
7 | import createAsyncProcess from '../utils/create-async-process'
8 |
9 | import { postFollowProfile, deleteFollowProfile } from '../services/profile/followProfile'
10 |
11 | interface UseFollowProps {
12 | username: ComputedRef
13 | following: ComputedRef
14 | onUpdate: (profile: Profile) => void
15 | }
16 |
17 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type
18 | export function useFollow ({ username, following, onUpdate }: UseFollowProps) {
19 | const router = useRouter()
20 |
21 | async function toggleFollow (): Promise {
22 | let response: Either
23 |
24 | if (following.value) {
25 | response = await deleteFollowProfile(username.value)
26 | } else {
27 | response = await postFollowProfile(username.value)
28 | }
29 |
30 | if (response.isOk()) onUpdate(response.value)
31 | else await router.push({ name: 'login' })
32 | }
33 |
34 | const { active, run } = createAsyncProcess(toggleFollow)
35 |
36 | return {
37 | followProcessGoing: active,
38 | toggleFollow: run,
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/composable/useProfile.ts:
--------------------------------------------------------------------------------
1 | import { ComputedRef, ref, watch } from 'vue'
2 |
3 | import { getProfile } from '../services/profile/getProfile'
4 |
5 | interface UseProfileProps {
6 | username: ComputedRef
7 | }
8 |
9 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type
10 | export function useProfile ({ username }: UseProfileProps) {
11 | const profile = ref(null)
12 |
13 | async function fetchProfile (): Promise {
14 | updateProfile(null)
15 | if (!username.value) return
16 | const profileData = await getProfile(username.value)
17 | updateProfile(profileData)
18 | }
19 |
20 | function updateProfile (profileData: Profile | null): void {
21 | profile.value = profileData
22 | }
23 |
24 | watch(username, fetchProfile, { immediate: true })
25 |
26 | return {
27 | profile,
28 | updateProfile,
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/composable/useTags.ts:
--------------------------------------------------------------------------------
1 | import { ref } from 'vue'
2 |
3 | import { getAllTags } from '../services/tag/getTags'
4 |
5 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type
6 | export function useTags () {
7 | const tags = ref([])
8 |
9 | async function fetchTags (): Promise {
10 | tags.value = []
11 | tags.value = await getAllTags()
12 | }
13 |
14 | return {
15 | fetchTags,
16 | tags,
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/config.ts:
--------------------------------------------------------------------------------
1 | export const CONFIG = {
2 | API_HOST: 'https://conduit.productionready.io',
3 | }
4 |
--------------------------------------------------------------------------------
/src/entry-client.ts:
--------------------------------------------------------------------------------
1 | import { createSSRApp } from 'vue'
2 |
3 | import { createRouter } from './router'
4 |
5 | import App from './App.vue'
6 |
7 | import Harlem from '@harlem/core'
8 | import { createClientSSRPlugin } from '@harlem/plugin-ssr'
9 |
10 | import registerGlobalComponents from './plugins/global-components'
11 | import setAuthorizationToken from './plugins/set-authorization-token'
12 |
13 | const router = createRouter('client')
14 |
15 | const app = createSSRApp(App)
16 | app.use(router)
17 |
18 | app.use(Harlem, {
19 | plugins: [createClientSSRPlugin()],
20 | })
21 |
22 | setAuthorizationToken()
23 | registerGlobalComponents(app)
24 |
25 | // eslint-disable-next-line @typescript-eslint/no-floating-promises
26 | router.isReady().then(() => {
27 | app.mount('#app', true)
28 | })
29 |
--------------------------------------------------------------------------------
/src/entry-server.ts:
--------------------------------------------------------------------------------
1 | import { createSSRApp } from 'vue'
2 | import { renderToString } from '@vue/server-renderer'
3 |
4 | import { createRouter } from './router'
5 |
6 | import App from './App.vue'
7 |
8 | import Harlem from '@harlem/core'
9 | import { createServerSSRPlugin, getBridgingScriptBlock } from '@harlem/plugin-ssr'
10 |
11 | import registerGlobalComponents from './plugins/global-components'
12 | import { initStore } from './store/init'
13 |
14 | import type { ParameterizedContext } from 'koa'
15 |
16 | export async function render (
17 | ctx: ParameterizedContext,
18 | manifest: Record,
19 | ): Promise<[string, string]> {
20 | const app = createSSRApp(App)
21 | const router = createRouter('server')
22 |
23 | // create and initialize store
24 | app.use(Harlem, { plugins: [createServerSSRPlugin()] })
25 | initStore(ctx)
26 |
27 | registerGlobalComponents(app)
28 |
29 | app.use(router)
30 |
31 | await router.push(ctx.path)
32 | await router.isReady()
33 |
34 | const renderCtx: {modules?: string[]} = {}
35 | let renderedHtml = await renderToString(app, renderCtx)
36 | renderedHtml += getBridgingScriptBlock()
37 |
38 | const preloadLinks = renderPreloadLinks(renderCtx.modules, manifest)
39 | return [renderedHtml, preloadLinks]
40 | }
41 |
42 | function renderPreloadLinks (modules: undefined | string[], manifest: Record): string {
43 | let links = ''
44 | const seen = new Set()
45 | if (modules === undefined) throw new Error()
46 | modules.forEach((id) => {
47 | const files = manifest[id]
48 | if (files) {
49 | files.forEach((file) => {
50 | if (!seen.has(file)) {
51 | seen.add(file)
52 | links += renderPreloadLink(file)
53 | }
54 | })
55 | }
56 | })
57 | return links
58 | }
59 |
60 | function renderPreloadLink (file: string): string {
61 | if (file.endsWith('.js')) {
62 | return ``
63 | } else if (file.endsWith('.css')) {
64 | return ``
65 | } else {
66 | return ''
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/pages/Article.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Article is downloading...
10 |
11 |
12 |
13 |
14 |
15 |
16 |
21 |
22 |
23 |
24 | Comments are downloading...
25 |
26 |
27 |
28 |
29 |
30 |
31 |
45 |
--------------------------------------------------------------------------------
/src/pages/EditArticle.vue:
--------------------------------------------------------------------------------
1 |
2 |
66 |
67 |
68 |
138 |
--------------------------------------------------------------------------------
/src/pages/Home.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | conduit
7 |
8 |
A place to share your knowledge.
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
22 |
23 |
24 | Articles are downloading...
25 |
26 |
27 |
28 |
29 |
30 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
60 |
--------------------------------------------------------------------------------
/src/pages/Login.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Sign in
8 |
9 |
10 |
11 | Need an account?
12 |
13 |
14 |
15 |
16 | -
20 | {{ field }} {{ error ? error[0] : '' }}
21 |
22 |
23 |
24 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
105 |
--------------------------------------------------------------------------------
/src/pages/Profile.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 | Profile is downloading...
12 |
13 |
14 |
18 |
19 | {{ profile.username }}
20 |
21 |
22 | {{ profile.bio }}
23 |
24 |
25 |
30 |
31 | Edit profile settings
32 |
33 |
34 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
58 |
59 |
60 | Articles are downloading...
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
111 |
112 |
120 |
--------------------------------------------------------------------------------
/src/pages/Register.vue:
--------------------------------------------------------------------------------
1 |
2 |
68 |
69 |
70 |
113 |
--------------------------------------------------------------------------------
/src/pages/Settings.vue:
--------------------------------------------------------------------------------
1 |
2 |
74 |
75 |
76 |
133 |
--------------------------------------------------------------------------------
/src/plugins/global-components.ts:
--------------------------------------------------------------------------------
1 | import type { App } from 'vue'
2 |
3 | import AppLink from '../components/AppLink.vue'
4 |
5 | export default function registerGlobalComponents (app: App): void {
6 | app.component('AppLink', AppLink)
7 | }
8 |
--------------------------------------------------------------------------------
/src/plugins/marked.ts:
--------------------------------------------------------------------------------
1 | import marked from 'marked'
2 | import insane from 'insane'
3 |
4 | export default (markdown: string): string => {
5 | const html = marked(markdown, {
6 | // Fixme: ts-jest import.meta not support
7 | // baseUrl: import.meta.env.BASE_URL,
8 | })
9 |
10 | return insane(html, {
11 | allowedTags: ['a', 'article', 'b', 'blockquote', 'br', 'caption', 'code', 'del', 'details', 'div', 'em',
12 | 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'ins', 'kbd', 'li', 'main', 'ol',
13 | 'p', 'pre', 'section', 'span', 'strike', 'strong', 'sub', 'summary', 'sup', 'table',
14 | 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul', 'input'],
15 | allowedAttributes: {
16 | a: ['href', 'name', 'target', 'title'],
17 | iframe: ['allowfullscreen', 'frameborder', 'src'],
18 | img: ['src', 'alt', 'title'],
19 | i: ['class'],
20 | h1: ['id'],
21 | h2: ['id'],
22 | h3: ['id'],
23 | h4: ['id'],
24 | h5: ['id'],
25 | h6: ['id'],
26 | ol: ['start'],
27 | code: ['class'],
28 | th: ['align', 'rowspan'],
29 | td: ['align'],
30 | input: ['disabled', 'type', 'checked'],
31 | },
32 | filter: ({ tag, attrs }: {tag: string, attrs: Record}) => {
33 | // Display checklist
34 | if (tag === 'input') {
35 | return attrs.type === 'checkbox' && attrs.disabled === ''
36 | }
37 | return true
38 | },
39 | })
40 | }
41 |
--------------------------------------------------------------------------------
/src/plugins/set-authorization-token.ts:
--------------------------------------------------------------------------------
1 | import { request } from '../services'
2 | import storage from '../utils/storage'
3 |
4 | export default function (): void {
5 | const token = storage.get('user')?.token
6 | if (token !== undefined) request.setAuthorizationHeader(token)
7 | }
8 |
--------------------------------------------------------------------------------
/src/router.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createRouter as createVueRouter,
3 | createMemoryHistory,
4 | createWebHistory,
5 | Router,
6 | } from 'vue-router'
7 |
8 | export const createRouter = (type: 'client' | 'server'): Router => createVueRouter({
9 | history: type === 'client' ? createWebHistory() : createMemoryHistory(),
10 |
11 | routes: [
12 | {
13 | name: 'global-feed',
14 | path: '/',
15 | component: () => import('./pages/Home.vue'),
16 | },
17 | {
18 | name: 'my-feed',
19 | path: '/my-feeds',
20 | component: () => import('./pages/Home.vue'),
21 | },
22 | {
23 | name: 'tag',
24 | path: '/tag/:tag',
25 | component: () => import('./pages/Home.vue'),
26 | },
27 | {
28 | name: 'article',
29 | path: '/article/:slug',
30 | component: () => import('./pages/Article.vue'),
31 | },
32 | {
33 | name: 'edit-article',
34 | path: '/article/:slug/edit',
35 | component: () => import('./pages/EditArticle.vue'),
36 | },
37 | {
38 | name: 'create-article',
39 | path: '/article/create',
40 | component: () => import('./pages/EditArticle.vue'),
41 | },
42 | {
43 | name: 'login',
44 | path: '/login',
45 | component: () => import('./pages/Login.vue'),
46 | },
47 | {
48 | name: 'register',
49 | path: '/register',
50 | component: () => import('./pages/Register.vue'),
51 | },
52 | {
53 | name: 'profile',
54 | path: '/profile/:username',
55 | component: () => import('./pages/Profile.vue'),
56 | },
57 | {
58 | name: 'profile-favorites',
59 | path: '/profile/:username/favorites',
60 | component: () => import('./pages/Profile.vue'),
61 | },
62 | {
63 | name: 'settings',
64 | path: '/settings',
65 | component: () => import('./pages/Settings.vue'),
66 | },
67 | ],
68 | })
69 |
--------------------------------------------------------------------------------
/src/services/article/deleteArticle.ts:
--------------------------------------------------------------------------------
1 | import { request } from '../index'
2 |
3 | export function deleteArticle (slug: string): Promise {
4 | return request.delete(`/articles/${slug}`)
5 | }
6 |
--------------------------------------------------------------------------------
/src/services/article/favoriteArticle.ts:
--------------------------------------------------------------------------------
1 | import { request } from '../index'
2 |
3 | import type { AuthorizationError } from '../../types/error'
4 |
5 | import { Either, fail, success } from '../../utils/either'
6 | import { mapAuthorizationResponse } from '../../utils/map-checkable-response'
7 |
8 | export async function postFavoriteArticle (slug: string): Promise> {
9 | const result1 = await request.checkablePost(`/articles/${slug}/favorite`)
10 | const result2 = mapAuthorizationResponse(result1)
11 |
12 | if (result2.isOk()) return success(result2.value.article)
13 | return fail(result2.value)
14 | }
15 |
16 | export async function deleteFavoriteArticle (slug: string): Promise> {
17 | const result1 = await request.checkableDelete(`/articles/${slug}/favorite`)
18 | const result2 = mapAuthorizationResponse(result1)
19 |
20 | if (result2.isOk()) return success(result2.value.article)
21 | return fail(result2.value)
22 | }
23 |
--------------------------------------------------------------------------------
/src/services/article/getArticle.ts:
--------------------------------------------------------------------------------
1 | import { request } from '../index'
2 |
3 | export function getArticle (slug: string): Promise {
4 | return request.get(`/articles/${slug}`).then(res => res.article)
5 | }
6 |
--------------------------------------------------------------------------------
/src/services/article/getArticles.ts:
--------------------------------------------------------------------------------
1 | import { limit, request } from '../index'
2 |
3 | export function getArticles (page = 1): Promise {
4 | const params = { limit, offset: (page - 1) * limit }
5 | return request.get('/articles', { params })
6 | }
7 |
8 | export function getFavoritedArticles (username: string, page = 1): Promise {
9 | const params = { limit, offset: (page - 1) * limit, favorited: username }
10 | return request.get('/articles', { params })
11 | }
12 |
13 | export function getProfileArticles (username: string, page = 1): Promise {
14 | const params = { limit, offset: (page - 1) * limit, author: username }
15 | return request.get('/articles', { params })
16 | }
17 |
18 | export function getFeeds (page = 1): Promise {
19 | const params = { limit, offset: (page - 1) * limit }
20 | return request.get('/articles/feed', { params })
21 | }
22 |
23 | export function getArticlesByTag (tagName: string, page = 1): Promise {
24 | const params = { tag: tagName, limit, offset: (page - 1) * limit }
25 | return request.get('/articles', { params })
26 | }
27 |
--------------------------------------------------------------------------------
/src/services/article/postArticle.ts:
--------------------------------------------------------------------------------
1 | import { request } from '../index'
2 |
3 | interface PostArticleForm {
4 | title: string
5 | description: string
6 | body: string
7 | tagList: string[]
8 | }
9 |
10 | export function postArticle (form: PostArticleForm): Promise {
11 | return request.post('/articles', { article: form })
12 | .then(res => res.article)
13 | }
14 |
15 | export function putArticle (slug: string, form: PostArticleForm): Promise {
16 | return request.put(`/articles/${slug}`, { article: form })
17 | .then(res => res.article)
18 | }
19 |
--------------------------------------------------------------------------------
/src/services/auth/postLogin.ts:
--------------------------------------------------------------------------------
1 | import { request } from '../index'
2 |
3 | import type { ValidationError } from '../../types/error'
4 |
5 | import { mapValidationResponse } from '../../utils/map-checkable-response'
6 | import { Either, fail, success } from '../../utils/either'
7 |
8 | export interface PostLoginForm {
9 | email: string
10 | password: string
11 | }
12 |
13 | export type PostLoginErrors = Partial>
14 |
15 | export async function postLogin (form: PostLoginForm): Promise, User>> {
16 | const result1 = await request.checkablePost('/users/login', { user: form })
17 | const result2 = mapValidationResponse(result1)
18 |
19 | if (result2.isOk()) return success(result2.value.user)
20 | else return fail(result2.value)
21 | }
22 |
--------------------------------------------------------------------------------
/src/services/auth/postRegister.ts:
--------------------------------------------------------------------------------
1 | import { request } from '../index'
2 |
3 | import type { ValidationError } from '../../types/error'
4 |
5 | import { mapValidationResponse } from '../../utils/map-checkable-response'
6 | import { Either, fail, success } from '../../utils/either'
7 |
8 | export interface PostRegisterForm {
9 | email: string
10 | password: string
11 | username: string
12 | }
13 |
14 | export type PostRegisterErrors = Partial>
15 |
16 | export async function postRegister (form: PostRegisterForm): Promise, User>> {
17 | const result1 = await request.checkablePost('/users', { user: form })
18 | const result2 = mapValidationResponse(result1)
19 |
20 | if (result2.isOk()) return success(result2.value.user)
21 | else return fail(result2.value)
22 | }
23 |
--------------------------------------------------------------------------------
/src/services/comment/getComments.ts:
--------------------------------------------------------------------------------
1 | import { request } from '../index'
2 |
3 | export function getCommentsByArticle (slug: string): Promise {
4 | return request.get(`/articles/${slug}/comments`).then(res => res.comments)
5 | }
6 |
--------------------------------------------------------------------------------
/src/services/comment/postComment.ts:
--------------------------------------------------------------------------------
1 | import { request } from '../index'
2 |
3 | export function deleteComment (slug: string, commentId: number): Promise> {
4 | return request.delete(`/articles/${slug}/comments/${commentId}`)
5 | }
6 |
7 | export function postComment (slug: string, body: string): Promise {
8 | return request.post(`/articles/${slug}/comments`, { comment: { body } })
9 | .then(res => res.comment)
10 | }
11 |
--------------------------------------------------------------------------------
/src/services/index.ts:
--------------------------------------------------------------------------------
1 | import { CONFIG } from '../config'
2 | import FetchRequest from '../utils/request'
3 |
4 | export const limit = 10
5 |
6 | export const request = new FetchRequest({
7 | prefix: `${CONFIG.API_HOST}/api`,
8 | headers: {
9 | 'Content-Type': 'application/json',
10 | },
11 | })
12 |
--------------------------------------------------------------------------------
/src/services/profile/followProfile.ts:
--------------------------------------------------------------------------------
1 | import type { AuthorizationError } from '../../types/error'
2 |
3 | import { request } from '../index'
4 |
5 | import { mapAuthorizationResponse } from '../../utils/map-checkable-response'
6 | import { Either, fail, success } from '../../utils/either'
7 |
8 | export async function postFollowProfile (username: string): Promise> {
9 | const result1 = await request.checkablePost(`/profiles/${username}/follow`)
10 | const result2 = mapAuthorizationResponse(result1)
11 |
12 | if (result2.isOk()) return success(result2.value.profile)
13 | return fail(result2.value)
14 | }
15 |
16 | export async function deleteFollowProfile (username: string): Promise> {
17 | const result1 = await request.checkableDelete(`/profiles/${username}/follow`)
18 | const result2 = mapAuthorizationResponse(result1)
19 |
20 | if (result2.isOk()) return success(result2.value.profile)
21 | return fail(result2.value)
22 | }
23 |
--------------------------------------------------------------------------------
/src/services/profile/getProfile.ts:
--------------------------------------------------------------------------------
1 | import { request } from '../index'
2 |
3 | export function getProfile (username: string): Promise {
4 | return request.get(`/profiles/${username}`).then(res => res.profile)
5 | }
6 |
--------------------------------------------------------------------------------
/src/services/profile/putProfile.ts:
--------------------------------------------------------------------------------
1 | import { request } from '../index'
2 |
3 | export interface PutProfileForm {
4 | username?: string
5 | bio?: string
6 | image?: string
7 | email?: string
8 | password?: string
9 | }
10 |
11 | export function putProfile (form: PutProfileForm): Promise {
12 | return request.put('/user', form).then(res => res.user)
13 | }
14 |
--------------------------------------------------------------------------------
/src/services/tag/getTags.ts:
--------------------------------------------------------------------------------
1 | import { request } from '../index'
2 |
3 | export function getAllTags (): Promise {
4 | return request.get('/tags').then(res => res.tags)
5 | }
6 |
--------------------------------------------------------------------------------
/src/shimes-vue.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.vue' {
2 | import { defineComponent } from 'vue'
3 | const Component: ReturnType
4 |
5 | export default Component
6 | }
7 |
--------------------------------------------------------------------------------
/src/store/init.ts:
--------------------------------------------------------------------------------
1 | import type { ParameterizedContext } from 'koa'
2 |
3 | import cookie from '../utils/cookie'
4 |
5 | import * as userStore from './user'
6 |
7 | export const initStore = (ctx: ParameterizedContext): void => {
8 | const userCookie = ctx.cookies.get('user')
9 | const user = typeof userCookie === 'string' ? cookie.parse(userCookie) : null
10 | userStore.updateUser(user)
11 | }
12 |
--------------------------------------------------------------------------------
/src/store/user.ts:
--------------------------------------------------------------------------------
1 | import type { ComputedRef } from 'vue'
2 |
3 | import { createStore } from '@harlem/core'
4 |
5 | import { request } from '../services'
6 | import cookie from '../utils/cookie'
7 |
8 | interface State {
9 | user: User | null
10 | }
11 |
12 | const STATE: State = {
13 | user: null,
14 | }
15 |
16 | const { getter, mutation } = createStore('user', STATE, { allowOverwrite: true })
17 |
18 | export const user = getter('user', state => state.user)
19 |
20 | export const isAuthorized = getter('isAuthorized', () => checkAuthorization(user))
21 |
22 | export const checkAuthorization = (user: ComputedRef): user is ComputedRef => {
23 | return user.value !== null
24 | }
25 |
26 | export const updateUser = mutation('updateUser', (state, userData) => {
27 | if (userData === undefined || userData === null) {
28 | cookie.remove('user')
29 | request.deleteAuthorizationHeader()
30 | state.user = null
31 | } else {
32 | cookie.set('user', userData)
33 | request.setAuthorizationHeader(userData.token)
34 | state.user = userData
35 | }
36 | })
37 |
--------------------------------------------------------------------------------
/src/types/app-routes.d.ts:
--------------------------------------------------------------------------------
1 | declare type AppRouteNames = 'global-feed'
2 | | 'my-feed'
3 | | 'tag'
4 | | 'article'
5 | | 'create-article'
6 | | 'edit-article'
7 | | 'login'
8 | | 'register'
9 | | 'profile'
10 | | 'profile-favorites'
11 | | 'settings'
12 |
--------------------------------------------------------------------------------
/src/types/article.d.ts:
--------------------------------------------------------------------------------
1 | declare interface Article {
2 | title: string
3 | slug: string
4 | body: string
5 | createdAt: string
6 | updatedAt: string
7 | tagList: string[]
8 | description: string
9 | author: Profile
10 | favorited: boolean
11 | favoritesCount: number
12 | }
13 |
--------------------------------------------------------------------------------
/src/types/comment.d.ts:
--------------------------------------------------------------------------------
1 | declare interface ArticleComment {
2 | id: number
3 | createdAt: string
4 | updatedAt: string
5 | body: string
6 | author: Profile
7 | }
8 |
--------------------------------------------------------------------------------
/src/types/error.ts:
--------------------------------------------------------------------------------
1 | class CustomNetworkError extends Error {
2 | response: Response
3 |
4 | constructor (name: string, response: Response) {
5 | super(name)
6 | this.response = response
7 | }
8 | }
9 |
10 | export class NetworkError extends CustomNetworkError {
11 | constructor (response: Response) {
12 | super('NETWORK_ERROR', response)
13 | }
14 | }
15 |
16 | export class AuthorizationError extends CustomNetworkError {
17 | constructor (response: Response) {
18 | super('AUTHORIZATION_ERROR', response)
19 | }
20 | }
21 |
22 | export class ValidationError>> extends CustomNetworkError {
23 | constructor (response: Response) {
24 | super('VALIDATION_ERROR', response)
25 | }
26 |
27 | getErrors (): Promise {
28 | return this.response.json().then(json => json.errors as T)
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/types/global.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'insane';
2 | declare module 'marked';
3 |
--------------------------------------------------------------------------------
/src/types/response.d.ts:
--------------------------------------------------------------------------------
1 | declare interface UserResponse {
2 | user: User
3 | }
4 |
5 | declare interface TagsResponse {
6 | tags: string[]
7 | }
8 |
9 | declare interface ProfileResponse {
10 | profile: Profile
11 | }
12 |
13 | declare interface ArticleResponse {
14 | article: Article
15 | }
16 |
17 | declare interface ArticlesResponse {
18 | articles: Article[]
19 | articlesCount: number
20 | }
21 |
22 | declare interface CommentResponse {
23 | comment: ArticleComment
24 | }
25 |
26 | declare interface CommentsResponse {
27 | comments: ArticleComment[]
28 | }
29 |
--------------------------------------------------------------------------------
/src/types/user.d.ts:
--------------------------------------------------------------------------------
1 | declare interface Profile {
2 | username: string
3 | bio: string
4 | image: string
5 | following: boolean
6 | }
7 |
8 | declare interface User {
9 | id: number
10 | email: string
11 | username: string
12 | bio: string | undefined
13 | image: string | undefined
14 | token: string
15 | }
16 |
--------------------------------------------------------------------------------
/src/utils/cookie.ts:
--------------------------------------------------------------------------------
1 | import Cookies from 'js-cookie'
2 |
3 | function get (key: string): T | null {
4 | try {
5 | const value = Cookies.get(key) ?? ''
6 | return JSON.parse(value)
7 | } catch (e) {
8 | return null
9 | }
10 | }
11 |
12 | function set (key: string, value: unknown): void {
13 | const strValue = JSON.stringify(value)
14 | Cookies.set(key, strValue)
15 | }
16 |
17 | function remove (key: string): void {
18 | Cookies.remove(key)
19 | }
20 |
21 | function parse (value: string): T {
22 | return JSON.parse(decodeURIComponent(value))
23 | }
24 |
25 | export default {
26 | get,
27 | set,
28 | remove,
29 | parse,
30 | }
31 |
--------------------------------------------------------------------------------
/src/utils/create-async-process.ts:
--------------------------------------------------------------------------------
1 | import { Ref, ref } from 'vue'
2 |
3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
4 | interface CreateAsyncProcessReturn any> {
5 | active: Ref
6 | run: (...args: Parameters) => Promise>
7 | }
8 |
9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
10 | export default function createAsyncProcess any> (fn: T): CreateAsyncProcessReturn {
11 | const active: CreateAsyncProcessReturn['active'] = ref(false)
12 |
13 | const run: CreateAsyncProcessReturn['run'] = async (...args) => {
14 | active.value = true
15 | const result = await fn(...args)
16 | active.value = false
17 | return result
18 | }
19 |
20 | return { active, run }
21 | }
22 |
--------------------------------------------------------------------------------
/src/utils/either.ts:
--------------------------------------------------------------------------------
1 | // original source from package: @sweet-monads/either, author: Artem Kobzar
2 | // https://github.com/JSMonk/sweet-monads/tree/master/either
3 |
4 | const enum EitherType {
5 | Left = 'Left',
6 | Right = 'Right'
7 | }
8 |
9 | export type Either =
10 | // eslint-disable-next-line no-use-before-define
11 | | EitherConstructor
12 | // eslint-disable-next-line no-use-before-define
13 | | EitherConstructor
14 |
15 | class EitherConstructor {
16 | static success (v: T): Either {
17 | return new EitherConstructor(EitherType.Right, v)
18 | }
19 |
20 | static fail (v: T): Either {
21 | return new EitherConstructor(EitherType.Left, v)
22 | }
23 |
24 | // eslint-disable-next-line no-useless-constructor
25 | private constructor (
26 | private readonly type: T,
27 | public readonly value: T extends EitherType.Left ? L : R,
28 | ) {}
29 |
30 | isFail (): this is EitherConstructor {
31 | return this.type === EitherType.Left
32 | }
33 |
34 | isOk (): this is EitherConstructor {
35 | return this.type === EitherType.Right
36 | }
37 | }
38 |
39 | export const { fail, success } = EitherConstructor
40 |
41 | export const isEither = (
42 | value: unknown | Either,
43 | ): value is Either => value instanceof EitherConstructor
44 |
--------------------------------------------------------------------------------
/src/utils/filters.ts:
--------------------------------------------------------------------------------
1 | export const dateFilter = (dateString: string): string => {
2 | const date = new Date(dateString)
3 | return date.toLocaleDateString('en-US', {
4 | month: 'long',
5 | day: 'numeric',
6 | })
7 | }
8 |
--------------------------------------------------------------------------------
/src/utils/map-checkable-response.ts:
--------------------------------------------------------------------------------
1 | import { AuthorizationError, NetworkError, ValidationError } from '../types/error'
2 |
3 | import { Either, fail, success } from './either'
4 |
5 | export const mapAuthorizationResponse = (result: Either): Either => {
6 | if (result.isOk()) {
7 | return success(result.value)
8 | } else if (result.value.response.status === 401) {
9 | return fail(new AuthorizationError(result.value.response))
10 | } else {
11 | throw result.value
12 | }
13 | }
14 |
15 | export const mapValidationResponse = (result: Either): Either, T> => {
16 | if (result.isOk()) {
17 | return success(result.value)
18 | } else if (result.value.response.status === 422) {
19 | return fail(new ValidationError(result.value.response))
20 | } else {
21 | throw result.value
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/utils/params-to-query.ts:
--------------------------------------------------------------------------------
1 | export default function params2query (params: Record): string {
2 | return Object.entries(params).map(([key, value]) => `${key}=${value.toString()}`).join('&')
3 | }
4 |
--------------------------------------------------------------------------------
/src/utils/request.ts:
--------------------------------------------------------------------------------
1 | import { NetworkError } from '../types/error'
2 |
3 | import { Either, fail, success } from './either'
4 | import params2query from './params-to-query'
5 | import fetch from 'cross-fetch'
6 |
7 | export interface FetchRequestOptions {
8 | prefix: string
9 | headers: Record
10 | params: Record
11 | }
12 |
13 | export default class FetchRequest {
14 | private readonly defaultOptions: FetchRequestOptions = {
15 | prefix: '',
16 | headers: {},
17 | params: {},
18 | }
19 |
20 | private readonly options: FetchRequestOptions
21 |
22 | constructor (options: Partial = {}) {
23 | this.options = Object.assign({}, this.defaultOptions, options)
24 | }
25 |
26 | private readonly generateFinalUrl = (url: string, options: Partial = {}): string => {
27 | const prefix = options.prefix ?? this.options.prefix
28 | const params = Object.assign({}, this.options.params, options.params ?? {})
29 |
30 | let finalUrl = `${prefix}${url}`
31 | if (Object.keys(params).length > 0) finalUrl += `?${params2query(params)}`
32 |
33 | return finalUrl
34 | }
35 |
36 | private readonly generateFinalHeaders = (options: Partial = {}): FetchRequestOptions['headers'] => {
37 | return Object.assign({}, this.options.headers, options.headers ?? {})
38 | }
39 |
40 | private readonly handleResponse = (response: Response): Promise> => {
41 | if (response.ok) {
42 | return response.json().then(json => success(json as T))
43 | }
44 |
45 | return Promise.resolve(fail(new NetworkError(response)))
46 | }
47 |
48 | private readonly handleCorrectResponse = (response: Response): Promise => {
49 | if (response.ok) {
50 | return response.json()
51 | }
52 |
53 | throw new NetworkError(response)
54 | }
55 |
56 | private runFetch ({ method, url, data, options }: {
57 | method: 'GET' | 'DELETE' | 'POST' | 'PUT' | 'PATCH'
58 | url: string
59 | data?: unknown
60 | options?: Partial
61 | }): Promise {
62 | const finalUrl = this.generateFinalUrl(url, options)
63 | const headers = this.generateFinalHeaders(options)
64 |
65 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
66 | const fetchOptions: any = { method, headers }
67 | if (data !== undefined) fetchOptions.body = JSON.stringify(data)
68 | return fetch(finalUrl, fetchOptions)
69 | }
70 |
71 | private runSafeFetch (
72 | method: 'GET' | 'DELETE',
73 | url: string,
74 | options?: Partial,
75 | ): Promise {
76 | return this.runFetch({ method, url, options })
77 | }
78 |
79 | private runUnsafeFetch (
80 | method: 'POST' | 'PUT' | 'PATCH',
81 | url: string,
82 | data?: unknown,
83 | options?: Partial,
84 | ): Promise {
85 | return this.runFetch({ method, url, options, data })
86 | }
87 |
88 | get (url: string, options?: Partial): Promise {
89 | return this.runSafeFetch('GET', url, options).then(r => this.handleCorrectResponse(r))
90 | }
91 |
92 | checkableGet (url: string, options?: Partial): Promise> {
93 | return this.runSafeFetch('GET', url, options).then(r => this.handleResponse(r))
94 | }
95 |
96 | post (url: string, data?: unknown, options?: Partial): Promise {
97 | return this.runUnsafeFetch('POST', url, data, options).then(r => this.handleCorrectResponse(r))
98 | }
99 |
100 | checkablePost (url: string, data?: unknown, options?: Partial): Promise> {
101 | return this.runUnsafeFetch('POST', url, data, options).then(r => this.handleResponse(r))
102 | }
103 |
104 | delete (url: string, options?: Partial): Promise {
105 | return this.runSafeFetch('DELETE', url, options).then(r => this.handleCorrectResponse(r))
106 | }
107 |
108 | checkableDelete (url: string, options?: Partial): Promise> {
109 | return this.runSafeFetch('DELETE', url, options).then(r => this.handleResponse(r))
110 | }
111 |
112 | put (url: string, data?: unknown, options?: Partial): Promise {
113 | return this.runUnsafeFetch('PUT', url, data, options).then(r => this.handleCorrectResponse(r))
114 | }
115 |
116 | checkablePut (url: string, data?: unknown, options?: Partial): Promise> {
117 | return this.runUnsafeFetch('PUT', url, data, options).then(r => this.handleResponse(r))
118 | }
119 |
120 | patch (url: string, data?: unknown, options?: Partial): Promise {
121 | return this.runUnsafeFetch('PATCH', url, data, options).then(r => this.handleCorrectResponse(r))
122 | }
123 |
124 | checkablePatch (url: string, data?: unknown, options?: Partial): Promise> {
125 | return this.runUnsafeFetch('PATCH', url, data, options).then(r => this.handleResponse(r))
126 | }
127 |
128 | public setAuthorizationHeader (token: string): void {
129 | if (token !== '') this.options.headers.Authorization = `Token ${token}`
130 | }
131 |
132 | public deleteAuthorizationHeader (): void {
133 | delete this.options?.headers?.Authorization
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/src/utils/storage.ts:
--------------------------------------------------------------------------------
1 | function get (key: string): T | null {
2 | try {
3 | const value = localStorage.getItem(key) ?? ''
4 | return JSON.parse(value)
5 | } catch (e) {
6 | return null
7 | }
8 | }
9 |
10 | function set (key: string, value: T): void {
11 | const strValue = JSON.stringify(value)
12 | localStorage.setItem(key, strValue)
13 | }
14 |
15 | function remove (key: string): void {
16 | localStorage.removeItem(key)
17 | }
18 |
19 | export default {
20 | get,
21 | set,
22 | remove,
23 | }
24 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Basic Options */
4 | // "incremental": true, /* Enable incremental compilation */
5 | "target": "ESNext",
6 | "module": "ESNext",
7 | // "lib": [], /* Specify library files to be included in the compilation. */
8 | // "allowJs": true, /* Allow javascript files to be compiled. */
9 | // "checkJs": true, /* Report errors in .js files. */
10 | "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
11 | // "declaration": true, /* Generates corresponding '.d.ts' file. */
12 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
13 | // "sourceMap": true, /* Generates corresponding '.map' file. */
14 | // "outFile": "./", /* Concatenate and emit output to single file. */
15 | // "outDir": "./", /* Redirect output structure to the directory. */
16 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
17 | // "composite": true, /* Enable project compilation */
18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
19 | // "removeComments": true, /* Do not emit comments to output. */
20 | "noEmit": true,
21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */
22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
23 | "isolatedModules": true,
24 |
25 | /* Strict Type-Checking Options */
26 | "strict": true,
27 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
28 | // "strictNullChecks": true, /* Enable strict null checks. */
29 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */
30 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
31 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
32 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
33 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
34 |
35 | /* Additional Checks */
36 | // "noUnusedLocals": true, /* Report errors on unused locals. */
37 | // "noUnusedParameters": true, /* Report errors on unused parameters. */
38 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
39 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
40 |
41 | /* Module Resolution Options */
42 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
43 | "baseUrl": ".",
44 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
45 | // "typeRoots": [], /* List of folders to include type definitions from. */
46 | // "types": [], /* Type declaration files to be included in compilation. */
47 | "allowSyntheticDefaultImports": true,
48 | "esModuleInterop": true,
49 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
50 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
51 |
52 | /* Source Map Options */
53 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
54 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
55 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
56 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
57 |
58 | /* Experimental Options */
59 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
60 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
61 |
62 | /* Advanced Options */
63 | "skipLibCheck": true,
64 | "forceConsistentCasingInFileNames": true
65 | },
66 | "include": [
67 | "src",
68 | "./server-prod.js",
69 | "./server-dev.js"
70 | ]
71 | }
72 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import vue from '@vitejs/plugin-vue'
2 | import { resolve } from 'path'
3 | import { defineConfig } from 'vite'
4 | import analyzer from 'rollup-plugin-analyzer'
5 |
6 | export default defineConfig({
7 | resolve: {
8 | alias: {
9 | 'src': resolve(__dirname, 'src'),
10 | },
11 | },
12 | plugins: [
13 | vue(),
14 | analyzer({ summaryOnly: true }),
15 | ],
16 | })
17 |
--------------------------------------------------------------------------------