diff --git a/client/components/admin.vue b/client/components/admin.vue index 3c35e55a..65f93558 100644 --- a/client/components/admin.vue +++ b/client/components/admin.vue @@ -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') }, diff --git a/client/components/admin/admin-comments.vue b/client/components/admin/admin-comments.vue new file mode 100644 index 00000000..0c9aea56 --- /dev/null +++ b/client/components/admin/admin-comments.vue @@ -0,0 +1,205 @@ + + 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" : ""' + ) + + + + + diff --git a/client/static/svg/icon-chat-bubble.svg b/client/static/svg/icon-chat-bubble.svg new file mode 100644 index 00000000..e292cb7a --- /dev/null +++ b/client/static/svg/icon-chat-bubble.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/server/core/kernel.js b/server/core/kernel.js index 8961f08e..ebe7b7ea 100644 --- a/server/core/kernel.js +++ b/server/core/kernel.js @@ -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() diff --git a/server/db/migrations-sqlite/2.3.14.js b/server/db/migrations-sqlite/2.3.14.js new file mode 100644 index 00000000..c1ecd694 --- /dev/null +++ b/server/db/migrations-sqlite/2.3.14.js @@ -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 => { } diff --git a/server/db/migrations/2.3.14.js b/server/db/migrations/2.3.14.js new file mode 100644 index 00000000..05706e44 --- /dev/null +++ b/server/db/migrations/2.3.14.js @@ -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 => { } diff --git a/server/graph/resolvers/comment.js b/server/graph/resolvers/comment.js index a05c72e5..bee35aa6 100644 --- a/server/graph/resolvers/comment.js +++ b/server/graph/resolvers/comment.js @@ -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) + } + } + } } diff --git a/server/graph/schemas/comment.graphql b/server/graph/schemas/comment.graphql new file mode 100644 index 00000000..216623e8 --- /dev/null +++ b/server/graph/schemas/comment.graphql @@ -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] +} diff --git a/server/models/commentProviders.js b/server/models/commentProviders.js new file mode 100644 index 00000000..bfdd6636 --- /dev/null +++ b/server/models/commentProviders.js @@ -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() + } + } + } +} diff --git a/server/modules/comments/commento/code.yml b/server/modules/comments/commento/code.yml new file mode 100644 index 00000000..c0a90dfe --- /dev/null +++ b/server/modules/comments/commento/code.yml @@ -0,0 +1,4 @@ +main: | +
+bodyEnd: | + diff --git a/server/modules/comments/commento/definition.yml b/server/modules/comments/commento/definition.yml new file mode 100644 index 00000000..9e484953 --- /dev/null +++ b/server/modules/comments/commento/definition.yml @@ -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 + diff --git a/server/modules/comments/default/definition.yml b/server/modules/comments/default/definition.yml new file mode 100644 index 00000000..3ad3ee8e --- /dev/null +++ b/server/modules/comments/default/definition.yml @@ -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 diff --git a/server/modules/comments/disqus/code.yml b/server/modules/comments/disqus/code.yml new file mode 100644 index 00000000..5faec766 --- /dev/null +++ b/server/modules/comments/disqus/code.yml @@ -0,0 +1,15 @@ +main: | + +bodyEnd: | + diff --git a/server/modules/comments/disqus/definition.yml b/server/modules/comments/disqus/definition.yml new file mode 100644 index 00000000..6ef9d9a7 --- /dev/null +++ b/server/modules/comments/disqus/definition.yml @@ -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