├── .prettierignore
├── .eslintignore
├── src
├── styles
│ ├── states.sass
│ ├── index.sass
│ ├── variables.sass
│ ├── post-content.sass
│ ├── highlight.sass
│ ├── formats.sass
│ └── elements.sass
├── types
│ ├── index.ts
│ ├── ApiResponse.ts
│ └── Database.ts
├── assets
│ └── logo.png
├── utils
│ ├── getErrMsg.ts
│ ├── index.ts
│ ├── setTitle.ts
│ ├── getAvatar.ts
│ ├── globalStates.ts
│ ├── userData.ts
│ ├── siteCache.ts
│ └── FileUploader.ts
├── components
│ ├── DateString.vue
│ ├── ExternalLink.vue
│ ├── UserLink.vue
│ ├── Placeholder.vue
│ ├── NProgress.vue
│ ├── Lazyload.vue
│ ├── GlobalAside.vue
│ ├── GlobalPlaceholder.vue
│ ├── GlobalFooter.vue
│ ├── CommentEdit.vue
│ ├── GlobalSideNav.vue
│ ├── GlobalHeader.vue
│ ├── QuickEdit.vue
│ ├── AuthorCard.vue
│ ├── CommentList.vue
│ ├── PostList.vue
│ ├── FloatToolbox.vue
│ └── GlobalHeaderUserDropdown.vue
├── view
│ ├── 404.vue
│ ├── archives.vue
│ ├── auth.vue
│ ├── index.vue
│ ├── user.vue
│ ├── edit-post.vue
│ └── post.vue
├── config.ts
├── App.vue
├── vue-app-env.d.ts
├── main.ts
└── router.ts
├── public
├── favicon.ico
└── images
│ ├── wordpress.svg
│ └── spinner.svg
├── .prettierrc.yml
├── vite.config.ts
├── index.html
├── tsconfig.json
├── .eslintrc.yml
├── _config.sample.yml
├── .vscode
├── vue.code-snippets
└── serverless.code-snippets
├── api
├── config.ts
├── index.ts
├── comment.ts
├── utils.ts
├── post.ts
└── user.ts
├── LICENSE
├── vercel.json
├── package.json
├── .gitignore
└── README.md
/.prettierignore:
--------------------------------------------------------------------------------
1 | dist
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | *.dev.*
2 | dist
3 | *.d.ts
4 |
--------------------------------------------------------------------------------
/src/styles/states.sass:
--------------------------------------------------------------------------------
1 | .lock-scroll
2 | overflow: hidden
3 |
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ApiResponse'
2 | export * from './Database'
3 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FreeNowOrg/BlogNow/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/src/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FreeNowOrg/BlogNow/HEAD/src/assets/logo.png
--------------------------------------------------------------------------------
/.prettierrc.yml:
--------------------------------------------------------------------------------
1 | trailingComma: 'es5'
2 | tabWidth: 2
3 | semi: false
4 | singleQuote: true
5 | arrowParens: always
6 | quoteProps: as-needed
7 |
--------------------------------------------------------------------------------
/src/utils/getErrMsg.ts:
--------------------------------------------------------------------------------
1 | export function getErrMsg(err: any): string {
2 | return err?.response?.data?.message || err.message || 'HTTP Timeout'
3 | }
4 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from './getAvatar'
2 | export * from './getErrMsg'
3 | export * from './globalStates'
4 | export * from './setTitle'
5 | export * from './siteCache'
6 | export * from './userData'
7 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import vue from '@vitejs/plugin-vue'
3 |
4 | export default defineConfig({
5 | plugins: [vue()],
6 | build: {
7 | sourcemap: true,
8 | },
9 | })
10 |
--------------------------------------------------------------------------------
/src/components/DateString.vue:
--------------------------------------------------------------------------------
1 |
2 | time
3 |
4 |
5 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/src/utils/setTitle.ts:
--------------------------------------------------------------------------------
1 | // import { PROJECT_NAME } from '../config'
2 |
3 | export function setTitle(...title: string[]) {
4 | title = title || []
5 | title.push('Blog Now')
6 | document.title = title.join(' | ')
7 | return document.title
8 | }
9 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Blog Now
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/view/404.vue:
--------------------------------------------------------------------------------
1 |
2 | #error-container
3 | .body-inner
4 | article.404-page
5 | h1 404
6 | p This page has been lost.
7 | p
8 | router-link.button(to='/') ← Take me home
9 |
10 |
11 |
18 |
19 |
23 |
--------------------------------------------------------------------------------
/src/styles/index.sass:
--------------------------------------------------------------------------------
1 | @import variables.sass
2 | @import elements.sass
3 | @import formats.sass
4 | @import states.sass
5 | @import highlight.sass
6 | @import post-content.sass
7 |
8 | html,
9 | body
10 | margin: 0
11 | padding: 0
12 | position: relative
13 |
14 | *
15 | box-sizing: border-box
16 |
17 | #app
18 | font-family: Avenir, Helvetica, Arial, sans-serif
19 | -webkit-font-smoothing: antialiased
20 | -moz-osx-font-smoothing: grayscale
21 | color: var(--theme-text-color)
22 |
23 | ::selection
24 | background-color: rgba(var(--theme-accent-color--rgb), 0.5)
--------------------------------------------------------------------------------
/src/utils/getAvatar.ts:
--------------------------------------------------------------------------------
1 | export function getAvatar(
2 | url?: string,
3 | options: {
4 | width?: number
5 | preset?:
6 | | '404'
7 | | 'mm'
8 | | 'identicon'
9 | | 'monsterid'
10 | | 'wavatar'
11 | | 'retro'
12 | | 'blank'
13 | restrict?: 'g' | 'pg' | 'r' | 'x'
14 | } = {}
15 | ): string {
16 | url = url || 'https://gravatar.loli.net/avatar/'
17 | return `${url}?${new URLSearchParams({
18 | s: '' + (options.width || 120),
19 | d: options.preset || 'identicon',
20 | r: options.restrict || 'g',
21 | })}`
22 | }
23 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "esnext",
4 | "module": "esnext",
5 | "moduleResolution": "node",
6 | "strict": true,
7 | "jsx": "preserve",
8 | "sourceMap": true,
9 | "lib": ["esnext", "dom"],
10 | "plugins": [{ "name": "@vuedx/typescript-plugin-vue" }],
11 | "resolveJsonModule": true,
12 | "esModuleInterop": true,
13 | "allowSyntheticDefaultImports": true
14 | },
15 | "include": [
16 | "src/**/*.ts",
17 | "src/**/*.d.ts",
18 | "src/**/*.tsx",
19 | "src/**/*.vue",
20 | "api/**/*.d.ts"
21 | ]
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/ExternalLink.vue:
--------------------------------------------------------------------------------
1 |
2 | a.external-link(:href='href', target='_blank', rel='nofollow')
3 | slot
4 | icon.external-icon(v-if="!noicon")
5 | ExternalLinkAlt
6 |
7 |
8 |
19 |
20 |
25 |
--------------------------------------------------------------------------------
/.eslintrc.yml:
--------------------------------------------------------------------------------
1 | env:
2 | browser: true
3 | commonjs: true
4 | es2021: true
5 | parser:
6 | vue-eslint-parser
7 | plugins:
8 | - '@typescript-eslint'
9 | extends:
10 | - 'eslint:recommended'
11 | - 'plugin:@typescript-eslint/recommended'
12 | - 'plugin:vue/vue3-recommended'
13 | - 'prettier'
14 | parserOptions:
15 | parser: '@typescript-eslint/parser'
16 | extraFileExtensions:
17 | - .vue
18 | ecmaVersion: 12
19 | rules:
20 | indent: ['error', 2, { SwitchCase: 1 }]
21 | quotes: ['error', 'single']
22 | semi: ['error', 'never']
23 | # 烦死了
24 | '@typescript-eslint/no-explicit-any': 0
25 | '@typescript-eslint/explicit-module-boundary-types': 0
--------------------------------------------------------------------------------
/src/config.ts:
--------------------------------------------------------------------------------
1 | import { version } from '../package.json'
2 |
3 | export const SITE_ENV = process.env.NODE_ENV === 'development' ? 'dev' : 'prod'
4 | export const API_BASE =
5 | SITE_ENV === 'prod' ? '/api' : 'https://blog-now.vercel.app/api'
6 | export const PROJECT_NAME = 'Blog Now'
7 | export const VERSION = version
8 |
9 | // Copyright
10 | export const GITHUB_OWNER = 'FreeNowOrg'
11 | export const GITHUB_REPO = 'BlogNow'
12 | export const GITHUB_URL = `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}`
13 | const year = new Date().getFullYear()
14 | export const COPYRIGHT_YEAR = 2021
15 | export const COPYRIGHT_STR =
16 | year === COPYRIGHT_YEAR ? COPYRIGHT_YEAR : `${COPYRIGHT_YEAR} - ${year}`
17 |
--------------------------------------------------------------------------------
/src/components/UserLink.vue:
--------------------------------------------------------------------------------
1 |
2 | span.userlink
3 | img.avatar(:src='getAvatar(user.avatar)')
4 | router-link.username(:to='`/@${user.username}`', v-if='!user.not_exist') @{{ user.nickname || user.username }}
5 | a.pointer.plain.username(v-else) {{ "" }}
6 |
7 |
8 |
16 |
17 |
29 |
--------------------------------------------------------------------------------
/src/types/ApiResponse.ts:
--------------------------------------------------------------------------------
1 | import { DbCommentDoc } from '.'
2 | import { DbPostDoc, DbUserDoc } from './Database'
3 |
4 | export type ApiResponse<
5 | BODY extends unknown,
6 | CUSTOM_BODY extends unknown = {}
7 | > = {
8 | status: number
9 | message: string
10 | body: BODY
11 | } & CUSTOM_BODY
12 |
13 | export interface ApiAttachedUser {
14 | author: ApiResponseUser
15 | editor: ApiResponseUser
16 | }
17 |
18 | export type ApiResponseComment = DbCommentDoc & ApiAttachedUser
19 | export type ApiResponseCommentList = {
20 | comments: ApiResponseComment[]
21 | total_comments: number
22 | limit: number
23 | offset: number
24 | has_next: number
25 | }
26 |
27 | export type ApiResponsePost = DbPostDoc &
28 | ApiAttachedUser & {
29 | cover: string
30 | }
31 |
32 | export type ApiResponseUser = DbUserDoc & { not_exist?: true }
33 |
--------------------------------------------------------------------------------
/src/styles/variables.sass:
--------------------------------------------------------------------------------
1 | :root
2 | font-size: 16px
3 | --theme-accent-color: rgb(106, 112, 215)
4 | --theme-accent-color--rgb: 106, 112, 215
5 | --theme-accent-color-darken: rgb(89, 85, 198)
6 | --theme-accent-link-color: rgb(255, 255, 255)
7 | --theme-secondary-color: rgb(224, 32, 128)
8 | --theme-secondary-color--rgb: 224, 32, 128
9 | --theme-text-color: rgb(44, 62, 80)
10 | --theme-link-color: rgb(63, 81, 181)
11 | --theme-link-color--rgb: 63, 81, 181
12 | --theme-background-color: #f4f9ff
13 | --theme-text-shadow-color: #fff
14 | --theme-box-shadow-color: #ddd
15 | --theme-box-shadow-color-hover: #b8b8b8
16 | --theme-border-color: #888
17 | --theme-box-shadow: 0 0 8px var(--theme-box-shadow-color)
18 | --theme-box-shadow-hover: 0 0 14px var(--theme-box-shadow-color-hover)
19 | --theme-tag-color: rgb(214, 228, 255)
20 | --theme-danger-color: #f55
21 | --theme-bookmark-color: #ff69b4
22 |
--------------------------------------------------------------------------------
/src/components/Placeholder.vue:
--------------------------------------------------------------------------------
1 |
2 | svg.spinner(
3 | xmlns='http://www.w3.org/2000/svg',
4 | version='1.1',
5 | width='400',
6 | height='300'
7 | )
8 | g.group
9 | circle.circle(r='36')
10 |
11 |
12 |
13 |
14 |
41 |
--------------------------------------------------------------------------------
/_config.sample.yml:
--------------------------------------------------------------------------------
1 | # 设定保存的方式暂时未确定,可能会变更
2 |
3 | # Site meta
4 | site_mame: Blog Now
5 | site_desc: This is a blog built by BlogNow engine.
6 | site_logo: https://www.wjghj.cn/public/icons/wiki-wordmark.svg
7 |
8 | # Top nav links & Side nav links
9 | site_menu:
10 | - text: Home
11 | link: /
12 | - text: Posts
13 | children:
14 | - text: Archives
15 | link: /archives
16 | - text: Tags
17 | link: /tags
18 | - text: About
19 | link: /-/about
20 |
21 | # Footer
22 | footer_top: global footer
23 | footer_bottom: Powered by BlogNow
24 |
25 | # Scheme
26 | link_scheme:
27 | post: /post/:uuid
28 | post_pid: /pid/:pid
29 | post_slug: /-/:slug
30 | archives: /archives/:year/:month/:day
31 | user: /user/:uuid
32 | user_self: /user/@me
33 |
34 | # Custom
35 | insert_head_end: # e.g.
36 | insert_body_end: # e.g.
37 |
--------------------------------------------------------------------------------
/.vscode/vue.code-snippets:
--------------------------------------------------------------------------------
1 | {
2 | // Place your workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and
3 | // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope
4 | // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is
5 | // used to trigger the snippet and the body will be expanded and inserted. Possible variables are:
6 | // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders.
7 | // Placeholders with the same ids are connected.
8 | "Init vue components": {
9 | "scope": "vue",
10 | "prefix": "vue",
11 | "body": [
12 | "",
13 | "$0",
14 | "",
15 | "",
16 | "",
20 | "",
21 | ""
22 | ],
23 | "description": "Init vue components"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/api/config.ts:
--------------------------------------------------------------------------------
1 | import { readFileSync } from 'fs'
2 | import { router } from './utils'
3 | import { VercelRequest, VercelResponse } from '@vercel/node'
4 |
5 | router.endpoint('/api/config')
6 |
7 | export const COLNAME = {
8 | COMMENT: 'comments',
9 | CONFIG: 'config',
10 | LOG: 'logs',
11 | USER: 'users',
12 | POST: 'posts',
13 | TAG: 'tags',
14 | }
15 |
16 | const CONFIG_DEFAULTS = [
17 | { key: 'siteName', val: 'Blog Now' },
18 | { key: 'siteDesc', val: 'My new blog!' },
19 | ]
20 |
21 | export function getLocalConfig(key: string) {
22 | let fileJSON: Record = {}
23 | try {
24 | const file = readFileSync('blognow.config.json')
25 | fileJSON = JSON.parse(file.toString())
26 | } catch (e) {
27 | console.warn('Can not find local config file')
28 | }
29 | return process.env[key.toUpperCase()] || fileJSON[key] || null
30 | }
31 |
32 | export default (req: VercelRequest, res: VercelResponse) => {
33 | router.endpoint('/api/config')
34 | router.setCollection(COLNAME.CONFIG)
35 |
36 | return router.init(req, res)
37 | }
38 |
--------------------------------------------------------------------------------
/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 | #full-container
3 | n-progress
4 | global-header
5 | #router-view(v-if='SITE_ENV === "dev" || globalInitDone')
6 | router-view
7 | #init-view(v-else)
8 | global-placeholder
9 | global-footer
10 | float-toolbox
11 |
12 |
13 |
30 |
31 |
41 |
--------------------------------------------------------------------------------
/src/components/NProgress.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
38 |
39 |
49 |
--------------------------------------------------------------------------------
/src/utils/globalStates.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 | import { computed, ref } from 'vue'
3 | import { getErrMsg, userData } from '.'
4 | import { API_BASE } from '../config'
5 | import type { DbPostDoc } from '../types/Database'
6 |
7 | // Site meta
8 | export interface SiteMetaType {
9 | total_posts: number
10 | total_users: number
11 | total_tags: number
12 | founded_at: string
13 | latest_post: Partial
14 | }
15 | export const siteMeta = ref(null)
16 | export async function getSiteMeta(): Promise {
17 | return axios.get(`${API_BASE}/site/meta`).then(
18 | ({ data }: any) => {
19 | const meta = data.body.meta
20 | siteMeta.value = meta
21 | return meta
22 | },
23 | (e) => {
24 | const content = getErrMsg(e)
25 | globalInitErrors.value.push({ title: 'Failed to get site meta', content })
26 | return null
27 | }
28 | )
29 | }
30 |
31 | export const globalInitDone = computed(
32 | () => !!(userData.value.uuid !== undefined && siteMeta.value)
33 | )
34 | export const globalInitErrors = ref<{ title?: string; content: string }[]>([])
35 |
--------------------------------------------------------------------------------
/src/styles/post-content.sass:
--------------------------------------------------------------------------------
1 | #post-content,
2 | #edit-post-container .v-md-editor__preview-wrapper
3 | img
4 | max-width: 100%
5 | blockquote
6 | margin: 0.2rem 0
7 | padding: 0.5rem 0.5rem 0.5rem 1rem
8 | border-left: 4px solid #ccc
9 | color: #888
10 | p
11 | margin: 0.2rem
12 |
13 | #edit-post-container .v-md-editor__preview-wrapper
14 | padding: 1rem
15 | font-size: 14px
16 |
17 | // Headers
18 | #post-content,
19 | #edit-post-container .v-md-editor__preview-wrapper,
20 | .site-style
21 | h1
22 | @include header-shared(rgba(var(--theme-accent-color--rgb), 0.5), 75%)
23 | margin-top: 1rem
24 | margin-bottom: 1rem
25 | padding: 0 0.4rem
26 | border: none
27 |
28 | h2
29 | @include header-shared(rgba(var(--theme-accent-color--rgb), 0.35), 50%)
30 | margin: 0.4rem 0
31 | padding: 0 0.4rem
32 |
33 | h3
34 | @include header-shared(rgba(var(--theme-accent-color--rgb), 0.25), 35%)
35 | margin: 0.4rem 0
36 | padding: 0 0.4rem
37 |
38 | h4
39 | @include header-shared(rgba(var(--theme-accent-color--rgb), 0.2), 25%)
40 | margin: 0.4rem 0
41 | padding: 0 0.4rem
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 GratisNow Tech.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "rewrites": [
3 | {
4 | "source": "/api/comment(.*)",
5 | "destination": "/api/comment"
6 | },
7 | {
8 | "source": "/api/config(.*)",
9 | "destination": "/api/config"
10 | },
11 | {
12 | "source": "/api/post(.*)",
13 | "destination": "/api/post"
14 | },
15 | {
16 | "source": "/api/user(.*)",
17 | "destination": "/api/user"
18 | },
19 | {
20 | "source": "/api(.*)",
21 | "destination": "/api/index"
22 | },
23 | {
24 | "source": "/(.*)",
25 | "destination": "/index.html"
26 | }
27 | ],
28 | "headers": [
29 | {
30 | "source": "/api(.*)",
31 | "has": [
32 | {
33 | "type": "header",
34 | "key": "origin",
35 | "value": "http://localhost:3000"
36 | }
37 | ],
38 | "headers": [
39 | {
40 | "key": "access-control-allow-origin",
41 | "value": "http://localhost:3000"
42 | },
43 | {
44 | "key": "access-control-allow-credentials",
45 | "value": "true"
46 | }
47 | ]
48 | }
49 | ]
50 | }
--------------------------------------------------------------------------------
/.vscode/serverless.code-snippets:
--------------------------------------------------------------------------------
1 | {
2 | // Place your workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and
3 | // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope
4 | // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is
5 | // used to trigger the snippet and the body will be expanded and inserted. Possible variables are:
6 | // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders.
7 | // Placeholders with the same ids are connected.
8 | "Init": {
9 | "scope": "typescript",
10 | "prefix": "vercel-node",
11 | "description": "Init serverless module",
12 | "body": [
13 | "import { router } from './utils'",
14 | "import {} from './config'",
15 | "",
16 | "export default (req, res) => {",
17 | " router.endpoint('/api')",
18 | " // router.beforeEach((ctx) => initCol(ctx, ''))",
19 | "",
20 | " $0",
21 | "",
22 | " return router.init(req, res)",
23 | "}"
24 | ]
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/public/images/wordpress.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/Lazyload.vue:
--------------------------------------------------------------------------------
1 |
2 | svg.layload.isLoading(
3 | v-show="!loaded && !error"
4 | :width="width"
5 | :height="height"
6 | role="img"
7 | :class="class"
8 | )
9 | svg.layload.isError(
10 | v-show="error"
11 | :width="width"
12 | :height="height"
13 | role="img"
14 | :class="class"
15 | )
16 | img.lazyload.isLoaded(
17 | v-show="loaded"
18 | :width="width"
19 | :height="height"
20 | :src="src"
21 | role="img"
22 | :class="class"
23 | )
24 |
25 |
26 |
61 |
62 |
72 |
--------------------------------------------------------------------------------
/src/components/GlobalAside.vue:
--------------------------------------------------------------------------------
1 |
2 | aside#global-aside
3 | hr#global-aside-hr
4 |
5 | slot(name='top')
6 |
7 | .card.site-card.align-center
8 | .avatar
9 | img(src='https://www.wjghj.cn/public/icons/wiki-wordmark.svg')
10 | strong.title Site Name
11 | p.desc This is a blog built by BlogNow engine.
12 | .stats.flex.gap-1
13 | .flex-1
14 | .key Posts
15 | .val {{ siteMeta?.total_posts || "-" }}
16 | .flex-1
17 | .key Tags
18 | .val {{ siteMeta?.total_tags || "-" }}
19 | .flex-1
20 | .key Users
21 | .val {{ siteMeta?.total_users || "-" }}
22 | .card.site-style
23 | h4 Announcement
24 | p Lorem ipsum dolor sit amet, consectetur adipisicing elit.
25 |
26 | slot(name='default')
27 |
28 |
29 |
33 |
34 |
64 |
--------------------------------------------------------------------------------
/src/view/archives.vue:
--------------------------------------------------------------------------------
1 |
2 | #archive-container
3 | main#archive-main
4 | .main-flex.body-inner
5 | article#archive-post-list
6 | #post-content.card
7 | h1 Recent posts
8 | .loading(v-if='posts.length < 1')
9 | placeholder
10 | post-list(:posts='posts')
11 |
12 | .next-btn.align-center(v-if='hasNext')
13 | a.button(@click='handleLoadMore') {{ nextLoading ? "Loading..." : "Load more" }}
14 |
15 | global-aside
16 |
17 |
18 |
54 |
55 |
59 |
--------------------------------------------------------------------------------
/src/styles/highlight.sass:
--------------------------------------------------------------------------------
1 | // Customs
2 | pre.hljs
3 | padding: 0.5rem
4 |
5 | pre code,
6 | .hljs code,
7 | code.hljs
8 | background-color: unset
9 | padding: 0
10 |
11 | pre[class^="language-"]
12 | display: block
13 | overflow-x: auto
14 | padding: 0.5em
15 | background: #fdf6e3
16 | color: #657b83
17 |
18 | .hljs
19 | display: block
20 | overflow-x: auto
21 | padding: 0.5em
22 | background: #fdf6e3
23 | color: #657b83
24 |
25 | &-comment,
26 |
27 | &-quote
28 | color: #93a1a1
29 |
30 | &-keyword,
31 |
32 | &-selector-tag,
33 |
34 | &-addition
35 | color: #859900
36 |
37 | &-number,
38 |
39 | &-string,
40 |
41 | &-meta .hljs-meta-string,
42 |
43 | &-literal,
44 |
45 | &-doctag,
46 |
47 | &-regexp
48 | color: #2aa198
49 |
50 | &-title,
51 |
52 | &-section,
53 |
54 | &-name,
55 |
56 | &-selector-id,
57 |
58 | &-selector-class
59 | color: #268bd2
60 |
61 | &-attribute,
62 |
63 | &-attr,
64 |
65 | &-variable,
66 |
67 | &-template-variable,
68 |
69 | &-class .hljs-title,
70 |
71 | &-type
72 | color: #b58900
73 |
74 | &-symbol,
75 |
76 | &-bullet,
77 |
78 | &-subst,
79 |
80 | &-meta,
81 |
82 | &-meta .hljs-keyword,
83 |
84 | &-selector-attr,
85 |
86 | &-selector-pseudo,
87 |
88 | &-link
89 | color: #cb4b16
90 |
91 | &-built_in,
92 |
93 | &-deletion
94 | color: #dc322f
95 |
96 | &-formula
97 | background: #eee8d5
98 |
99 | &-emphasis
100 | font-style: italic
101 |
102 | &-strong
103 | font-weight: bold
104 |
--------------------------------------------------------------------------------
/src/components/GlobalPlaceholder.vue:
--------------------------------------------------------------------------------
1 |
2 | #global-init-container
3 | #on-loading.body-inner.align-center(v-if='!globalInitErrors.length')
4 | placeholder
5 | h1 Application is initializing
6 | .info-area
7 | p.user
8 | .is-loading(v-if='userData.uuid === undefined') Init user data{{ dot }}
9 | .is-ok(v-else) User data - OK
10 | p.site
11 | .is-loading(v-if='!siteMeta') Init site meta{{ dot }}
12 | .is-ok(v-else) Site meta - OK
13 | #on-error.body-inner(v-else)
14 | .align-center
15 | .icon-area
16 | icon
17 | error-filled
18 | h1 Application error
19 | p.desc
20 | | You can
21 | |
22 | a.button(href='') reload the page
23 | | , or contact site admin.
24 | .error-area.card
25 | details
26 | .flex.flex-column.gap-1
27 | .info.error(v-for='item in globalInitErrors')
28 | .title(v-if='item.title') {{ item.title }}
29 | p {{ item.content }}
30 |
31 |
32 |
46 |
47 |
58 |
--------------------------------------------------------------------------------
/src/utils/userData.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 | import { ref, computed } from 'vue'
3 | import { getErrMsg, globalInitErrors } from '.'
4 | import { API_BASE } from '../config'
5 | import { DbUserDoc } from '../types/Database'
6 |
7 | export const userData = ref({} as DbUserDoc)
8 | export const isLoggedIn = computed(
9 | () => !!(userData.value && userData.value.uid > 0 && userData.value.uuid)
10 | )
11 |
12 | export async function userLogin({
13 | username,
14 | password,
15 | }: {
16 | username: string
17 | password: string
18 | }): Promise {
19 | const { data }: any = await axios.post(`${API_BASE}/user/auth/sign-in`, {
20 | username,
21 | password,
22 | })
23 | userData.value = data.body.profile
24 | return data
25 | }
26 |
27 | export async function initUserData(): Promise {
28 | console.log('Get userData by token')
29 | return axios.get(`${API_BASE}/user/auth/profile`).then(
30 | ({ data }: any) => {
31 | const profile = data.body.profile
32 | console.info('Current user', profile)
33 | userData.value = profile
34 | return profile
35 | },
36 | (e) => {
37 | if (e?.response?.status === 401) {
38 | const profile = e?.response?.data?.body?.profile
39 | console.info('Anonymous', profile)
40 | userData.value = profile
41 | return profile
42 | } else {
43 | const content = getErrMsg(e)
44 | console.warn('Failed to get user data', e)
45 | globalInitErrors.value.push({
46 | title: 'Failed to get user data',
47 | content,
48 | })
49 | return null
50 | }
51 | }
52 | )
53 | }
54 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "blog-now",
3 | "version": "1.0.0-alpha.4",
4 | "author": "Dragon-Fish <824399619@qq.com>",
5 | "license": "MIT",
6 | "private": true,
7 | "main": "index.js",
8 | "repository": "https://github.com/GratisNow/BlogNow.git",
9 | "scripts": {
10 | "start": "vite",
11 | "serve": "vercel dev",
12 | "build": "vuedx-typecheck . && vite build",
13 | "pretty": "prettier --write ./src ./api",
14 | "preview": "vercel deploy",
15 | "bump": "bump --nopublish"
16 | },
17 | "dependencies": {
18 | "axios": "^0.24.0",
19 | "highlight.js": "^11.3.1",
20 | "js-cookie": "^3.0.1",
21 | "tslib": "^2.3.1",
22 | "uuid": "^8.3.2",
23 | "vue": "^3.2.20",
24 | "vue-router": "^4.0.11"
25 | },
26 | "devDependencies": {
27 | "@dragon-fish/bump": "^0.0.15",
28 | "@kangc/v-md-editor": "^2.3.10",
29 | "@prettier/plugin-pug": "^1.16.7",
30 | "@types/js-cookie": "^3.0.0",
31 | "@types/js-yaml": "^4.0.3",
32 | "@types/node": "^16.10.3",
33 | "@types/nprogress": "^0.2.0",
34 | "@types/uuid": "^8.3.0",
35 | "@vercel/node": "^1.11.1",
36 | "@vicons/fa": "^0.11.0",
37 | "@vicons/material": "^0.11.0",
38 | "@vicons/utils": "^0.1.4",
39 | "@vitejs/plugin-vue": "^1.9.3",
40 | "@vue/compiler-sfc": "^3.2.20",
41 | "@vuedx/typecheck": "^0.7.4",
42 | "@vuedx/typescript-plugin-vue": "^0.7.4",
43 | "animated-scroll-to": "^2.2.0",
44 | "check-password-strength": "^2.0.3",
45 | "js-yaml": "^4.1.0",
46 | "mongodb": "^4.1.3",
47 | "nanoid": "^3.1.29",
48 | "nprogress": "^0.2.0",
49 | "prettier": "^2.4.1",
50 | "pug": "^3.0.2",
51 | "sass": "^1.42.1",
52 | "serverless-kit": "^0.2.3",
53 | "slugify": "^1.6.1",
54 | "typescript": "^4.4.4",
55 | "vercel": "^23.1.2",
56 | "vite": "^2.6.5"
57 | }
58 | }
--------------------------------------------------------------------------------
/src/components/GlobalFooter.vue:
--------------------------------------------------------------------------------
1 |
2 | footer#global-footer
3 | .top
4 | .body-inner.flex-auto.gap-1
5 | section.flex-1
6 | h4 Discovery
7 | ul
8 | li
9 | router-link(to='/archives') Archives
10 | li
11 | router-link(to='/tags') Tags
12 | li
13 | router-link(to='/-/about') About
14 |
15 | section.flex-1
16 | h4 Follow us
17 | ul
18 | li Free Now Organization
19 | ul
20 | li
21 | e-link(href='https://github.com/FreeNowOrg') @FreeNowOrg
22 |
23 | section.flex-1
24 | h4 Friend links
25 | p Come to GitHub issues to exchange friend links~
26 |
27 | .bottom
28 | .body-inner
29 | .flex-auto.gap-1
30 | p.flex-1
31 | | Powered by
32 | |
33 | e-link(:href='GITHUB_URL') BlogNow
34 | |
35 | | (
36 | em v{{ VERSION }}
37 | | )
38 | p ©{{ COPYRIGHT_STR }} FreeNowOrg
39 |
40 |
41 |
45 |
46 |
74 |
--------------------------------------------------------------------------------
/src/vue-app-env.d.ts:
--------------------------------------------------------------------------------
1 | declare namespace NodeJS {
2 | interface Process {
3 | env: ProcessEnv
4 | }
5 | interface ProcessEnv {
6 | /**
7 | * By default, there are two modes in Vite:
8 | *
9 | * * `development` is used by vite and vite serve
10 | * * `production` is used by vite build
11 | *
12 | * You can overwrite the default mode used for a command by passing the --mode option flag.
13 | *
14 | */
15 | readonly NODE_ENV: 'development' | 'production'
16 | }
17 | }
18 |
19 | declare var process: NodeJS.Process
20 |
21 | declare module '*.gif' {
22 | const src: string
23 | export default src
24 | }
25 |
26 | declare module '*.jpg' {
27 | const src: string
28 | export default src
29 | }
30 |
31 | declare module '*.jpeg' {
32 | const src: string
33 | export default src
34 | }
35 |
36 | declare module '*.png' {
37 | const src: string
38 | export default src
39 | }
40 |
41 | declare module '*.webp' {
42 | const src: string
43 | export default src
44 | }
45 |
46 | declare module '*.svg' {
47 | const src: string
48 | export default src
49 | }
50 |
51 | declare module '*.module.css' {
52 | const classes: { readonly [key: string]: string }
53 | export default classes
54 | }
55 |
56 | declare module '*.module.scss' {
57 | const classes: { readonly [key: string]: string }
58 | export default classes
59 | }
60 |
61 | declare module '*.module.sass' {
62 | const classes: { readonly [key: string]: string }
63 | export default classes
64 | }
65 |
66 | declare module '*.vue' {
67 | import { ComponentOptions } from 'vue'
68 | const componentOptions: ComponentOptions
69 | export default componentOptions
70 | }
71 |
72 | // VMEditor
73 | declare module '@kangc/v-md-editor'
74 | declare module '@kangc/v-md-editor/lib/theme/hljs'
75 | declare module '@kangc/v-md-editor/lib/plugins/tip/index'
76 | declare module '@kangc/v-md-editor/lib/plugins/emoji/index'
77 |
--------------------------------------------------------------------------------
/src/types/Database.ts:
--------------------------------------------------------------------------------
1 | export interface DbConfigDoc {
2 | key: string
3 | val: any
4 | }
5 |
6 | export type DbConfigCol = DbConfigDoc[]
7 |
8 | // Post
9 | export interface DbPostDoc {
10 | uuid: string
11 | pid: number
12 | slug: string
13 | title: string
14 | content: string
15 | created_at: Date
16 | author_uuid: string
17 | edited_at: Date
18 | editor_uuid: string
19 | allow_comment: boolean
20 | is_deleted: boolean
21 | deleted_by: string
22 | is_private: boolean
23 | allowed_users: string[]
24 | allowed_authority: number
25 | }
26 |
27 | // User
28 | export interface DbUserDoc {
29 | uuid: string
30 | uid: number
31 | username: string
32 | email: string
33 | created_at: Date
34 | nickname: string
35 | slogan: string
36 | gender: 'male' | 'female' | 'other'
37 | avatar: string
38 | password_hash: string
39 | salt: string
40 | token: string
41 | token_expires: number
42 | authority: number
43 | title: string
44 | allow_comment: boolean
45 | }
46 |
47 | export interface DbAuthorityDoc {
48 | key: DbAuthorityKeys
49 | authority: number
50 | display_name?: string
51 | }
52 |
53 | // Authority
54 | export type AuthorityPost = 'post_create' | 'post_edit_any' | 'post_delete_any'
55 | export type AuthorityComment =
56 | | 'comment_create'
57 | | 'comment_edit_any'
58 | | 'comment_delete_any'
59 | | 'comment_protect'
60 | export type AuthorityUser =
61 | | 'user_register'
62 | | 'user_block'
63 | | 'user_unblock_self'
64 | | 'user_admin'
65 | | 'user_group_edit'
66 | export type AuthoritySite = 'site_admin'
67 | export type DbAuthorityKeys =
68 | | AuthorityComment
69 | | AuthorityPost
70 | | AuthorityUser
71 | | AuthoritySite
72 |
73 | // Comment
74 | export interface DbCommentDoc {
75 | target_type: 'user' | 'post' | 'comment'
76 | target_uuid: string
77 | uuid: string
78 | content: string
79 | created_at: Date
80 | author_uuid: string
81 | edited_at: Date
82 | editor_uuid: string
83 | is_deleted: boolean
84 | }
85 |
--------------------------------------------------------------------------------
/src/components/CommentEdit.vue:
--------------------------------------------------------------------------------
1 |
2 | .comment-edit
3 | .login-area(v-if='!isLoggedIn')
4 | p
5 | | Please
6 | |
7 | router-link(:to='{ name: "auth", query: { backto: $route.path } }') login
8 | |
9 | | to comment.
10 | .info-area(v-else-if='userData.authority < 1')
11 | p Permission denied. Please contact site admin.
12 | .comment-edit-main(v-else, :class='{ "loading-cover": submitLoading }')
13 | .user-area
14 | user-link(:user='userData')
15 | .edit-area
16 | textarea.site-style(v-model='content')
17 | .btn-area
18 | button(@click='handleSubmit') Submit
19 |
20 |
21 |
66 |
67 |
74 |
--------------------------------------------------------------------------------
/src/components/GlobalSideNav.vue:
--------------------------------------------------------------------------------
1 |
2 | aside#global-side-nav(:class='{ "is-hidden": isHidden }')
3 | .backdrop(@click='isHidden = true')
4 | .inner
5 | #global-side-nav-top
6 | #global-side-nav-bottom
7 | ul#global-side-nav-items
8 | li(v-for='item in sidebarItems') {{ item }}
9 |
10 |
31 |
79 |
--------------------------------------------------------------------------------
/src/components/GlobalHeader.vue:
--------------------------------------------------------------------------------
1 |
2 | nav#global-header.flex.gap-1
3 | .item.site-logo-area.flex.flex-center
4 | router-link.plain(to='/')
5 | .logo-placeholder LOGO
6 |
7 | .item.links-area.flex.flex-1.gap-1
8 | router-link(to='/') Home
9 | router-link(to='/archives') Archives
10 | router-link(to='/-/about') About
11 |
12 | .item.user-area
13 | global-header-user-dropdown
14 |
15 |
16 |
33 |
34 |
90 |
--------------------------------------------------------------------------------
/api/index.ts:
--------------------------------------------------------------------------------
1 | import { VercelRequest, VercelResponse } from '@vercel/node'
2 | import { COLNAME } from './config'
3 | import { getPostModel } from './post'
4 | import { router } from './utils'
5 |
6 | export default (req: VercelRequest, res: VercelResponse) => {
7 | router.endpoint('/api')
8 |
9 | // GET /site/meta
10 | router
11 | .addRoute()
12 | .method('GET')
13 | .path('site')
14 | .path('meta')
15 | .action(async (ctx) => {
16 | // Total stats
17 | const total_posts = await ctx.db.collection(COLNAME.POST).countDocuments()
18 | const total_users = await ctx.db.collection(COLNAME.USER).countDocuments()
19 | const total_tags = await ctx.db.collection(COLNAME.TAG).countDocuments()
20 |
21 | // Get found date
22 | const [firstUser] = await ctx.db
23 | .collection(COLNAME.USER)
24 | .find()
25 | .project({ created_at: 1 })
26 | .sort({ uid: 1 })
27 | .limit(1)
28 | .toArray()
29 | let founded_at = ''
30 | if (firstUser) {
31 | founded_at = firstUser.created_at
32 | }
33 |
34 | // Get latest update
35 | const [latestPost] = await ctx.db
36 | .collection(COLNAME.POST)
37 | .find()
38 | .sort({ pid: -1 })
39 | .limit(1)
40 | .toArray()
41 | let latest_post = null
42 | if (latestPost) {
43 | latest_post = getPostModel(latestPost)
44 | delete latest_post.content
45 | }
46 |
47 | ctx.body = {
48 | meta: { total_posts, total_users, total_tags, founded_at, latest_post },
49 | }
50 | ctx.message = 'Get site meta'
51 | })
52 |
53 | // Easter eggs
54 | router
55 | .addRoute()
56 | .method('GET')
57 | .path(/(coffee|café|easter[_\-\s]egg)/i)
58 | .action((ctx) => {
59 | ctx.status = 418
60 | ctx.message = `Well, I think I need to remind you that I'm just a blog backend.`
61 | })
62 | router
63 | .addRoute()
64 | .method('GET')
65 | .path(/(password|secrets?)/i)
66 | .action((ctx) => {
67 | ctx.status = 403
68 | ctx.message = `Hey, don't even think about it.`
69 | })
70 |
71 | return router.init(req, res)
72 | }
73 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | import { createApp } from 'vue'
2 | import Cookies from 'js-cookie'
3 | import { SITE_ENV } from './config'
4 |
5 | // Create App
6 | import App from './App.vue'
7 | const app = createApp(App)
8 |
9 | // Router
10 | import { router } from './router'
11 | app.use(router)
12 |
13 | // Style
14 | import './styles/index.sass'
15 |
16 | // Inject axios
17 | import axios from 'axios'
18 | axios.interceptors.request.use(
19 | (req) => {
20 | if (SITE_ENV !== 'prod') {
21 | req.headers = req.headers || {}
22 | try {
23 | req.headers.authorization = window.Cookies.get('BLOG_NOW_TOKEN') || ''
24 | console.info('[Axios]', 'Request with local token')
25 | } catch (err) {
26 | console.warn('[Axios]', 'Inject error', err)
27 | }
28 | }
29 | return req
30 | },
31 | (e) => Promise.reject(e)
32 | )
33 |
34 | // Icon
35 | import { Icon } from '@vicons/utils'
36 | app.component('Icon', Icon)
37 |
38 | // ExternamLink
39 | import ExternamLink from './components/ExternalLink.vue'
40 | app.component('ELink', ExternamLink)
41 |
42 | // LazyLoad
43 | import Lazyload from './components/Lazyload.vue'
44 | app.component('Lazyload', Lazyload)
45 |
46 | // Placeholder
47 | import Placeholder from './components/Placeholder.vue'
48 | app.component('Placeholder', Placeholder)
49 |
50 | // highlightjs
51 | import hljs from 'highlight.js'
52 |
53 | // Editor
54 | import VMdEditor from '@kangc/v-md-editor'
55 | import '@kangc/v-md-editor/lib/style/base-editor.css'
56 | // Editor theme
57 | import createHljsTheme from '@kangc/v-md-editor/lib/theme/hljs'
58 | const baseTheme = createHljsTheme({ Hljs: hljs })
59 | // Editor Plugins
60 | import createTipPlugin from '@kangc/v-md-editor/lib/plugins/tip/index'
61 | import '@kangc/v-md-editor/lib/plugins/tip/tip.css'
62 | import createEmojiPlugin from '@kangc/v-md-editor/lib/plugins/emoji/index'
63 | import '@kangc/v-md-editor/lib/plugins/emoji/emoji.css'
64 |
65 | VMdEditor.vMdParser.theme(baseTheme)
66 | VMdEditor.use(createTipPlugin())
67 | VMdEditor.use(createEmojiPlugin())
68 | app.use(VMdEditor)
69 |
70 | // Mount
71 | app.mount('#app')
72 | document.body?.setAttribute('data-env', SITE_ENV)
73 | window.Cookies = Cookies
74 |
--------------------------------------------------------------------------------
/src/components/QuickEdit.vue:
--------------------------------------------------------------------------------
1 |
2 | #quick-edit.card
3 | .site-style
4 | h3 Quick create post
5 | #editor(:class='{ "loading-cover": submitLoading }')
6 | #edit-area
7 | label
8 | strong Title
9 | input.site-style(v-model='title', @blur='handleSaveDraft')
10 | label
11 | strong Content
12 | textarea.site-style(v-model='content', @blur='handleSaveDraft')
13 | #btn-area
14 | button(@click='handleSubmit') Submit
15 |
16 |
17 |
78 |
79 |
87 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 |
9 | # Diagnostic reports (https://nodejs.org/api/report.html)
10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
11 |
12 | # Runtime data
13 | pids
14 | *.pid
15 | *.seed
16 | *.pid.lock
17 |
18 | # Directory for instrumented libs generated by jscoverage/JSCover
19 | lib-cov
20 |
21 | # Coverage directory used by tools like istanbul
22 | coverage
23 | *.lcov
24 |
25 | # nyc test coverage
26 | .nyc_output
27 |
28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
29 | .grunt
30 |
31 | # Bower dependency directory (https://bower.io/)
32 | bower_components
33 |
34 | # node-waf configuration
35 | .lock-wscript
36 |
37 | # Compiled binary addons (https://nodejs.org/api/addons.html)
38 | build/Release
39 |
40 | # Dependency directories
41 | node_modules/
42 | jspm_packages/
43 |
44 | # TypeScript v1 declaration files
45 | typings/
46 |
47 | # TypeScript cache
48 | *.tsbuildinfo
49 |
50 | # Optional npm cache directory
51 | .npm
52 |
53 | # Optional eslint cache
54 | .eslintcache
55 |
56 | # Microbundle cache
57 | .rpt2_cache/
58 | .rts2_cache_cjs/
59 | .rts2_cache_es/
60 | .rts2_cache_umd/
61 |
62 | # Optional REPL history
63 | .node_repl_history
64 |
65 | # Output of 'npm pack'
66 | *.tgz
67 |
68 | # Yarn Integrity file
69 | .yarn-integrity
70 |
71 | # dotenv environment variables file
72 | .env
73 | .env.test
74 |
75 | # parcel-bundler cache (https://parceljs.org/)
76 | .cache
77 |
78 | # Next.js build output
79 | .next
80 |
81 | # Nuxt.js build / generate output
82 | .nuxt
83 | dist
84 |
85 | # Gatsby files
86 | .cache/
87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js
88 | # https://nextjs.org/blog/next-9-1#public-directory-support
89 | # public
90 |
91 | # vuepress build output
92 | .vuepress/dist
93 |
94 | # Serverless directories
95 | .serverless/
96 |
97 | # FuseBox cache
98 | .fusebox/
99 |
100 | # DynamoDB Local files
101 | .dynamodb/
102 |
103 | # TernJS port file
104 | .tern-port
105 |
106 | .vercel
107 |
108 | lib/
109 | dev/
110 | secret/
111 | secret*.*
112 |
113 | !*.sample.*
114 | !sample/
--------------------------------------------------------------------------------
/src/components/AuthorCard.vue:
--------------------------------------------------------------------------------
1 |
2 | #post-author.card
3 | .flex.flex-column.gap-1
4 | .top.flex.gap-1
5 | .left
6 | router-link.avatar.plain(:to='`/@${author.username}`')
7 | img(:src='getAvatar(author.avatar)')
8 | .right.flex-1
9 | router-link.username(:to='`/@${author.username}`') {{ author.username }}
10 | .special-title(v-if='author.title', title='Special title') {{ author.title }}
11 | .btn
12 | a.button Follow
13 | .bottom
14 | ul.posts-list
15 | li.post-placeholder(v-for='item in 10') {{ item }}
16 | .co-author(v-if='editor.uuid && editor.uuid !== author.uuid')
17 | | Co author:
18 | user-link(:user='editor')
19 |
20 |
21 |
29 |
30 |
87 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # BlogNow
4 |
5 | Follow the wizard, start your blog journey right now!
6 |
7 | [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FFreeNowOrg%2FBlogNow&env=MONGO_URI&envDescription=MONGO_URI%3A%20Your%20MongoDB%20Atlas%20connect%20uri%20with%20username%20and%20password.%20(e.g.%20mongodb%2Bsrv%3A%2F%2Fuser%3Apassword%40yours.mongodb.net)%20%2F%20BLOGNOW_DB(optional)%3A%20Your%20blog's%20db%20name.%20(e.g.%20blog_now)&repo-name=my-blog&demo-title=BlogNow&demo-description=BlogNow%20Offical%20Demo&demo-url=https%3A%2F%2Fblog-now.vercel.app%2F&demo-image=https%3A%2F%2Fi.loli.net%2F2021%2F10%2F12%2FL38zE4oF7ITsqHO.jpg)
8 |
9 | **BlogNow** 是一款完全免费的开源动态博客引擎。
10 |
11 | Free dynamic blog engine with full backend support. Powered by Vercel & MongoDB Atlas. Built with :heart:
12 |
13 |
14 |
15 | 
16 |
17 | ## 特色功能
18 |
19 | ### 完全免费且开源
20 |
21 | 一般来说,部署一个基于 BlogNow 的博客——没错,包括后端——是完全可以做到白嫖的!
22 |
23 | 在你的博客内容数据超过 500 MB 以前,均可不花一分钱地使用。
24 |
25 | ### 开箱即用
26 |
27 | 很多时候,个人博客只是极客们的玩具,因为个人博客的安装或者使用都有一定的技术门槛。
28 |
29 | 大部分人都望而却步了——服务器是什么?数据库又是什么?域名怎么注册?诶——怎么还要备案啊!
30 |
31 | 但是 BlogNow 的安装超级简单——呃,这算是安装吗?——你似乎只需要点击几个按钮,然后一路下一步就行了。
32 |
33 | ### 包括完整的后端
34 |
35 | - 文章管理——撰写、编辑、删除博文
36 | - 用户系统——注册、用户权限
37 | - 媒体管理(开发中🚧)
38 |
39 | ### 动态博客系统,更新内容超级快
40 |
41 | 相当长一段时间里,白嫖博客的手段基本都是基于静态网站生成器和持续构建,例如经典的 Hexo + GitHub Actions 组合。
42 |
43 | 然而,BlogNow 敲开了白嫖博客新时代的大门,它是一款**动态博客引擎**,你可以类比传统的博客系统——例如 WordPress。
44 |
45 | 这意味着你不必在每次更新文章时重新构建——现在,你只需编辑文章,点击保存,然后眨眨眼,网页便自己刷新了,你的好点子已经汇入了互联网的川流之中,就是这么迅速。
46 |
47 | ### 支持 markdown,在文章里加点格式吧
48 |
49 | 
50 |
51 | BlogNow 采用 markdown 保存文章内容,得益于 md,你可以在文章里插入各种格式。
52 |
53 | **粗体字** _斜体字_ ~~删除线~~ [链接](https://github.com)
54 |
55 | - 一个
56 | - 无序
57 | - 列表
58 |
59 | > 以及更多格式……
60 |
61 | BlogNow 内置了非常强大的 markdown 编辑器,你可以点击按钮插入元素,然后实时预览你所写的内容。
62 |
63 | ### 另外,超棒的 API 接口——极客们也一定会喜欢
64 |
65 | 非常 RESTful 风格的 API 设计,请看《一篇文章的一生》:
66 |
67 | 1. 新建一篇博客
68 | `POST /post/create`
69 | 2. 获取博客内容
70 | `GET /post/uuid/foo-bar-baz`
71 | 3. 修改博客内容
72 | `PATCH /post/uuid/foo-bar-baz`
73 | 4. 最后再删掉它
74 | `DELETE /post/uuid/foo-bar-baz`
75 |
76 | ——顺便一提,[API 文档](https://blog-now.vercel.app/-/api-references)也很详尽!
77 |
--------------------------------------------------------------------------------
/src/components/CommentList.vue:
--------------------------------------------------------------------------------
1 |
2 | .comment-list-container
3 | ul.comment-list.flex.flex-column.gap-1
4 | li.comment-item(
5 | v-for='item in comments',
6 | :class='{ "is-self": isSelf(item), "is-author": isAuthor(item) }'
7 | )
8 | .user
9 | user-link(:user='item.author')
10 | span.tag.self-tag(v-if='isSelf(item)') You
11 | span.tag.author-tag(v-if='isSelf(item)') Author
12 | .content
13 | .content-main {{ item.content }}
14 | .time
15 | .created_at {{ new Date(item.created_at).toLocaleString() }}
16 |
17 |
18 |
32 |
33 |
80 |
--------------------------------------------------------------------------------
/src/styles/formats.sass:
--------------------------------------------------------------------------------
1 | .align-center,
2 | .loading
3 | text-align: center
4 |
5 | .align-left
6 | text-align: left
7 |
8 | .align-right
9 | text-align: right
10 |
11 | .position-center
12 | text-align: left
13 | position: relative
14 | left: 50%
15 | transform: translateX(-50%)
16 |
17 | .flex-center
18 | display: flex
19 | align-items: center
20 |
21 | .pre,
22 | .poem
23 | white-space: pre-wrap
24 |
25 | .flex
26 | display: flex
27 |
28 | .flex-column
29 | flex-direction: column
30 |
31 | .flex-auto
32 | display: flex
33 | flex-direction: row
34 | @media screen and (max-width: 800px)
35 | .flex-auto
36 | flex-direction: column
37 |
38 | .flex-1
39 | flex: 1
40 |
41 | .gap-1
42 | gap: 1rem
43 |
44 | .flex-list
45 | .list-item
46 | display: flex
47 | gap: 0.5rem
48 |
49 | &:not(:first-of-type)
50 | margin-top: 4px
51 |
52 | &.header
53 | position: sticky
54 | top: 50px
55 | background-color: #f8f8f8
56 | font-weight: 600
57 | font-size: 1.24rem
58 | z-index: 10
59 |
60 | > div:not(:last-of-type)
61 | box-shadow: 2px 0 #dedede
62 |
63 | > div
64 | flex: 1
65 |
66 | .key
67 | font-weight: 600
68 | box-shadow: 2px 0 #dedede
69 |
70 | .main-flex
71 | display: flex
72 | gap: 1.5rem
73 | margin-bottom: 2rem
74 | article
75 | flex: 1
76 | aside
77 | width: 250px
78 |
79 | @media screen and(max-width: 900px)
80 | .main-flex
81 | flex-direction: column
82 | aside
83 | width: 100%
84 |
85 | .pointer
86 | cursor: pointer
87 |
88 | .bread-crumb
89 | margin-bottom: 1.5rem
90 |
91 | // Info
92 | .info
93 | --border-color: rgba(0, 0, 0, 0.1)
94 | --bg-color: rgba(0, 0, 0, 0.04)
95 | background-color: #fff
96 | box-shadow: 0 0 6px rgba(0, 0, 0, 0.1)
97 | border-left: 6px solid var(--border-color)
98 | border-radius: 4px
99 | & > *
100 | padding: 1rem
101 | margin: 0
102 | & .title
103 | font-weight: 600
104 | font-size: 1.2rem
105 | padding: 0.4rem 1rem
106 | background-color: var(--bg-color)
107 | &.tips
108 | --border-color: #30a0ff
109 | --bg-color: rgba(0, 140, 255, 0.1)
110 | &.warn
111 | --border-color: #ffa500
112 | --bg-color: rgba(231, 139, 0, 0.1)
113 | &.error
114 | --border-color: #e00000
115 | --bg-color: rgba(233, 0, 0, 0.1)
116 |
--------------------------------------------------------------------------------
/src/router.ts:
--------------------------------------------------------------------------------
1 | import { createRouter, createWebHistory } from 'vue-router'
2 | import scrollTo from 'animated-scroll-to'
3 |
4 | const router = createRouter({
5 | history: createWebHistory(),
6 | routes: [],
7 | scrollBehavior(to, from, savedPosition) {
8 | if (savedPosition) {
9 | return savedPosition
10 | } else {
11 | scrollTo(0, { maxDuration: 800 })
12 | }
13 | },
14 | })
15 |
16 | router.afterEach(({ name }) => {
17 | document.body.setAttribute('data-route', name as string)
18 | document.body.style.overflow = 'visible'
19 | document.documentElement.style.overflow = 'visible'
20 | })
21 |
22 | // Home
23 | router.addRoute({
24 | path: '/',
25 | name: 'home',
26 | component: () => import('./view/index.vue'),
27 | })
28 |
29 | // Archive
30 | router.addRoute({
31 | path: '/archives',
32 | name: 'archives',
33 | alias: ['/archive'],
34 | component: () => import('./view/archives.vue'),
35 | })
36 |
37 | // Post
38 | router.addRoute({
39 | path: '/post/:uuid',
40 | name: 'post-uuid',
41 | component: () => import('./view/post.vue'),
42 | })
43 | router.addRoute({
44 | path: '/pid/:pid',
45 | alias: ['/p-:pid'],
46 | name: 'post-pid',
47 | component: () => import('./view/post.vue'),
48 | })
49 | router.addRoute({
50 | path: '/-/:slug',
51 | name: 'post-slug',
52 | component: () => import('./view/post.vue'),
53 | })
54 |
55 | // Post edit
56 | router.addRoute({
57 | path: '/post/:uuid/edit',
58 | name: 'edit-post',
59 | component: () => import('./view/edit-post.vue'),
60 | })
61 | router.addRoute({
62 | path: '/post/new',
63 | name: 'edit-post-create',
64 | component: () => import('./view/edit-post.vue'),
65 | })
66 |
67 | // Auth
68 | router.addRoute({
69 | path: '/auth',
70 | alias: ['/login', '/sign-in', '/logout'],
71 | name: 'auth',
72 | component: () => import('./view/auth.vue'),
73 | })
74 |
75 | // User
76 | router.addRoute({
77 | path: '/user/:uuid',
78 | name: 'user-uuid',
79 | component: () => import('./view/user.vue'),
80 | })
81 | router.addRoute({
82 | path: '/u/:uid',
83 | name: 'user-uid',
84 | component: () => import('./view/user.vue'),
85 | })
86 | router.addRoute({
87 | path: '/@:username',
88 | name: 'user-username',
89 | component: () => import('./view/user.vue'),
90 | })
91 |
92 | // Search
93 | // router.addRoute({
94 | // path: '/search',
95 | // name: 'search-index-redirect',
96 | // component: () => import('./view/search.vue'),
97 | // })
98 |
99 | // 404
100 | router.addRoute({
101 | path: '/:pathMatch(.*)*',
102 | name: 'not-found',
103 | component: () => import('./view/404.vue'),
104 | })
105 |
106 | export { router }
107 |
--------------------------------------------------------------------------------
/src/utils/siteCache.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 | import { ref } from 'vue'
3 | import { API_BASE } from '../config'
4 | import type { ApiResponsePost, ApiResponseUser } from '../types'
5 |
6 | export const siteCache = ref({
7 | meta: {} as Record,
8 | posts: [] as ApiResponsePost[],
9 | users: [] as ApiResponseUser[],
10 | recents: [] as string[],
11 | })
12 |
13 | // Post
14 | export function setPostCache(post: ApiResponsePost) {
15 | const index = siteCache.value.posts.findIndex(
16 | ({ uuid }) => uuid === post.uuid
17 | )
18 | if (index < 0) {
19 | console.info('[CACHE]', 'Update post cache')
20 | siteCache.value.posts.push(post)
21 | } else {
22 | console.info('[CACHE]', 'Set post cache')
23 | siteCache.value.posts[index] = post
24 | }
25 | return true
26 | }
27 |
28 | export async function getPost(
29 | selector: 'uuid' | 'pid' | 'slug',
30 | target: string | number,
31 | noCache?: boolean
32 | ): Promise {
33 | const cache = siteCache.value.posts.find((i) => i[selector] === target)
34 | if (cache && !noCache) {
35 | console.info('[CACHE]', 'Get post from cache')
36 | return cache
37 | }
38 | console.info('[CACHE]', 'Get post from origin')
39 | const { data }: any = await axios.get(
40 | `${API_BASE}/post/${selector}/${target}`
41 | )
42 | setPostCache(data.body.post)
43 | return data.body.post
44 | }
45 |
46 | export async function getPostList({ limit = 25, offset = 0 }) {
47 | const { data }: any = await axios.get(`${API_BASE}/post/list/recent`, {
48 | params: { limit, offset },
49 | })
50 |
51 | const posts: ApiResponsePost[] = data.body.posts || []
52 |
53 | if (offset === 0 && posts.length > 0) {
54 | console.info('[CACHE]', 'Set recents')
55 | siteCache.value.recents = posts.map(({ uuid }) => uuid)
56 | }
57 |
58 | posts.forEach(setPostCache)
59 |
60 | return posts
61 | }
62 |
63 | export async function getRecentPosts(
64 | noCache?: boolean
65 | ): Promise {
66 | const list: ApiResponsePost[] = []
67 | if (siteCache.value.recents.length > 0 && !noCache) {
68 | console.info('[CACHE]', 'Get recents from cache')
69 | siteCache.value.recents.forEach((uuid) => {
70 | const post = siteCache.value.posts.find(
71 | ({ uuid: uuid1 }) => uuid === uuid1
72 | )
73 | if (post) return list.push(post)
74 | console.warn(
75 | '[CACHE]',
76 | `Post ${uuid} is not in cache, but was required by recents list`
77 | )
78 | })
79 | return list
80 | }
81 | return getPostList({ limit: 25, offset: 0 })
82 | }
83 |
84 | // User
85 | export function setUserCache(user: ApiResponseUser) {
86 | const index = siteCache.value.users.findIndex(
87 | ({ uuid }) => uuid === user.uuid
88 | )
89 | if (index < 0) {
90 | console.info('[CACHE]', 'Update user cache')
91 | siteCache.value.users.push(user)
92 | } else {
93 | console.info('[CACHE]', 'Set user cache')
94 | siteCache.value.users[index] = user
95 | }
96 | return true
97 | }
98 |
99 | export async function getUser(
100 | selector: 'uuid' | 'uid' | 'username',
101 | target: string | number,
102 | noCache?: boolean
103 | ): Promise {
104 | const cache = siteCache.value.users.find((i) => i[selector] === target)
105 | if (cache && !noCache) {
106 | console.info('[CACHE]', 'Get user from cache')
107 | return cache
108 | }
109 | console.info('[CACHE]', 'Get user from origin')
110 | const { data }: any = await axios.get(
111 | `${API_BASE}/user/${selector}/${target}`
112 | )
113 | setUserCache(data.body.user)
114 | return data.body.user
115 | }
116 |
117 | export async function getUserList(
118 | selector: 'uuid' | 'uid' | 'username',
119 | users: string[]
120 | ) {
121 | const { data }: any = await axios.get(
122 | `${API_BASE}/users/${selector}/${users.join(',')}`
123 | )
124 |
125 | const list: ApiResponseUser[] = data.body.users || []
126 |
127 | list.forEach(setUserCache)
128 |
129 | return list
130 | }
131 |
--------------------------------------------------------------------------------
/src/components/PostList.vue:
--------------------------------------------------------------------------------
1 |
2 | ul.posts-list
3 | li.post-item(v-for='item in posts')
4 | .flex.gap-1
5 | .left
6 | router-link.thumb.plain(
7 | :to='{ name: item.slug ? "post-slug" : "post-uuid", params: { slug: item.slug, uuid: item.uuid } }'
8 | )
9 | img(
10 | :src='item.cover || "https://api.daihan.top/api/acg?_random=" + item.uuid'
11 | )
12 | .right.flex-1.flex.flex-column
13 | .title
14 | router-link(
15 | :to='{ name: item.slug ? "post-slug" : "post-uuid", params: { slug: item.slug, uuid: item.uuid } }'
16 | ) {{ item.title }}
17 | .author-link(v-if='!item.author.not_exist')
18 | img.avatar(:src='getAvatar(item.author.avatar)')
19 | router-link(:to='`/@${item.author.username}`') {{ item.author.nickname || item.author.username }}
20 | .post-date
21 | span.created-date(title='Created date')
22 | icon
23 | calendar-alt
24 | time {{ new Date(item.created_at).toLocaleString() }}
25 | span.edited-date(v-if='item.editor_uuid', title='Edited date')
26 | | ·
27 | icon
28 | pen-nib
29 | time {{ new Date(item.edited_at).toLocaleString() }}
30 | .actions
31 | router-link.edit-btn(
32 | :to='{ name: "edit-post", params: { uuid: item.uuid } }',
33 | v-if='userData.uuid === item.author_uuid || userData.authority >= 4'
34 | )
35 | icon
36 | pen
37 | router-link.delete-btn(
38 | v-if='userData.uuid === item.author_uuid || userData.authority >= 3'
39 | )
40 | icon
41 | trash-alt
42 |
43 |
44 |
51 |
52 |
125 |
--------------------------------------------------------------------------------
/api/comment.ts:
--------------------------------------------------------------------------------
1 | import { VercelRequest, VercelResponse } from '@vercel/node'
2 | import { DbCommentDoc, DbUserDoc } from '../src/types'
3 | import { COLNAME } from './config'
4 | import { v4 as UUID } from 'uuid'
5 | import { attachUsers, router } from './utils'
6 | import { Db } from 'mongodb'
7 | import { RouteContextDefaults } from 'serverless-kit'
8 | import { getUserModel } from './user'
9 |
10 | const COMMENT_DEFAULTS: DbCommentDoc = {
11 | target_type: 'post',
12 | target_uuid: '',
13 | uuid: '',
14 | content: '',
15 | author_uuid: '',
16 | editor_uuid: '',
17 | created_at: new Date(0),
18 | edited_at: new Date(0),
19 | is_deleted: false,
20 | }
21 |
22 | export function getCommentModel(payload: Partial) {
23 | return {
24 | ...COMMENT_DEFAULTS,
25 | ...payload,
26 | }
27 | }
28 |
29 | export default (req: VercelRequest, res: VercelResponse) => {
30 | router.endpoint('/api/comment')
31 | router.setCollection(COLNAME.COMMENT)
32 |
33 | // Get comments for target
34 | router
35 | .addRoute()
36 | .method('GET')
37 | .path(['user', 'post', 'comment'], 'target_type')
38 | .path(/.+/, 'target_uuid')
39 | .parseOffsetLimitSort()
40 | .check(checkCanCreate)
41 | .action(async (ctx) => {
42 | const filter = {
43 | target_type: ctx.params.target_type,
44 | target_uuid: ctx.params.target_uuid,
45 | }
46 |
47 | const total_comments = await ctx.col.find(filter).count()
48 | const comments = await ctx.col
49 | .find(filter)
50 | .skip(ctx.offset)
51 | .sort(ctx.sort)
52 | .limit(ctx.limit + 1)
53 | .toArray()
54 |
55 | let has_next = false
56 | if (comments.length > ctx.limit) {
57 | has_next = true
58 | comments.pop()
59 | }
60 |
61 | ctx.message = 'Get comments by filter'
62 | ctx.body = {
63 | comments: await attachUsers(ctx, comments),
64 | filter,
65 | total_comments,
66 | offset: ctx.offset,
67 | limit: ctx.limit,
68 | sort: ctx.sort,
69 | has_next,
70 | }
71 | })
72 |
73 | // Add comment
74 | router
75 | .addRoute()
76 | .method('POST')
77 | .path(['user', 'post', 'comment'], 'target_type')
78 | .path(/.+/, 'target_uuid')
79 | .checkLogin()
80 | .checkAuth(1)
81 | .check<{
82 | content: string
83 | }>((ctx) => {
84 | const content: string = ctx.req.body?.content || ''
85 | if (!content.trim()) {
86 | ctx.status = 400
87 | ctx.message = 'Missing content'
88 | return false
89 | }
90 | if (content.length > 1000) {
91 | ctx.status = 413
92 | ctx.message = 'The content should be less than 1000 words'
93 | return false
94 | }
95 | ctx.content = content
96 | })
97 | .check(checkCanCreate)
98 | .action(async (ctx) => {
99 | const comment = getCommentModel({
100 | author_uuid: ctx.user.uuid,
101 | content: ctx.content,
102 | created_at: new Date(),
103 | target_type: ctx.params.target_type as 'user' | 'post' | 'comment',
104 | target_uuid: ctx.params.target_uuid,
105 | uuid: UUID(),
106 | })
107 | const dbRes = await ctx.col.insertOne(comment)
108 |
109 | ctx.message = 'Comment created.'
110 | ctx.body = {
111 | comment: {
112 | ...comment,
113 | author: getUserModel(ctx.user, true),
114 | editor: getUserModel(null, true),
115 | },
116 | ...dbRes,
117 | }
118 | })
119 |
120 | return router.init(req, res)
121 | }
122 |
123 | async function checkCanCreate(
124 | ctx: RouteContextDefaults & { db: Db; user: DbUserDoc }
125 | ) {
126 | const target = await ctx.db
127 | .collection(COLNAME[ctx.params.target_type.toUpperCase()])
128 | .findOne({ uuid: ctx.params.target_uuid })
129 | if (!target || target.is_deleted) {
130 | ctx.status = 404
131 | ctx.message = `Requested ${ctx.params.target_type} not found.`
132 | return false
133 | } else if (
134 | target.is_private &&
135 | !target.allowed_user.includes(ctx.user.uuid)
136 | ) {
137 | ctx.status = 403
138 | ctx.message = 'Permision denied'
139 | return false
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/src/utils/FileUploader.ts:
--------------------------------------------------------------------------------
1 | import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
2 | import { nanoid } from 'nanoid'
3 |
4 | export class FileUploader {
5 | private repo!: string
6 | private token!: string
7 | request!: AxiosInstance
8 |
9 | /**
10 | * @param token e.g. `ghp_ABCabc123`
11 | * @param repo e.g. `username/reponame`
12 | */
13 | constructor(token: string, repo: string) {
14 | this.setToken(token)
15 | this.setRepo(repo)
16 | }
17 |
18 | private getRequest() {
19 | return axios.create({
20 | baseURL: 'https://api.github.com',
21 | headers: {
22 | Authorization: `Bearer ${this.token}`,
23 | },
24 | })
25 | }
26 |
27 | setToken(token: string) {
28 | this.token = token
29 | this.request = this.getRequest()
30 | return this
31 | }
32 |
33 | setRepo(repo: string) {
34 | this.repo = repo
35 | return this
36 | }
37 |
38 | private generateFilePath(name: string, ext: string) {
39 | const now = new Date()
40 | const yyyy = now.getFullYear()
41 | const mm = `0${now.getMonth() + 1}`.slice(-2)
42 | const dd = `0${now.getDate()}`.slice(-2)
43 | return `${yyyy}/${mm}/${dd}/${name}.${ext}`
44 | }
45 |
46 | async getFileBase64(
47 | file: File
48 | ): Promise<{ base64: string; url: string; ext: string }> {
49 | return new Promise((resolve) => {
50 | const reader = new FileReader()
51 | reader.readAsDataURL(file)
52 | reader.addEventListener(
53 | 'load',
54 | function () {
55 | const url = reader.result as string
56 | const base64 = url?.split(',')[1]
57 | const ext = file.type.split('/')[1]
58 | return resolve({ url, base64, ext })
59 | },
60 | false
61 | )
62 | })
63 | }
64 |
65 | async getFileList(): Promise {
66 | const { data }: any = await this.request.get(
67 | `/repos/${this.repo}/git/trees/HEAD:uploads`,
68 | {
69 | params: {
70 | recursive: 1,
71 | _random: Math.random(),
72 | },
73 | }
74 | )
75 | const list = (data.tree as FilePayload[])
76 | .filter(({ type }) => type === 'blob')
77 | .map((i) => {
78 | i.html_url = `https://github.com/${this.repo}/tree/HEAD/uploads/${i.path}`
79 | i.name = i.path.split('/').pop() as string
80 | return i
81 | })
82 | console.log(list)
83 | return list
84 | }
85 |
86 | /**
87 | * @desc Get file raw url and proxy urls
88 | */
89 | getFileUrl(payload: FilePayload) {
90 | const file = `${this.repo}/HEAD/uploads/${payload.path}`
91 | return {
92 | raw: `https://raw.githubusercontent.com/${file}`,
93 | fastgit: `https://raw.fastgit.org/${file}`,
94 | ghproxy: `https://ghproxy.com/https://raw.githubusercontent.com/${file}`,
95 | jsdelivr: `https://cdn.jsdelivr.net/gh/${this.repo}/uploads/${payload.path}`,
96 | zwc365: `https://pd.zwc365.com/https://raw.githubusercontent.com/${file}`,
97 | }
98 | }
99 |
100 | async upload(file: File): Promise> {
101 | const name = nanoid(8)
102 | const { base64, ext } = await this.getFileBase64(file)
103 | const path = this.generateFilePath(name, ext)
104 | return this.request.put(`/repos/${this.repo}/contents/uploads/${path}`, {
105 | message: `upload file "${file.name}"`,
106 | content: base64,
107 | })
108 | }
109 |
110 | async delete(payload: FilePayload): Promise<
111 | AxiosResponse<{
112 | commit: {
113 | sha: string
114 | node_id: string
115 | url: string
116 | html_url: string
117 | committer: {
118 | name: string
119 | date: string
120 | }
121 | }
122 | }>
123 | > {
124 | return this.request.delete(
125 | `/repos/${this.repo}/contents/uploads/${payload.path}`,
126 | {
127 | data: {
128 | message: `remove file "${payload.name}"`,
129 | sha: payload.sha,
130 | },
131 | }
132 | )
133 | }
134 | }
135 |
136 | export interface FilePayload {
137 | name: string
138 | path: string
139 | sha: string
140 | size: number
141 | url: string
142 | html_url: string
143 | git_url: string
144 | download_url: string
145 | type: 'tree' | 'blob'
146 | }
147 |
--------------------------------------------------------------------------------
/src/view/auth.vue:
--------------------------------------------------------------------------------
1 |
2 | #auth-container
3 | .body-inner
4 | form#auth-form.card(
5 | v-if='!isLoggedIn',
6 | :class='{ "loading-cover": loading }'
7 | )
8 | #logo-area
9 | .logo-placeholder LOGO
10 |
11 | #info-area
12 | .info.error(v-if='errorMsg')
13 | .title
14 | a.pointer(@click='errorMsg = ""', style='float: right') ×
15 | | {{ errorTitle }}
16 | p {{ errorMsg }}
17 |
18 | #tabber-area
19 | .tabber
20 | .tabber-tabs
21 | .tab.flex-1
22 | a.pointer(
23 | @click='tab = "login"',
24 | :class='{ "tab-active": tab === "login" }'
25 | ) Login
26 | .tab.flex-1
27 | a.pointer(
28 | @click='tab = "register"',
29 | :class='{ "tab-active": tab === "register" }'
30 | ) Register
31 |
32 | #login(v-if='tab === "login"')
33 | label
34 | strong Username
35 | input.site-style(v-model='username')
36 | label
37 | strong Password
38 | input.site-style(
39 | v-model='password',
40 | type='password',
41 | autocomplete='current-password'
42 | )
43 | .btn
44 | button(@click.prevent='handleLogin') Login
45 |
46 | #register(v-else)
47 | label
48 | strong Username
49 | input.site-style(v-model='username')
50 | label
51 | strong Password
52 | input.site-style(
53 | v-model='password',
54 | type='password',
55 | autocomplete='new-password'
56 | )
57 | label
58 | strong Repeat password
59 | input.site-style(
60 | v-model='repeatPassword',
61 | type='password',
62 | autocomplete='new-password'
63 | )
64 | p You cannot register at this time
65 |
66 | #user-info(v-else)
67 | .card
68 | h2 Hello, {{ userData.username }}~
69 | .align-center
70 | p Are you sure you want to log out?
71 | .btn
72 | button(@click='handleLogout') Logout
73 |
74 |
75 |
134 |
135 |
175 |
--------------------------------------------------------------------------------
/src/components/FloatToolbox.vue:
--------------------------------------------------------------------------------
1 |
2 | #float-toolbox
3 | #toolbox-show-btn.fixed-container(:class='{ "is-hide": isHide }')
4 | .btn-group
5 | a.plain.pointer(@click='isHide = false', title='Show toolbox')
6 | icon
7 | plus-circle
8 |
9 | #toolbox-main.fixed-container(:class='{ "is-hide": isHide }')
10 | .btn-group
11 | //- Settings
12 | a.plain.pointer(
13 | title='Settings',
14 | type='button',
15 | @click='showMore = !showMore'
16 | )
17 | Icon
18 | cog
19 | .btn-group(v-if='userData?.authority >= 1')
20 | //- Quick create
21 | a.plain.pointer(@click='showEdit = !showEdit', title='Quick create post')
22 | icon(
23 | :style='`transition: all 0.2s ease;${showEdit ? "transform: rotate(45deg)" : ""}`'
24 | )
25 | plus
26 | .btn-group
27 | //- Theme toggle
28 | a.plain.pointer(
29 | @click='theme === "light" ? (theme = "dark") : (theme = "light")',
30 | :title='`Switch to ${theme === "light" ? "dark" : "light"} mode`'
31 | )
32 | icon
33 | moon(v-if='theme === "light"')
34 | sun(v-else)
35 | .btn-group
36 | //- Back to top
37 | a#back-to-top.plain.pointer(title='Back to top', @click='backToTop')
38 | icon
39 | arrow-up
40 | //- Hide
41 | a.plain.pointer(@click='isHide = true', title='Hide toolbox')
42 | icon
43 | minus-circle
44 |
45 | #quick-actions-container
46 | quick-edit(v-show='showEdit', @created='handleQuickCreated')
47 |
48 |
49 |
93 |
94 |
168 |
169 |
180 |
--------------------------------------------------------------------------------
/src/view/index.vue:
--------------------------------------------------------------------------------
1 |
2 | #home-container
3 | header#home-header
4 | .inner
5 | h1.home-site-name Blog Now
6 | .meta-data
7 | p Blah, Blah, Blah, Blah, Blah...
8 | a#jump-btn.pointer.plain(@click='handleJumpToMain')
9 | icon
10 | angle-down
11 |
12 | main#home-main.body-inner
13 | .main-flex
14 | #home-post-list.flex-1(v-if='recents.length > 0')
15 | .home-post-card.card(
16 | v-for='(item, index) in recents',
17 | style='padding: 0'
18 | )
19 | .post-cover
20 | router-link.plain.title(
21 | :to='{ name: item.slug ? "post-slug" : "post-uuid", params: { slug: item.slug, uuid: item.uuid } }'
22 | )
23 | img.cover-img(
24 | :src='item.cover || "https://api.daihan.top/api/acg?_random=" + item.uuid'
25 | )
26 | .post-meta
27 | router-link.title(
28 | :to='{ name: item.slug ? "post-slug" : "post-uuid", params: { slug: item.slug, uuid: item.uuid } }'
29 | ) {{ item.title }}
30 | .author
31 | user-link(:user='item.author')
32 | .time Created at {{ new Date(item.created_at).toLocaleString() }}
33 | p.preview {{ item.content.length > 120 ? item.content.slice(0, 120) + "..." : item.content }}
34 | #home-post-list.flex-1.no-data(v-else)
35 | .card
36 | .loading
37 | placeholder
38 | global-aside
39 |
40 |
41 |
65 |
66 |
175 |
176 |
186 |
--------------------------------------------------------------------------------
/src/components/GlobalHeaderUserDropdown.vue:
--------------------------------------------------------------------------------
1 |
2 | .user-dropdown(@click.stop='')
3 | a.pointer.plain.dropdown-btn(
4 | :class='{ "is-show": userDropdownShow }',
5 | @click='userDropdownShow = !userDropdownShow'
6 | )
7 | img.avatar(:src='getAvatar(userData.avatar)')
8 | .angle
9 | icon
10 | angle-down
11 | transition(
12 | name='fade',
13 | mode='out-in',
14 | enter-active-class='fadeInUp',
15 | leave-active-class='fadeOutDown'
16 | )
17 | .dropdown-content(v-show='userDropdownShow')
18 | ul
19 | //- User Card
20 | //- Is logged in
21 | li(v-if='isLoggedIn')
22 | .nav-user-card
23 | .top
24 | .banner-bg
25 | router-link.plain.name(:to='`/@${userData.username}`')
26 | img.avatar(:src='getAvatar(userData.avatar)')
27 | .details
28 | router-link.plain.user-name(:to='`/@${userData.username}`') {{ userData.nickname || userData.username }}
29 | .uid {{ userData.title }}
30 | //- Not logged in
31 | li(v-else)
32 | .nav-user-card
33 | .top
34 | .banner-bg
35 | img.avatar(:src='getAvatar(userData.avatar)')
36 | .details
37 | router-link.plain.name(to='/auth') Guest
38 | .uid Welcome to the blog~
39 |
40 | //- Links
41 | li(v-if='isLoggedIn')
42 | router-link.plain(to='/post/new') Add new post
43 | li(v-if='userData.authority >= 4')
44 | router-link.plain(to='/dashboard') Admin dashboard
45 | li(v-if='$route.name !== "auth"')
46 | router-link.plain(
47 | :to='{ name: "auth", query: { backto: $route.path } }'
48 | ) {{ isLoggedIn ? "Logout" : "Login" }}
49 |
50 |
51 |
70 |
71 |
166 |
167 |
192 |
--------------------------------------------------------------------------------
/src/view/user.vue:
--------------------------------------------------------------------------------
1 |
2 | #user-container
3 | nav#user-header
4 |
5 | header#user-info.body-inner.flex.flex-auto.gap-1
6 | .avatar
7 | img(:src='getAvatar(user?.avatar, { width: 200 })')
8 | .user-meta.flex-1
9 | h1.nickname(v-if='!notFound && !user') Loading user...
10 | h1.nickname(v-if='user') {{ user.nickname || user.username }}
11 | h1.nickname(v-if='notFound') User not found
12 | .username(v-if='user?.nickname') @{{ user.username }}
13 | .uuid(v-if='user') UID{{ user.uid }}
14 |
15 | main#user-main.body-inner
16 | .main-flex
17 | article.card
18 | #user-loading.loading(v-if='!user && !notFound')
19 | placeholder
20 | #user-not-found(v-if='notFound') User not found
21 |
22 | #user-content(v-if='!notFound && user')
23 | #user-details
24 | p.pre {{ user.slogan || "-" }}
25 | hr
26 | #user-posts
27 | h3 Posts by user
28 | #post-loading.loading(v-if='postLoading')
29 | placeholder
30 | post-list(v-else :posts='posts')
31 |
32 | global-aside
33 |
34 |
35 |
114 |
115 |
175 |
176 |
188 |
--------------------------------------------------------------------------------
/src/styles/elements.sass:
--------------------------------------------------------------------------------
1 | // Header
2 | @mixin header-shared($shadow-color, $bg-width)
3 | position: relative
4 | text-shadow: 0px 2px 0 var(--theme-text-shadow-color)
5 | z-index: 1
6 | &::after
7 | content: ""
8 | width: $bg-width
9 | height: 0.5em
10 | display: block
11 | position: absolute
12 | background-color: $shadow-color
13 | pointer-events: none
14 | border-radius: 1em
15 | left: -2px
16 | bottom: 0px
17 | z-index: -1
18 |
19 | h1, h2, h3, h4, h5, h6
20 | font-weight: 600
21 | h1
22 | font-size: 2.2rem
23 | h2
24 | font-size: 1.8rem
25 | h3
26 | font-size: 1.5rem
27 | h4, h5, h6
28 | font-size: 1.2rem
29 |
30 | // Links
31 | a
32 | --color: var(--theme-link-color)
33 | color: var(--color)
34 | text-decoration: none
35 | position: relative
36 | display: inline-block
37 |
38 | &.plain
39 | display: unset
40 |
41 | &:not(.plain)::after
42 | content: ''
43 | display: block
44 | position: absolute
45 | width: 100%
46 | height: 0.1em
47 | bottom: -0.1em
48 | left: 0
49 | background-color: var(--color)
50 | visibility: hidden
51 | transform: scaleX(0)
52 | transition: all 0.4s ease-in-out
53 |
54 | &:not(.plain):hover::after,
55 | &.router-link-active::after,
56 | &.tab-active::after,
57 | &.is-active::after
58 | visibility: visible
59 | transform: scaleX(1)
60 |
61 | &.button
62 | padding: 0.2rem 0.4rem
63 | background-color: var(--theme-tag-color)
64 | transition: all .4s ease
65 | cursor: pointer
66 |
67 | &:hover
68 | background-color: rgba(var(--theme-link-color--rgb), 1)
69 | color: var(--theme-accent-link-color)
70 |
71 | hr,
72 | .hr
73 | border: none
74 | border-bottom: 4px dotted rgba(var(--theme-accent-color--rgb), 0.5)
75 | margin: 1rem 0
76 |
77 | // Button
78 | button
79 | display: inline-block
80 | padding: 0.4rem 0.8rem
81 | font-size: 1rem
82 | border: none
83 | border-radius: 4px
84 | background-color: var(--theme-accent-color)
85 | color: #fff
86 | cursor: pointer
87 |
88 | &:hover
89 | background-color: var(--theme-accent-color-darken)
90 |
91 | &:focus
92 | background-color: var(--theme-accent-color)
93 | box-shadow: 0 0 0 2px #fff inset, 0 0 0 2px var(--theme-accent-color)
94 |
95 | &:disabled
96 | background-color: #ccc
97 | cursor: not-allowed
98 |
99 | &:active
100 | box-shadow: 0 0 0 2px #fff inset, 0 0 0 2px #ccc
101 |
102 | // Card
103 | .card
104 | background-color: #fff
105 | border-radius: 8px
106 | padding: 1rem
107 | transition: box-shadow .6s ease
108 | box-shadow: var(--theme-box-shadow)
109 |
110 | &.gap
111 | margin-top: 1rem
112 | margin-bottom: 1.4rem
113 |
114 | &:hover
115 | box-shadow: var(--theme-box-shadow-hover)
116 |
117 | // Tags
118 | .tags-list
119 | line-height: 1.6
120 | .tag
121 | line-height: 1em
122 | display: inline-block
123 | padding: 2px 4px
124 | margin-right: 0.4rem
125 | background-color: var(--theme-tag-color)
126 | &.router-link-active
127 | color: #fff
128 | background-color: var(--theme-link-color)
129 |
130 | input.site-style,
131 | textarea.site-style
132 | width: 100%
133 | padding: 0.4em 0.75em
134 | font-size: 1rem
135 | line-height: 1em
136 | border: none
137 | border-radius: 0.5em
138 | background-color: rgba(0, 0, 0, 0.04)
139 | outline: none
140 | &:hover
141 | background-color: rgba(0, 0, 0, 0.08)
142 | box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.25)
143 | &:focus
144 | background-color: rgba(0, 0, 0, 0.04)
145 | box-shadow: 0 0 0 2px var(--theme-accent-color)
146 |
147 | // Responsive
148 | .responsive,
149 | .body-inner
150 | padding-left: 5%
151 | padding-right: 5%
152 | max-width: 1200px
153 | margin-left: auto
154 | margin-right: auto
155 |
156 | // Table
157 | table
158 | width: 100%
159 | max-width: 100%
160 | margin-bottom: 20px
161 | border-spacing: 0
162 | border-collapse: collapse
163 | box-shadow: 0 0 8px rgba(0, 0, 0, 0.1)
164 | border-radius: 8px
165 |
166 | tr:nth-of-type(2n)
167 | background-color: rgba(var(--theme-accent-color--rgb), 0.1)
168 |
169 | td
170 | padding: 0.5rem
171 | border-bottom: 1px solid #ccc
172 |
173 | // Loading
174 | .loading-cover
175 | position: relative
176 | &::before,&::after
177 | content: ""
178 | width: 100%
179 | height: 100%
180 | display: block
181 | position: absolute
182 | &::before
183 | background-image: url(/images/spinner.svg)
184 | background-size: 75px
185 | background-repeat: no-repeat
186 | background-position: center
187 | top: 50%
188 | left: 50%
189 | transform: translateX(-50%) translateY(-50%)
190 | z-index: 6
191 | &::after
192 | top: 0
193 | left: 0
194 | background-color: rgba(200,200,200,0.2)
195 | z-index: 5
196 |
197 | .xicon
198 | vertical-align: middle
199 |
200 | // Code
201 | pre:not(.hljs)
202 | overflow: auto
203 | background: rgba(172, 232, 255, 0.2)
204 | padding: 0.4rem
205 | border: 1px solid #cccccc
206 | border-radius: 6px
207 |
208 | code
209 | background-color: #efefef
210 | display: inline
211 | border-radius: 2px
212 | padding: .1rem .2rem
213 | color: #e02080
214 | word-break: break-word
215 |
216 | // Tabber
217 | .tabber
218 | .tabber-tabs
219 | margin-bottom: 1rem
220 | display: flex
221 | gap: 1rem
222 | a
223 | cursor: pointer
224 |
--------------------------------------------------------------------------------
/src/view/edit-post.vue:
--------------------------------------------------------------------------------
1 |
2 | #edit-post-container
3 | main#edit-main
4 | .body-inner
5 | h1 {{ isCreate ? "Create new post" : "Edit post" }}
6 | .bread-crumb(v-if='uuid')
7 | router-link(:to='{ name: "post-uuid", params: { uuid } }') ← back to post
8 | .edit-area(:class='{ "loading-cover": loading }')
9 | //- title
10 | .title-area
11 | label
12 | strong Title
13 | input.title-input(v-model='title')
14 |
15 | //- content
16 | .content-input.flex.gap-1
17 | .text-area.flex-1
18 | label(for='content')
19 | strong Content
20 | v-md-editor#content(
21 | v-model='content',
22 | height='70vh',
23 | left-toolbar='undo redo | h bold italic strikethrough quote tip | ul ol table hr | link image code | emoji',
24 | right-toolbar='preview toc fullscreen | save',
25 | :disabled-menus='["image/upload-image", "h/h1"]',
26 | @save='handleSubmit'
27 | )
28 |
29 | //- slug
30 | .slug-area
31 | label
32 | strong Slug
33 | input.slug-input.site-style(
34 | v-model='slug',
35 | @blur='slug = slugify(slug, { lower: true })'
36 | )
37 |
38 | .btn-area
39 | .info.warn(v-if='!canEdit')
40 | .title {{ isLoggedIn ? "No permision" : "Authority error" }}
41 | p(v-if='!isLoggedIn')
42 | | Please
43 | |
44 | router-link(:to='{ name: "auth", query: { backto: $route.path } }') Login
45 | p(v-else) Please contact site admin
46 | .info.error(v-if='error')
47 | .title Submit failed
48 | a.pointer(style='float: right', @click='error = ""') ×
49 | p {{ error }}
50 | button(v-if='canEdit', @click='handleSubmit', :disabled='loading') {{ isCreate ? "Publish" : "Update" }}
51 |
52 |
53 |
161 |
162 |
196 |
--------------------------------------------------------------------------------
/api/utils.ts:
--------------------------------------------------------------------------------
1 | import { VercelRequest, VercelResponse } from '@vercel/node'
2 | import { Collection, Db, MongoClient } from 'mongodb'
3 | import {
4 | HandleRouter,
5 | HandleResponse,
6 | Route,
7 | getProjectSrotFromStr,
8 | } from 'serverless-kit'
9 | import { SITE_ENV } from '../src/config'
10 | import { ApiAttachedUser } from '../src/types'
11 | import { DbPostDoc, DbUserDoc } from '../src/types/Database'
12 | import { COLNAME, getLocalConfig } from './config'
13 | import { getUserModel, TOKEN_COOKIE_NAME } from './user'
14 |
15 | // type gymnastics
16 | declare module '../node_modules/serverless-kit/lib/modules/HandleRouter' {
17 | interface HandleRouter {
18 | setCollection: (col: string) => HandleRouter<{ col: Collection }>
19 | }
20 | interface Route {
21 | checkAuth: (required: number) => Route
22 | checkLogin: () => Route
23 | parseOffsetLimitSort: () => Route<
24 | RouteContextDefaults &
25 | ContextT & {
26 | offset: number
27 | limit: number
28 | sort: Record
29 | }
30 | >
31 | }
32 | }
33 |
34 | HandleRouter.prototype.setCollection = function (colName) {
35 | return this.beforeEach((ctx) => {
36 | ctx.col = ctx.db.collection(colName)
37 | })
38 | }
39 |
40 | Route.prototype.checkAuth = function (required) {
41 | return this.check((ctx) => {
42 | if (ctx.user.authority < required) {
43 | ctx.status = 403
44 | ctx.message = 'Permission denied'
45 | ctx.body = {
46 | authcheck: {
47 | required,
48 | recived: ctx.user.authority,
49 | },
50 | }
51 | return false
52 | }
53 | })
54 | }
55 |
56 | Route.prototype.checkLogin = function () {
57 | return this.check((ctx) => {
58 | if (!ctx.user.uuid || ctx.user.uid < 0) {
59 | ctx.status = 401
60 | ctx.message = 'Please login'
61 | return false
62 | }
63 | })
64 | }
65 |
66 | Route.prototype.parseOffsetLimitSort = function () {
67 | return this.check((ctx) => {
68 | ctx.offset = parseInt((ctx.req.query.offset as string) || '0')
69 | ctx.limit = Math.min(25, parseInt((ctx.req.query.limit as string) || '10'))
70 | ctx.sort = getProjectSrotFromStr((ctx.req.query.sort as string) || '')
71 | })
72 | }
73 |
74 | // Constuct a router
75 | const router = new HandleRouter<{
76 | mongoClient: MongoClient
77 | db: Db
78 | col: Collection
79 | user: DbUserDoc
80 | }>()
81 | // Make sure the body exists
82 | router.beforeEach((ctx) => {
83 | ctx.body = ctx.body || {}
84 | ctx.req.body = ctx.req.body || {}
85 | })
86 | // Connect db
87 | router.beforeEach(initMongo)
88 | // Pre fetch userData
89 | router.beforeEach(initUserData)
90 | // Close db
91 | router.afterEach(closeMongo)
92 | export { router }
93 | export default (req: VercelRequest, res: VercelResponse) => {
94 | return router.init(req, res)
95 | }
96 |
97 | export async function initMongo(ctx: any) {
98 | const client = new MongoClient(
99 | getLocalConfig('MONGO_URI') || 'mongodb://localhost'
100 | )
101 | const db = client.db(getLocalConfig('BLOGNOW_DB') || 'blog_now')
102 | try {
103 | await client.connect()
104 | } catch (e) {
105 | ctx.status = 501
106 | ctx.message =
107 | 'Unable to connect to the database or the MONGO_URI is incorrectly configured.'
108 | return false
109 | }
110 | console.log('DB connected')
111 | ctx.mongoClient = client
112 | ctx.db = db
113 | }
114 |
115 | export function initCol(ctx: any, colName: string) {
116 | ctx.col = ctx.db.collection(colName)
117 | }
118 |
119 | export async function closeMongo(ctx: any) {
120 | await ctx.mongoClient.close()
121 | console.log('DB closed')
122 | }
123 |
124 | export async function initUserData(ctx: any) {
125 | const token = getTokenFromReq(ctx.req)
126 | const col = (ctx.db as Db).collection(COLNAME.USER)
127 | const user = await col.findOne({
128 | token,
129 | token_expires: { $gt: Date.now() },
130 | })
131 | ctx.user = getUserModel(user)
132 | }
133 |
134 | export async function checkLogin(ctx: any) {
135 | if (!ctx.user.uuid || ctx.user.uid < 0) {
136 | ctx.status = 401
137 | ctx.message = 'Please login'
138 | return false
139 | }
140 | }
141 |
142 | export function checkAuth(required: number, ctx: any) {
143 | if (ctx.user.authority < required) {
144 | ctx.status = 403
145 | ctx.message = 'Permission denied'
146 | ctx.body = {
147 | authcheck: {
148 | required,
149 | recived: ctx.user.authority,
150 | },
151 | }
152 | return false
153 | }
154 | }
155 |
156 | export function handleInvalidController(http: HandleResponse) {
157 | return http.send(400, `Invalid controller: ${http.req.query.CONTROLLER}`, {})
158 | }
159 |
160 | export function handleInvalidScope(http: HandleResponse) {
161 | return http.send(400, `Invalid scope: ${http.req.query.SCOPE}`, {})
162 | }
163 |
164 | export function handleMissingParams(http: HandleResponse) {
165 | http.send(400, 'Missing params')
166 | }
167 |
168 | export function getTokenFromReq(req: VercelRequest) {
169 | return (
170 | (req.query.token as string) ||
171 | req.headers.authorization ||
172 | req.cookies[TOKEN_COOKIE_NAME] ||
173 | ''
174 | )
175 | }
176 |
177 | export async function attachUsers(
178 | ctx: { db: Db },
179 | docs: T[]
180 | ): Promise<(T & ApiAttachedUser)[]> {
181 | if (docs.length < 1) return []
182 | const findList: string[] = []
183 | docs.forEach(({ author_uuid, editor_uuid }) => {
184 | findList.push(author_uuid, editor_uuid)
185 | })
186 | const users = (await ctx.db
187 | .collection(COLNAME.USER)
188 | .find({
189 | $or: unique(findList)
190 | .filter((i) => !!i)
191 | .map((uuid) => ({ uuid })),
192 | })
193 | .toArray()) as DbUserDoc[]
194 | return docs.map((i: any) => {
195 | i.author = getUserModel(
196 | users.find(({ uuid }) => uuid === i.author_uuid),
197 | true
198 | )
199 | i.editor = getUserModel(
200 | users.find(({ uuid }) => uuid === i.editor_uuid),
201 | true
202 | )
203 | return i
204 | })
205 | }
206 |
207 | export function sortKeys(obj: T): T {
208 | const copy = {} as T
209 | const allKeys = Object.keys(obj).sort()
210 | allKeys.forEach((key) => {
211 | copy[key] = obj[key]
212 | })
213 | return copy
214 | }
215 |
216 | export function unique(arr: T[]) {
217 | return Array.from(new Set(arr))
218 | }
219 |
--------------------------------------------------------------------------------
/public/images/spinner.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/api/post.ts:
--------------------------------------------------------------------------------
1 | import { v4 as UUID } from 'uuid'
2 | import { DbPostDoc, DbUserDoc } from '../src/types/Database'
3 | import { COLNAME } from './config'
4 | import { attachUsers, router, sortKeys } from './utils'
5 | import slugify from 'slugify'
6 | import { VercelRequest, VercelResponse } from '@vercel/node'
7 | import { RouteContextDefaults } from 'serverless-kit'
8 | import { Db } from 'mongodb'
9 |
10 | export const POSTDATA_DEFAULTS: DbPostDoc = {
11 | uuid: '',
12 | pid: 0,
13 | slug: '',
14 | title: '',
15 | content: '',
16 | created_at: new Date(0),
17 | author_uuid: '',
18 | edited_at: new Date(0),
19 | editor_uuid: '',
20 | allow_comment: true,
21 | is_deleted: false,
22 | deleted_by: '',
23 | is_private: false,
24 | allowed_users: [],
25 | allowed_authority: 0,
26 | }
27 |
28 | export function getPostModel(payload: Partial) {
29 | const post = sortKeys({
30 | ...POSTDATA_DEFAULTS,
31 | ...payload,
32 | })
33 | post.slug = slugify(post.slug, { lower: true })
34 | post.created_at = new Date(post.created_at)
35 | post.edited_at =
36 | new Date(post.edited_at).getTime() !== 0
37 | ? new Date(post.edited_at)
38 | : new Date(post.created_at)
39 | return post
40 | }
41 |
42 | export default (req: VercelRequest, res: VercelResponse) => {
43 | router.endpoint('/api/post')
44 | router.setCollection(COLNAME.POST)
45 |
46 | // GET /post/:selector/:scope
47 | router
48 | .addRoute()
49 | .method('GET')
50 | .path(['uuid', 'pid', 'slug'], 'selector')
51 | .path(/.+/, 'target')
52 | .action(async (ctx) => {
53 | const filter = {
54 | [ctx.params.selector]:
55 | ctx.params.selector === 'pid'
56 | ? parseInt(ctx.params.target)
57 | : ctx.params.target,
58 | }
59 |
60 | const post = await ctx.col.findOne(filter)
61 |
62 | if (!post) {
63 | ctx.status = 404
64 | ctx.message = 'Post not found'
65 | ctx.body = {
66 | filter,
67 | post: null,
68 | }
69 | return
70 | } else {
71 | ctx.message = 'Get post by filter'
72 | }
73 |
74 | const [post1] = await attachUsers(ctx, [post])
75 |
76 | ctx.body = { post: post1, filter }
77 | })
78 |
79 | // GET /post/list/recent
80 | router
81 | .addRoute()
82 | .method('GET')
83 | .path('list')
84 | .path(/recents?/)
85 | .check<{
86 | offset: number
87 | limit: number
88 | }>((ctx) => {
89 | ctx.offset = parseInt((ctx.req.query.offset as string) || '0')
90 | ctx.limit = Math.min(
91 | 25,
92 | parseInt((ctx.req.query.limit as string) || '10')
93 | )
94 | })
95 | .action(async (ctx) => {
96 | const posts = await ctx.col
97 | .find()
98 | .sort({ pid: -1 })
99 | .skip(ctx.offset)
100 | .limit(ctx.limit)
101 | .toArray()
102 |
103 | let has_next = false
104 | if (posts.length > ctx.limit) {
105 | has_next = true
106 | posts.pop()
107 | }
108 |
109 | ctx.body = {
110 | posts: await attachUsers(ctx, posts.map(getPostModel)),
111 | has_next,
112 | limit: ctx.limit,
113 | offset: ctx.offset,
114 | }
115 | })
116 |
117 | // POST /post/create
118 | router
119 | .addRoute()
120 | .method('POST')
121 | .path(['create', 'new'])
122 | .checkLogin()
123 | .checkAuth(2)
124 | .check((ctx) => {
125 | ctx.req.body = ctx.req.body || {}
126 | const { title, content } = ctx.req.body
127 | if (title === undefined || content === undefined) {
128 | ctx.status = 400
129 | ctx.message = 'Missing params'
130 | return false
131 | }
132 | })
133 | .check(async (ctx) => {
134 | ctx.req.body.slug = slugify(ctx.req.body.slug || '', { lower: true })
135 | const slug = ctx.req.body.slug
136 | if (slug && (await ctx.col.findOne({ slug }))) {
137 | ctx.status = 409
138 | ctx.message = 'Slug has been taken'
139 | return false
140 | }
141 | })
142 | .action(async (ctx) => {
143 | const { title, content, slug } = ctx.req.body
144 | const now = new Date()
145 |
146 | const [lastPost] = await ctx.col
147 | .find()
148 | .project({ pid: 1 })
149 | .sort({ pid: -1 })
150 | .limit(1)
151 | .toArray()
152 |
153 | const pid = isNaN(lastPost?.pid)
154 | ? (await ctx.col.countDocuments()) + 1
155 | : (lastPost.pid as number) + 1
156 | const uuid = UUID()
157 |
158 | const insert: DbPostDoc = getPostModel({
159 | uuid,
160 | pid,
161 | title,
162 | content,
163 | slug,
164 | author_uuid: ctx.user.uuid,
165 | created_at: now,
166 | })
167 | const dbRes = await ctx.col.insertOne(insert)
168 |
169 | ctx.message = 'Post created'
170 | ctx.body = { ...dbRes, uuid }
171 | })
172 |
173 | // PATCH /post/:selector/:scope
174 | router
175 | .addRoute()
176 | .method('PATCH')
177 | .path(['uuid', 'pid', 'slug'], 'selector')
178 | .path(/.+/, 'target')
179 | .checkLogin()
180 | // Validate body
181 | .check((ctx) => {
182 | ctx.req.body = ctx.req.body || {}
183 | const { title, content } = ctx.req.body
184 | if (title === undefined || content === undefined) {
185 | ctx.status = 400
186 | ctx.message = 'Missing params'
187 | return false
188 | }
189 | })
190 | // Find target post
191 | .check<{
192 | post: DbPostDoc
193 | }>(async (ctx) => {
194 | const filter = {
195 | [ctx.params.selector]:
196 | ctx.params.selector === 'pid'
197 | ? parseInt(ctx.params.target)
198 | : ctx.params.target,
199 | }
200 | ctx.post = (await ctx.col.findOne(filter)) as DbPostDoc
201 |
202 | if (!ctx.post) {
203 | ctx.status = 404
204 | ctx.message = 'Post not found'
205 | return false
206 | }
207 | })
208 | // User authentication
209 | .check((ctx) => {
210 | if (ctx.post.author_uuid !== ctx.user.uuid && ctx.user.authority < 4) {
211 | ctx.status = 403
212 | ctx.message = 'Permission denied'
213 | return false
214 | }
215 | })
216 | // Check slug conflict
217 | .check(async (ctx) => {
218 | ctx.req.body.slug = slugify(ctx.req.body.slug || '', { lower: true })
219 | const slug = ctx.req.body.slug
220 |
221 | if (slug && slug !== ctx.post.slug && (await ctx.col.findOne({ slug }))) {
222 | ctx.status = 409
223 | ctx.message = 'Slug has been taken'
224 | return false
225 | }
226 | })
227 | // Update db
228 | .action(async (ctx) => {
229 | const { title, content, slug } = ctx.req.body
230 | const now = new Date()
231 |
232 | const dbRes = await ctx.col.updateOne(
233 | { uuid: ctx.post.uuid },
234 | {
235 | $set: {
236 | title,
237 | content,
238 | slug,
239 | edited_at: now.toISOString(),
240 | editor_uuid: ctx.user.uuid,
241 | },
242 | }
243 | )
244 |
245 | ctx.message = 'Post updated'
246 | ctx.body = {
247 | ...dbRes,
248 | uuid: ctx.post.uuid,
249 | pid: ctx.post.pid,
250 | slug: slug ?? ctx.post.slug,
251 | }
252 | })
253 |
254 | return router.init(req, res)
255 | }
256 |
257 | async function checkCanView(
258 | ctx: RouteContextDefaults & { db: Db; user: DbUserDoc; post: DbPostDoc }
259 | ) {
260 | let display_reason
261 |
262 | if (ctx.post.is_deleted) {
263 | if (
264 | ctx.user.uuid === ctx.post.author_uuid &&
265 | ctx.user.uuid === ctx.post.deleted_by
266 | ) {
267 | display_reason = 'deleted_by_self'
268 | } else if (ctx.user.authority >= 3) {
269 | display_reason = 'moderator'
270 | } else {
271 | ctx.status = 404
272 | ctx.message = 'Post not found'
273 | return false
274 | }
275 | }
276 |
277 | if (ctx.post.is_private) {
278 | if (ctx.user.uuid === ctx.post.author_uuid) {
279 | display_reason = 'author'
280 | } else if (ctx.post.allowed_users.includes(ctx.user.uuid)) {
281 | display_reason = 'allowed_user'
282 | } else if (ctx.user.authority = 4) {
283 | display_reason = 'moderator'
284 | } else {
285 | ctx.status = 404
286 | ctx.message = 'Private post'
287 | return false
288 | }
289 | }
290 |
291 | ctx.customBody = {
292 | display_reason,
293 | }
294 | }
295 |
296 | async function checkCanDelete(
297 | ctx: RouteContextDefaults & { db: Db; user: DbUserDoc; post: DbPostDoc }
298 | ) {
299 | if (ctx.post.author_uuid === ctx.user.uuid) {
300 | // Is author
301 | } else {
302 | // Not author
303 | if (ctx.user.authority < 3) {
304 | ctx.status = 403
305 | ctx.message = 'Permission denied'
306 | return false
307 | }
308 | }
309 |
310 | if (ctx.post.is_deleted) {
311 | ctx.status = 406
312 | ctx.message = 'The post has already been deleted'
313 | return false
314 | }
315 | }
316 |
--------------------------------------------------------------------------------
/api/user.ts:
--------------------------------------------------------------------------------
1 | import { DbAuthorityKeys, DbUserDoc } from '../src/types/Database'
2 | import { COLNAME } from './config'
3 | import { attachUsers, router, unique } from './utils'
4 | import * as crypto from 'crypto'
5 | import { v4 as UUID } from 'uuid'
6 | import { nanoid } from 'nanoid'
7 | import {
8 | passwordStrength,
9 | defaultOptions as passwordStrengthOptions,
10 | } from 'check-password-strength'
11 | import { VercelRequest, VercelResponse } from '@vercel/node'
12 | import { getPostModel } from './post'
13 |
14 | /**
15 | * @desc authority ```
16 | * 0: Everyone
17 | * 1: Member
18 | * 2: Editor
19 | * 3: Moderator
20 | * 4: System Operator
21 | * ```
22 | */
23 | export const AUTHORITY_DEFAULTS: Record = {
24 | post_create: 2,
25 | post_delete_any: 3,
26 | post_edit_any: 4,
27 | comment_create: 0,
28 | comment_delete_any: 3,
29 | comment_edit_any: 4,
30 | comment_protect: 3,
31 | user_admin: 4,
32 | user_block: 3,
33 | user_group_edit: 4,
34 | user_register: 0,
35 | user_unblock_self: 4,
36 | site_admin: 4,
37 | }
38 |
39 | export const TOKEN_COOKIE_NAME = 'BLOG_NOW_TOKEN'
40 |
41 | export const USERDATA_DEFAULTS: DbUserDoc = {
42 | allow_comment: true,
43 | authority: 0,
44 | avatar: '',
45 | created_at: new Date(0),
46 | gender: 'other',
47 | nickname: '',
48 | slogan: '',
49 | title: '',
50 | uid: -1,
51 | username: '',
52 | uuid: '',
53 | // sensitive
54 | email: '',
55 | password_hash: '',
56 | salt: '',
57 | token: '',
58 | token_expires: 0,
59 | }
60 |
61 | export const PASSWORD_STRENGTH: 0 | 1 | 2 | 3 = 1
62 |
63 | export function getUserModel(
64 | payload: Partial,
65 | removeSensitive?: boolean
66 | ) {
67 | const data: DbUserDoc & { avatar: string; not_exist?: boolean } = {
68 | ...USERDATA_DEFAULTS,
69 | ...payload,
70 | }
71 |
72 | data.created_at = new Date(data.created_at)
73 |
74 | // Handle avatar
75 | const email_hash = crypto
76 | .createHash('md5')
77 | .update(data.email || '')
78 | .digest('hex')
79 | data.avatar = `https://gravatar.loli.net/avatar/${email_hash}`
80 |
81 | if (data.uid < 0) data.not_exist = true
82 |
83 | if (removeSensitive) {
84 | delete data.email
85 | delete data.password_hash
86 | delete data.token
87 | delete data.token_expires
88 | delete data.salt
89 | }
90 | return data
91 | }
92 |
93 | // Utils
94 | function getPasswordHash(salt: string, password: string) {
95 | return crypto
96 | .createHash('sha256')
97 | .update(`salt=${salt},password=${password}`)
98 | .digest('hex')
99 | }
100 |
101 | function trimUsername(str: string) {
102 | return str
103 | .replace(/^[\s_\-\.~]+/, '')
104 | .replace(/[\s_\-\.~]+$/, '')
105 | .replace(/\s+/g, ' ')
106 | }
107 |
108 | export default (req: VercelRequest, res: VercelResponse) => {
109 | router.endpoint('/api/user')
110 | router.setCollection(COLNAME.USER)
111 |
112 | // Get single user
113 | router
114 | .addRoute()
115 | .method('GET')
116 | .path(['uuid', 'uid', 'username'], 'selector')
117 | .path(/.+/, 'target')
118 | .action(async (ctx) => {
119 | const filter = {
120 | [ctx.params.selector]:
121 | ctx.params.selector === 'uid'
122 | ? parseInt(ctx.params.target)
123 | : ctx.params.target,
124 | }
125 |
126 | const user = await ctx.col.findOne(filter)
127 |
128 | if (!user) {
129 | ctx.status = 404
130 | ctx.message = 'User not found'
131 | ctx.body = {
132 | filter,
133 | user: null,
134 | }
135 | return
136 | }
137 |
138 | ctx.status = 200
139 | ctx.message = 'Get user by filter'
140 | ctx.body = {
141 | user: getUserModel(user, true),
142 | filter,
143 | }
144 | })
145 |
146 | // Get users list
147 | router
148 | .addRoute()
149 | .method('GET')
150 | .endpoint('/api/users')
151 | .path(['uuid', 'uid', 'username'], 'selector')
152 | .path(/.+/, 'rawList')
153 | .action(async (ctx) => {
154 | const list = unique(ctx.params.rawList.split(/[|,]/).map((i) => i.trim()))
155 | if (list.length > 25) {
156 | ctx.customBody = {
157 | info: 'Too many requests, only the first 25 users are returned',
158 | has_next: true,
159 | next_uuids: list.slice(25 - list.length),
160 | }
161 | }
162 | const find = {
163 | $or: list.slice(0, 25).map((i) => ({
164 | [ctx.params.selector]:
165 | ctx.params.selector === 'uid' ? parseInt(i) : i,
166 | })),
167 | }
168 | const users = await ctx.col.find(find).toArray()
169 | ctx.message = 'Get users'
170 | ctx.body = {
171 | users: users.map((i) => getUserModel(i, true)),
172 | uuids: list.slice(0, 25),
173 | }
174 | })
175 |
176 | // Verify current user
177 | router
178 | .addRoute()
179 | .method('GET')
180 | .path('auth')
181 | .path('profile')
182 | .check((ctx) => {
183 | if (!ctx.user.uuid || ctx.user.uid < 0) {
184 | ctx.status = 401
185 | ctx.message = 'Please login'
186 | ctx.body = {
187 | profile: getUserModel(ctx.user, true),
188 | }
189 | return false
190 | }
191 | })
192 | .action(async (ctx) => {
193 | ctx.body = {
194 | profile: getUserModel(ctx.user, true),
195 | }
196 | })
197 |
198 | router
199 | .addRoute()
200 | .method('POST')
201 | .path('auth')
202 | .path('register')
203 | .check((ctx) => {
204 | const { username, password } = ctx.req.body || {}
205 | if (!username || !password) {
206 | ctx.status = 400
207 | ctx.message = 'Missing params'
208 | return false
209 | }
210 | })
211 | .check<{
212 | username: string
213 | password: string
214 | }>((ctx) => {
215 | let { username, password } = ctx.req.body || {}
216 |
217 | // Trim username
218 | username = trimUsername(username)
219 |
220 | const usernameTest = /^[_\-\.~\s0-9A-Za-z\u0080-\uFFFF]+$/
221 | if (!usernameTest.test(username) || username.length <= 5) {
222 | ctx.status = 400
223 | ctx.message = 'Invalid username.'
224 | ctx.body = {
225 | invalid_item: 'username',
226 | item_standard: username,
227 | item_test: false,
228 | item_required: {
229 | allowed_symbols: ['_', '-', '.', '~', ' '],
230 | regexp: usernameTest.toString(),
231 | min_length: 5,
232 | },
233 | }
234 | return false
235 | }
236 |
237 | const passwordTest = passwordStrength(password)
238 | if (passwordTest.id < PASSWORD_STRENGTH) {
239 | ctx.status = 400
240 | ctx.message = 'Password is too weak.'
241 | ctx.body = {
242 | invalid_item: 'password',
243 | item_test: passwordTest,
244 | item_required: passwordStrengthOptions[PASSWORD_STRENGTH],
245 | }
246 | return false
247 | }
248 |
249 | ctx.username = username
250 | ctx.password = password
251 | })
252 | .check(async (ctx) => {
253 | const { username } = ctx
254 | const already = await ctx.col.findOne({
255 | username: new RegExp(username, 'i'),
256 | })
257 | if (already) {
258 | ctx.status = 409
259 | ctx.message = 'Username has been taken'
260 | return false
261 | }
262 | })
263 | .action(async (ctx) => {
264 | const { username, password } = ctx
265 |
266 | const [lastUser] = (await ctx.col
267 | .find()
268 | .sort({ uid: -1 })
269 | .project({ uid: 1 })
270 | .limit(1)
271 | .toArray()) as DbUserDoc[]
272 | const uid = isNaN(lastUser?.uid)
273 | ? (await ctx.col.countDocuments()) + 10000
274 | : lastUser.uid + 1
275 |
276 | const salt = nanoid(32)
277 | const insert: DbUserDoc = getUserModel({
278 | authority: 1,
279 | username,
280 | uid,
281 | uuid: UUID(),
282 | password_hash: getPasswordHash(salt, password),
283 | salt,
284 | created_at: new Date(),
285 | })
286 |
287 | const dbRes = await ctx.col.insertOne(insert)
288 |
289 | ctx.message = 'User created'
290 | ctx.body = {
291 | ...dbRes,
292 | username,
293 | }
294 | })
295 |
296 | router
297 | .addRoute()
298 | .method('POST')
299 | .path('auth')
300 | .path(/(log-?in|sign-?in)/)
301 | .check((ctx) => {
302 | const { username, password } = ctx.req.body || {}
303 | if (!username || !password) {
304 | ctx.status = 400
305 | ctx.message = 'Missing params'
306 | return false
307 | }
308 | })
309 | .action(async (ctx) => {
310 | const { username, password } = ctx.req.body || {}
311 | const profile = await ctx.col.findOne({
312 | username,
313 | })
314 | if (!profile) {
315 | ctx.status = 403
316 | ctx.message = 'Invalid username'
317 | return false
318 | }
319 | const password_hash = getPasswordHash(profile.salt, password)
320 | if (password_hash !== profile.password_hash) {
321 | ctx.status = 403
322 | ctx.message = 'Invalid password'
323 | return false
324 | }
325 | const token =
326 | profile.token_expires - Date.now() < 0 ? nanoid(32) : profile.token
327 | const token_expires = Date.now() + 7 * 24 * 60 * 60 * 1000
328 | await ctx.col.updateOne(
329 | { uuid: profile.uuid },
330 | { $set: { token, token_expires } }
331 | )
332 |
333 | ctx.res.setHeader(
334 | 'set-cookie',
335 | `${TOKEN_COOKIE_NAME}=${token}; expires=${new Date(
336 | token_expires
337 | ).toUTCString()}; path=/`
338 | )
339 |
340 | ctx.body = { token, profile: getUserModel(profile, true) }
341 | })
342 |
343 | // Get user posts
344 | router
345 | .addRoute()
346 | .method('GET')
347 | .path(['uuid', 'uid', 'username'], 'selector')
348 | .path(/.+/, 'target')
349 | .path(/posts?/)
350 | .check<{
351 | offset: number
352 | limit: number
353 | }>((ctx) => {
354 | ctx.offset = parseInt((ctx.req.query.offset as string) || '0')
355 | ctx.limit = Math.min(
356 | 25,
357 | parseInt((ctx.req.query.limit as string) || '10')
358 | )
359 | })
360 | .check<{ author_uuid: string }>(async (ctx) => {
361 | const user = (await ctx.db.collection(COLNAME.USER).findOne({
362 | [ctx.params.selector]:
363 | ctx.params.selector === 'uid'
364 | ? parseInt(ctx.params.target)
365 | : ctx.params.target,
366 | })) as DbUserDoc
367 | if (!user) {
368 | ctx.status = 404
369 | ctx.message = 'Reqested user not found'
370 | ctx.body = {
371 | posts: [],
372 | }
373 | return false
374 | }
375 | ctx.author_uuid = user.uuid
376 | })
377 | .action(async (ctx) => {
378 | const posts = await ctx.db
379 | .collection(COLNAME.POST)
380 | .find({ author_uuid: ctx.author_uuid })
381 | .sort({ pid: -1 })
382 | .skip(ctx.offset)
383 | .limit(ctx.limit)
384 | .toArray()
385 |
386 | let has_next = false
387 | if (posts.length > ctx.limit) {
388 | has_next = true
389 | posts.pop()
390 | }
391 |
392 | ctx.body = {
393 | posts: await attachUsers(ctx, posts.map(getPostModel)),
394 | has_next,
395 | limit: ctx.limit,
396 | offset: ctx.offset,
397 | }
398 | })
399 |
400 | return router.init(req, res)
401 | }
402 |
--------------------------------------------------------------------------------
/src/view/post.vue:
--------------------------------------------------------------------------------
1 |
2 | #post-container
3 | header#post-header
4 | .inner
5 | h1#post-title {{ post ? post.title : notFound ? "Post Not Found" : "Blog Post" }}
6 | #post-meta(v-if='post')
7 | .create-date Created at
8 | .edited-date(v-if='post.edited_at !== post.created_at') Edited at
9 | #post-meta(v-if='!post')
10 | .desc {{ notFound ? "Oops..." : "Now loading" + dots }}
11 |
12 | main#post-main.body-inner
13 | .bread-crumb.card
14 | icon
15 | home
16 | router-link(to='/')
17 | | Home
18 | icon
19 | grip-lines-vertical
20 | router-link(to='/archive') Posts
21 | icon
22 | angle-right
23 | | {{ notFound ? "404" : post ? post.title : "loading" + dots }}
24 |
25 | .main-flex
26 | article.card
27 | .loading(v-if='!post && !notFound')
28 | placeholder
29 |
30 | #post-content
31 | v-md-editor(
32 | v-if='post',
33 | v-model='post.content',
34 | mode='preview',
35 | @change='handleContentUpdated'
36 | )
37 |
38 | #post-not-found(v-if='notFound')
39 | .align-center
40 | h2 That's four-oh-four
41 | .flex.flex-column.gap-1
42 | router-link.big-link.plain(
43 | v-if='userData && userData.authority >= 2',
44 | to='/post/new'
45 | )
46 | .icon(style='font-size: 5rem')
47 | icon
48 | edit
49 | .desc Add new post
50 | .flex.gap-1
51 | router-link.big-link.plain(to='/')
52 | .icon(style='font-size: 4rem')
53 | icon
54 | home
55 | .desc Take me home
56 | router-link.big-link.plain(to='/archives')
57 | .icon(style='font-size: 4rem')
58 | icon
59 | folder-open
60 | .desc View all posts
61 |
62 | #post-after(v-if='post')
63 | hr
64 | #comment-area
65 | h4 Latest commets
66 | .loading(v-if='commentsLoading && comments.length < 1')
67 | placeholder
68 | #comments-main(v-else)
69 | comment-edit(
70 | target_type='post',
71 | :target_uuid='post.uuid',
72 | :author_uuid='post.author_uuid',
73 | @created='handleCommentCreated'
74 | )
75 | .desc Total {{ total_comments }} {{ total_comments > 1 ? "comments" : "comment" }}
76 | comment-list(:comments='comments')
77 |
78 | #post-tools-container
79 | #post-tools(v-if='post')
80 | router-link.plain.tool-btn(
81 | :to='{ name: "edit-post", params: { uuid: post.uuid } }'
82 | )
83 | icon
84 | pen(v-if='userData.authority >= 2')
85 | code-icon(v-else)
86 | .tooltip {{ userData.authority >= 2 ? "Edit this post" : "View source" }}
87 | button#post-float-menu-btn.tool-btn(
88 | v-if='titles.length >= 3',
89 | @click.stop='menuShow = !menuShow'
90 | )
91 | icon
92 | bars
93 | .tooltip(v-if='!menuShow') Toggle menu
94 | transition(
95 | name='fade',
96 | mode='out-in',
97 | enter-active-class='fadeInUp',
98 | leave-active-class='fadeOutDown'
99 | )
100 | #post-float-menu.flex.flex-column(
101 | v-show='menuShow',
102 | @click.stop='',
103 | :class='{ "menu-show": menuShow }'
104 | )
105 | strong Table of Contents
106 | ul.flex-1
107 | li(v-for='item in titles', :indent='item.indent')
108 | a.plain(
109 | @click='handleAnchorClick(item.line)',
110 | :style='{ "padding-left": `calc(0.4rem + ${item.indent * 0.8}rem)` }'
111 | ) {{ item.title }}
112 | button.tool-btn
113 | icon
114 | trash-alt
115 | .tooltip Delete this post
116 | global-aside
117 | template(#top)
118 | author-card(
119 | v-if='post',
120 | :author='post.author',
121 | :editor='post.editor'
122 | )
123 | template(#default)
124 | .card.site-style(v-if='post')
125 | h4 Meta data
126 | .flex-list
127 | .list-item
128 | .key UUID
129 | .val {{ post.uuid }}
130 | .list-item
131 | .key Post ID
132 | .val {{ post.pid }}
133 | .list-item
134 | .key Slug
135 | .val {{ post.slug }}
136 | .list-item
137 | .key Created at
138 | .val {{ new Date(post.created_at).toLocaleString() }}
139 |
140 |
141 |
306 |
307 |
448 |
449 |
459 |
--------------------------------------------------------------------------------