feat: admin comments page

This commit is contained in:
NGPixel 2020-04-06 22:21:42 -04:00 committed by Nicolas Giard
parent bb21f6ed3e
commit 1def5289af
14 changed files with 514 additions and 39 deletions

View File

@ -62,8 +62,8 @@
v-list-item(to='/auth', color='primary')
v-list-item-avatar(size='24', tile): v-icon mdi-lock-outline
v-list-item-title {{ $t('admin:auth.title') }}
v-list-item(to='/comments', disabled)
v-list-item-avatar(size='24', tile): v-icon(color='grey lighten-2') mdi-comment-text-outline
v-list-item(to='/comments')
v-list-item-avatar(size='24', tile): v-icon mdi-comment-text-outline
v-list-item-title {{ $t('admin:comments.title') }}
v-list-item(to='/editor', disabled)
v-list-item-avatar(size='24', tile): v-icon(color='grey lighten-2') mdi-playlist-edit
@ -171,6 +171,7 @@ const router = new VueRouter({
{ path: '/users/:id(\\d+)', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-users-edit.vue') },
{ path: '/analytics', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-analytics.vue') },
{ path: '/auth', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-auth.vue') },
{ path: '/comments', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-comments.vue') },
{ path: '/rendering', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-rendering.vue') },
{ path: '/editor', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-editor.vue') },
{ path: '/extensions', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-extensions.vue') },

View File

@ -0,0 +1,205 @@
<template lang='pug'>
v-container(fluid, grid-list-lg)
v-layout(row, wrap)
v-flex(xs12)
.admin-header
img.animated.fadeInUp(src='/svg/icon-chat-bubble.svg', alt='Comments', style='width: 80px;')
.admin-header-title
.headline.primary--text.animated.fadeInLeft {{$t('admin:comments.title')}}
.subtitle-1.grey--text.animated.fadeInLeft.wait-p2s {{$t('admin:comments.subtitle')}}
v-spacer
v-btn.mx-1.animated.fadeInDown.wait-p2s(outlined, color='grey', @click='refresh', large)
v-icon mdi-refresh
v-btn.ml-1.animated.fadeInDown(color='success', @click='save', depressed, large)
v-icon(left) mdi-check
span {{$t('common:actions.apply')}}
v-flex(lg3, xs12)
v-card.animated.fadeInUp
v-toolbar(flat, color='primary', dark, dense)
.subtitle-1 {{$t('admin:comments.providers')}}
v-list.py-0(two-line, dense)
template(v-for='(provider, idx) in providers')
v-list-item(:key='provider.key', @click='selectedProvider = provider.key', :disabled='!provider.isAvailable')
v-list-item-avatar(size='24')
v-icon(color='grey', v-if='!provider.isAvailable') mdi-minus-box-outline
v-icon(color='primary', v-else-if='provider.key === selectedProvider') mdi-checkbox-marked-circle-outline
v-icon(color='grey', v-else) mdi-checkbox-blank-circle-outline
v-list-item-content
v-list-item-title.body-2(:class='!provider.isAvailable ? `grey--text` : (selectedProvider === provider.key ? `primary--text` : ``)') {{ provider.title }}
v-list-item-subtitle: .caption(:class='!provider.isAvailable ? `grey--text text--lighten-1` : (selectedProvider === provider.key ? `blue--text ` : ``)') {{ provider.description }}
v-list-item-avatar(v-if='selectedProvider === provider.key', size='24')
v-icon.animated.fadeInLeft(color='primary', large) mdi-chevron-right
v-divider(v-if='idx < providers.length - 1')
v-flex(lg9, xs12)
v-card.animated.fadeInUp.wait-p2s
v-toolbar(color='primary', dense, flat, dark)
.subtitle-1 {{provider.title}}
v-card-text
.providerlogo
img(:src='provider.logo', :alt='provider.title')
.caption.pt-3 {{provider.description}}
.caption.pb-3: a(:href='provider.website') {{provider.website}}
v-divider.mt-3
.overline.my-5 {{$t('admin:comments.providerConfig')}}
.body-2.ml-3(v-if='!provider.config || provider.config.length < 1'): em {{$t('admin:comments.providerNoConfig')}}
template(v-else, v-for='cfg in provider.config')
v-select(
v-if='cfg.value.type === "string" && cfg.value.enum'
outlined
:items='cfg.value.enum'
:key='cfg.key'
:label='cfg.value.title'
v-model='cfg.value.value'
prepend-icon='mdi-cog-box'
:hint='cfg.value.hint ? cfg.value.hint : ""'
persistent-hint
:class='cfg.value.hint ? "mb-2" : ""'
)
v-switch.mb-3(
v-else-if='cfg.value.type === "boolean"'
:key='cfg.key'
:label='cfg.value.title'
v-model='cfg.value.value'
color='primary'
prepend-icon='mdi-cog-box'
:hint='cfg.value.hint ? cfg.value.hint : ""'
persistent-hint
inset
)
v-textarea(
v-else-if='cfg.value.type === "string" && cfg.value.multiline'
outlined
:key='cfg.key'
:label='cfg.value.title'
v-model='cfg.value.value'
prepend-icon='mdi-cog-box'
:hint='cfg.value.hint ? cfg.value.hint : ""'
persistent-hint
:class='cfg.value.hint ? "mb-2" : ""'
)
v-text-field(
v-else
outlined
:key='cfg.key'
:label='cfg.value.title'
v-model='cfg.value.value'
prepend-icon='mdi-cog-box'
:hint='cfg.value.hint ? cfg.value.hint : ""'
persistent-hint
:class='cfg.value.hint ? "mb-2" : ""'
)
</template>
<script>
import _ from 'lodash'
import gql from 'graphql-tag'
export default {
data() {
return {
providers: [],
selectedProvider: '',
provider: {}
}
},
watch: {
selectedProvider(newValue, oldValue) {
this.provider = _.find(this.providers, ['key', newValue]) || {}
},
providers(newValue, oldValue) {
this.selectedProvider = _.get(_.find(this.providers, 'isEnabled'), 'key', 'db')
}
},
methods: {
async refresh() {
await this.$apollo.queries.providers.refetch()
this.$store.commit('showNotification', {
message: this.$t('admin:comments.listRefreshSuccess'),
style: 'success',
icon: 'cached'
})
},
async save() {
this.$store.commit(`loadingStart`, 'admin-comments-saveproviders')
try {
const resp = await this.$apollo.mutate({
mutation: gql``,
variables: {
providers: this.providers.map(tgt => ({
isEnabled: tgt.key === this.selectedProvider,
key: tgt.key,
config: tgt.config.map(cfg => ({...cfg, value: JSON.stringify({ v: cfg.value.value })}))
}))
}
})
if (_.get(resp, 'data.comments.updateEngines.responseResult.succeeded', false)) {
this.$store.commit('showNotification', {
message: this.$t('admin:comments.configSaveSuccess'),
style: 'success',
icon: 'check'
})
} else {
throw new Error(_.get(resp, 'data.comments.updateEngines.responseResult.message', this.$t('common:error.unexpected')))
}
} catch (err) {
this.$store.commit('pushGraphError', err)
}
this.$store.commit(`loadingStop`, 'admin-comments-saveengines')
}
},
apollo: {
providers: {
query: gql`
query {
comments {
providers {
isEnabled
key
title
description
logo
website
isAvailable
config {
key
value
}
}
}
}
`,
fetchPolicy: 'network-only',
update: (data) => _.cloneDeep(data.comments.providers).map(str => ({
...str,
config: _.sortBy(str.config.map(cfg => ({
...cfg,
value: JSON.parse(cfg.value)
})), [t => t.value.order])
})),
watchLoading (isLoading) {
this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-comments-refresh')
}
}
}
}
</script>
<style lang='scss' scoped>
.providerlogo {
width: 250px;
height: 85px;
float:right;
display: flex;
justify-content: flex-end;
align-items: center;
img {
max-width: 100%;
max-height: 50px;
}
}
</style>

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="96px" height="96px"><linearGradient id="r6aF76sThYJjByxe12PMJa" x1="26.5" x2="26.5" y1="16.5" y2="26.237" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#6dc7ff"/><stop offset="1" stop-color="#e6abff"/></linearGradient><path fill="url(#r6aF76sThYJjByxe12PMJa)" d="M32.832,25H20.168C19.523,25,19,24.477,19,23.832v-5.665C19,17.523,19.523,17,20.168,17 h12.665C33.477,17,34,17.523,34,18.168v5.665C34,24.477,33.477,25,32.832,25z"/><linearGradient id="r6aF76sThYJjByxe12PMJb" x1="32" x2="32" y1="9.553" y2="54.562" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1a6dff"/><stop offset="1" stop-color="#c822ff"/></linearGradient><path fill="url(#r6aF76sThYJjByxe12PMJb)" d="M49,9H15c-2.758,0-5,2.242-5,4.996v28C10,44.754,12.242,47,15,47h15c0.551,0,1,0.445,1,0.996 v4.102c0,1.133,0.656,2.137,1.719,2.617c0.422,0.191,0.863,0.289,1.301,0.289c0.719,0,1.426-0.258,1.996-0.754l8.074-7.008 C44.273,47.086,44.508,47,44.746,47H49c2.758,0,5-2.246,5-5.004v-28C54,11.242,51.758,9,49,9z M52,41.996 C52,43.652,50.652,45,49,45h-4.254c-0.723,0-1.422,0.262-1.969,0.734l-8.074,7.008c-0.383,0.328-0.848,0.293-1.156,0.152 C33.383,52.82,33,52.594,33,52.098v-4.102C33,46.344,31.652,45,30,45H15c-1.652,0-3-1.348-3-3.004v-28C12,12.344,13.348,11,15,11 h34c1.652,0,3,1.344,3,2.996V41.996z"/><linearGradient id="r6aF76sThYJjByxe12PMJc" x1="32" x2="32" y1="9.553" y2="54.562" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1a6dff"/><stop offset="1" stop-color="#c822ff"/></linearGradient><rect width="26" height="2" x="19" y="29" fill="url(#r6aF76sThYJjByxe12PMJc)"/><linearGradient id="r6aF76sThYJjByxe12PMJd" x1="28.5" x2="28.5" y1="9.553" y2="54.562" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1a6dff"/><stop offset="1" stop-color="#c822ff"/></linearGradient><rect width="19" height="2" x="19" y="33" fill="url(#r6aF76sThYJjByxe12PMJd)"/><linearGradient id="r6aF76sThYJjByxe12PMJe" x1="32" x2="32" y1="9.553" y2="54.562" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1a6dff"/><stop offset="1" stop-color="#c822ff"/></linearGradient><rect width="26" height="2" x="19" y="37" fill="url(#r6aF76sThYJjByxe12PMJe)"/></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -69,6 +69,7 @@ module.exports = {
async postBootMaster() {
await WIKI.models.analytics.refreshProvidersFromDisk()
await WIKI.models.authentication.refreshStrategiesFromDisk()
await WIKI.models.commentProviders.refreshProvidersFromDisk()
await WIKI.models.editors.refreshEditorsFromDisk()
await WIKI.models.loggers.refreshLoggersFromDisk()
await WIKI.models.renderers.refreshRenderersFromDisk()

View File

@ -0,0 +1,10 @@
exports.up = knex => {
return knex.schema
.createTable('commentProviders', table => {
table.string('key').notNullable().primary()
table.boolean('isEnabled').notNullable().defaultTo(false)
table.json('config').notNullable()
})
}
exports.down = knex => { }

View File

@ -0,0 +1,16 @@
/* global WIKI */
exports.up = knex => {
const dbCompat = {
charset: (WIKI.config.db.type === `mysql` || WIKI.config.db.type === `mariadb`)
}
return knex.schema
.createTable('commentProviders', table => {
if (dbCompat.charset) { table.charset('utf8mb4') }
table.string('key').notNullable().primary()
table.boolean('isEnabled').notNullable().defaultTo(false)
table.json('config').notNullable()
})
}
exports.down = knex => { }

View File

@ -1,39 +1,57 @@
const _ = require('lodash')
const graphHelper = require('../../helpers/graph')
/* global WIKI */
module.exports = {
// Query: {
// comments(obj, args, context, info) {
// return WIKI.models.Comment.findAll({ where: args })
// }
// },
// Mutation: {
// createComment(obj, args) {
// return WIKI.models.Comment.create({
// content: args.content,
// author: args.userId,
// document: args.documentId
// })
// },
// deleteComment(obj, args) {
// return WIKI.models.Comment.destroy({
// where: {
// id: args.id
// },
// limit: 1
// })
// },
// modifyComment(obj, args) {
// return WIKI.models.Comment.update({
// content: args.content
// }, {
// where: { id: args.id }
// })
// }
// },
// Comment: {
// author(cm) {
// return cm.getAuthor()
// },
// document(cm) {
// return cm.getDocument()
// }
// }
Query: {
async comments() { return {} }
},
Mutation: {
async comments() { return {} }
},
CommentQuery: {
async providers(obj, args, context, info) {
const providers = await WIKI.models.commentProviders.getProviders()
return providers.map(provider => {
const providerInfo = _.find(WIKI.data.commentProviders, ['key', provider.key]) || {}
return {
...providerInfo,
...provider,
config: _.sortBy(_.transform(provider.config, (res, value, key) => {
const configData = _.get(providerInfo.props, key, false)
if (configData) {
res.push({
key,
value: JSON.stringify({
...configData,
value
})
})
}
}, []), 'key')
}
})
}
},
CommentMutation: {
async updateProviders(obj, args, context) {
try {
for (let provider of args.providers) {
await WIKI.models.providers.query().patch({
isEnabled: provider.isEnabled,
config: _.reduce(provider.config, (result, value, key) => {
_.set(result, `${value.key}`, _.get(JSON.parse(value.value), 'v', null))
return result
}, {})
}).where('key', provider.key)
}
return {
responseResult: graphHelper.generateSuccess('Comment Providers updated successfully')
}
} catch (err) {
return graphHelper.generateError(err)
}
}
}
}

View File

@ -0,0 +1,52 @@
# ===============================================
# COMMENT
# ===============================================
extend type Query {
comments: CommentQuery
}
extend type Mutation {
comments: CommentMutation
}
# -----------------------------------------------
# QUERIES
# -----------------------------------------------
type CommentQuery {
providers: [CommentProvider] @auth(requires: ["manage:system"])
}
# -----------------------------------------------
# MUTATIONS
# -----------------------------------------------
type CommentMutation {
updateProviders(
providers: [CommentProviderInput]
): DefaultResponse @auth(requires: ["manage:system"])
rebuildIndex: DefaultResponse @auth(requires: ["manage:system"])
}
# -----------------------------------------------
# TYPES
# -----------------------------------------------
type CommentProvider {
isEnabled: Boolean!
key: String!
title: String!
description: String
logo: String
website: String
isAvailable: Boolean
config: [KeyValuePair]
}
input CommentProviderInput {
isEnabled: Boolean!
key: String!
config: [KeyValuePairInput]
}

View File

@ -0,0 +1,100 @@
const Model = require('objection').Model
const fs = require('fs-extra')
const path = require('path')
const _ = require('lodash')
const yaml = require('js-yaml')
const commonHelper = require('../helpers/common')
/* global WIKI */
/**
* CommentProvider model
*/
module.exports = class CommentProvider extends Model {
static get tableName() { return 'commentProviders' }
static get idColumn() { return 'key' }
static get jsonSchema () {
return {
type: 'object',
required: ['key', 'isEnabled'],
properties: {
key: {type: 'string'},
isEnabled: {type: 'boolean'}
}
}
}
static get jsonAttributes() {
return ['config']
}
static async getProvider(key) {
return WIKI.models.commentProviders.query().findOne({ key })
}
static async getProviders(isEnabled) {
const providers = await WIKI.models.commentProviders.query().where(_.isBoolean(isEnabled) ? { isEnabled } : {})
return _.sortBy(providers, ['key'])
}
static async refreshProvidersFromDisk() {
let trx
try {
const dbProviders = await WIKI.models.commentProviders.query()
// -> Fetch definitions from disk
const authDirs = await fs.readdir(path.join(WIKI.SERVERPATH, 'modules/comments'))
let diskProviders = []
for (let dir of authDirs) {
const def = await fs.readFile(path.join(WIKI.SERVERPATH, 'modules/comments', dir, 'definition.yml'), 'utf8')
diskProviders.push(yaml.safeLoad(def))
}
WIKI.data.commentProviders = diskProviders.map(engine => ({
...engine,
props: commonHelper.parseModuleProps(engine.props)
}))
let newProviders = []
for (let engine of WIKI.data.commentProviders) {
if (!_.some(dbProviders, ['key', engine.key])) {
newProviders.push({
key: engine.key,
isEnabled: engine.key === 'default',
config: _.transform(engine.props, (result, value, key) => {
_.set(result, key, value.default)
return result
}, {})
})
} else {
const engineConfig = _.get(_.find(dbProviders, ['key', engine.key]), 'config', {})
await WIKI.models.commentProviders.query().patch({
config: _.transform(engine.props, (result, value, key) => {
if (!_.has(result, key)) {
_.set(result, key, value.default)
}
return result
}, engineConfig)
}).where('key', engine.key)
}
}
if (newProviders.length > 0) {
trx = await WIKI.models.Objection.transaction.start(WIKI.models.knex)
for (let engine of newProviders) {
await WIKI.models.commentProviders.query(trx).insert(engine)
}
await trx.commit()
WIKI.logger.info(`Loaded ${newProviders.length} new comment providers: [ OK ]`)
} else {
WIKI.logger.info(`No new comment providers found: [ SKIPPED ]`)
}
} catch (err) {
WIKI.logger.error(`Failed to scan or load new comment providers: [ FAILED ]`)
WIKI.logger.error(err)
if (trx) {
trx.rollback()
}
}
}
}

View File

@ -0,0 +1,4 @@
main: |
<div id="commento"></div>
bodyEnd: |
<script defer src="{{instanceUrl}}/js/commento.js"></script>

View File

@ -0,0 +1,17 @@
key: commento
title: Commento
description: A fast, privacy-focused commenting platform.
author: requarks.io
logo: https://static.requarks.io/logo/commento.svg
website: https://commento.io/
displayMode: footer
codeTemplate: true
isAvailable: true
props:
instanceUrl:
type: String
title: Instance URL
default: 'https://cdn.commento.io'
hint: The URL (without a trailing slash) to the Commento instance. Leave the default https://cdn.commento.io if using the cloud-hosted version.
order: 1

View File

@ -0,0 +1,19 @@
key: default
title: Default
description: Built-in advanced comments tool.
author: requarks.io
logo: https://static.requarks.io/logo/wikijs-butterfly.svg
website: https://wiki.js.org
displayMode: dynamic
codeTemplate: false
isAvailable: true
props:
displayMode:
type: String
title: Display mode
default: 'page'
enum:
- inline
- page
hint: Whether to display the comments under the content (inline) or on a dedicated page (page).
order: 1

View File

@ -0,0 +1,15 @@
main: |
<div id="disqus_thread"></div>
bodyEnd: |
<script>
var disqus_config = function () {
this.page.url = {{pageUrl}};
this.page.identifier = {{pageId}};
};
(function() {
var d = document, s = d.createElement('script');
s.src = 'https://{{shortName}}.disqus.com/embed.js';
s.setAttribute('data-timestamp', +new Date());
(d.head || d.body).appendChild(s);
})();
</script>

View File

@ -0,0 +1,16 @@
key: disqus
title: Disqus
description: Disqus help publishers power online discussions with comments.
author: requarks.io
logo: https://static.requarks.io/logo/disqus.svg
website: https://disqus.com/
displayMode: footer
codeTemplate: true
isAvailable: true
props:
accountName:
type: String
title: Shortname
default: ''
hint: Unique identifier from Disqus to identify your website
order: 1