diff --git a/client/client-app.js b/client/client-app.js index 1f3bda40..79a86a2f 100644 --- a/client/client-app.js +++ b/client/client-app.js @@ -163,9 +163,10 @@ Vue.component('not-found', () => import(/* webpackChunkName: "not-found" */ './c Vue.component('page-selector', () => import(/* webpackPrefetch: true, webpackChunkName: "ui-extra" */ './components/common/page-selector.vue')) Vue.component('profile', () => import(/* webpackChunkName: "profile" */ './components/profile.vue')) Vue.component('register', () => import(/* webpackChunkName: "register" */ './components/register.vue')) -Vue.component('v-card-chin', () => import(/* webpackPrefetch: true, webpackChunkName: "ui-extra" */ './components/common/v-card-chin.vue')) Vue.component('search-results', () => import(/* webpackPrefetch: true, webpackChunkName: "ui-extra" */ './components/common/search-results.vue')) +Vue.component('tags', () => import(/* webpackChunkName: "tags" */ './components/tags.vue')) Vue.component('unauthorized', () => import(/* webpackChunkName: "unauthorized" */ './components/unauthorized.vue')) +Vue.component('v-card-chin', () => import(/* webpackPrefetch: true, webpackChunkName: "ui-extra" */ './components/common/v-card-chin.vue')) Vue.component('welcome', () => import(/* webpackChunkName: "welcome" */ './components/welcome.vue')) Vue.component('nav-footer', () => import(/* webpackChunkName: "theme-page" */ './themes/' + process.env.CURRENT_THEME + '/components/nav-footer.vue')) diff --git a/client/components/admin/admin-analytics.vue b/client/components/admin/admin-analytics.vue index 6d046d12..5960db81 100644 --- a/client/components/admin/admin-analytics.vue +++ b/client/components/admin/admin-analytics.vue @@ -37,6 +37,15 @@ v-card.animated.fadeInUp.wait-p2s v-toolbar(color='primary', dense, flat, dark) .subtitle-1 {{provider.title}} + v-spacer + v-switch( + dark + color='blue lighten-5' + label='Active' + v-model='provider.isEnabled' + hide-details + inset + ) v-card-text v-form .analytic-provider-logo @@ -68,6 +77,7 @@ prepend-icon='mdi-settings-box' :hint='cfg.value.hint ? cfg.value.hint : ""' persistent-hint + inset ) v-textarea( v-else-if='cfg.value.type === "string" && cfg.value.multiline' diff --git a/client/components/admin/admin-auth.vue b/client/components/admin/admin-auth.vue index 5b6bdbb6..abfcaa41 100644 --- a/client/components/admin/admin-auth.vue +++ b/client/components/admin/admin-auth.vue @@ -63,10 +63,19 @@ ) v-flex(xs12, lg9) - v-card.animated.fadeInUp.wait-p2s v-toolbar(color='primary', dense, flat, dark) .subtitle-1 {{strategy.title}} + v-spacer + v-switch( + dark + color='blue lighten-5' + label='Active' + v-model='strategy.isEnabled' + hide-details + inset + :disabled='strategy.key === `local`' + ) v-card-text v-form .authlogo @@ -104,6 +113,7 @@ prepend-icon='mdi-settings-box' :hint='cfg.value.hint ? cfg.value.hint : ""' persistent-hint + inset ) v-textarea( v-else-if='cfg.value.type === "string" && cfg.value.multiline' @@ -136,6 +146,7 @@ color='primary' :hint='$t(`admin:auth.selfRegistrationHint`)' persistent-hint + inset ) v-switch.ml-3( v-if='strategy.key === `local`' @@ -145,6 +156,7 @@ color='primary' hint='Protects against spam robots and malicious registrations.' persistent-hint + inset ) v-combobox.ml-3.mt-3( :label='$t(`admin:auth.domainsWhitelist`)' @@ -187,6 +199,7 @@ color='primary' :hint='$t(`admin:auth.force2faHint`)' persistent-hint + inset ) v-card.mt-4.wiki-form.animated.fadeInUp.wait-p4s diff --git a/client/components/admin/admin-dashboard.vue b/client/components/admin/admin-dashboard.vue index eb887ce9..d1ba7163 100644 --- a/client/components/admin/admin-dashboard.vue +++ b/client/components/admin/admin-dashboard.vue @@ -19,7 +19,7 @@ easing='easeOutQuint' ) v-flex(xs12 md6 lg4 xl3 d-flex) - v-card.indigo.lighten-1.dashboard-card.animated.fadeInUp.wait-p2s(dark) + v-card.green.lighten-1.dashboard-card.animated.fadeInUp.wait-p2s(dark) v-card-text v-icon.dashboard-icon mdi-account .overline {{$t('admin:dashboard.users')}} @@ -30,7 +30,7 @@ easing='easeOutQuint' ) v-flex(xs12 md6 lg4 xl3 d-flex) - v-card.indigo.lighten-2.dashboard-card.animated.fadeInUp.wait-p4s(dark) + v-card.indigo.lighten-1.dashboard-card.animated.fadeInUp.wait-p4s(dark) v-card-text v-icon.dashboard-icon mdi-account-group .overline {{$t('admin:dashboard.groups')}} diff --git a/client/components/admin/admin-dev-flags.vue b/client/components/admin/admin-dev-flags.vue index c24bf0a7..6c41fe2b 100644 --- a/client/components/admin/admin-dev-flags.vue +++ b/client/components/admin/admin-dev-flags.vue @@ -23,6 +23,7 @@ persistent-hint label='LDAP Debug' v-model='flags.ldapdebug' + inset ) v-divider.mt-3 v-switch.mt-3( @@ -31,6 +32,7 @@ persistent-hint label='SQL Query Logging' v-model='flags.sqllog' + inset ) diff --git a/client/components/admin/admin-general.vue b/client/components/admin/admin-general.vue index d7a800f4..50eff359 100644 --- a/client/components/admin/admin-general.vue +++ b/client/components/admin/admin-general.vue @@ -98,6 +98,7 @@ v-chip(label, color='white', small).indigo--text coming soon v-card-text v-switch( + inset label='Asset Image Optimization' color='indigo' v-model='config.featureTinyPNG' @@ -118,6 +119,7 @@ v-divider.mt-3 v-switch( + inset label='Page Ratings' color='indigo' v-model='config.featurePageRatings' @@ -128,6 +130,7 @@ v-divider.mt-3 v-switch( + inset label='Page Comments' color='indigo' v-model='config.featurePageComments' @@ -138,6 +141,7 @@ v-divider.mt-3 v-switch( + inset label='Personal Wikis' color='indigo' v-model='config.featurePersonalWikis' @@ -152,6 +156,7 @@ v-card-text v-alert(outlined, color='red darken-2', icon='mdi-information-outline').body-2 Make sure to understand the implications before turning on / off a security feature. v-switch.mt-3( + inset label='Block IFrame Embedding' color='red darken-2' v-model='config.securityIframe' @@ -160,6 +165,7 @@ ) v-divider.mt-3 v-switch( + inset label='Same Origin Referrer Policy' color='red darken-2' v-model='config.securityReferrerPolicy' @@ -169,6 +175,7 @@ v-divider.mt-3 v-switch( + inset label='Enforce HSTS' color='red darken-2' v-model='config.securityHSTS' @@ -191,6 +198,7 @@ v-divider.mt-3 v-switch( + inset label='Enforce CSP' color='red darken-2' v-model='config.securityCSP' diff --git a/client/components/admin/admin-locale.vue b/client/components/admin/admin-locale.vue index 9cbeb5da..59cfcb3a 100644 --- a/client/components/admin/admin-locale.vue +++ b/client/components/admin/admin-locale.vue @@ -40,6 +40,7 @@ v-list-item-subtitle(v-html='data.item.nativeName') v-divider.mt-3 v-switch( + inset v-model='autoUpdate' :label='$t("admin:locale.autoUpdate.label")' color='primary' @@ -52,6 +53,7 @@ v-toolbar-title.subtitle-1 {{ $t('admin:locale.namespacing') }} v-card-text v-switch( + inset v-model='namespacing' :label='$t("admin:locale.namespaces.label")' color='primary' diff --git a/client/components/admin/admin-mail.vue b/client/components/admin/admin-mail.vue index ab23679f..35e64144 100644 --- a/client/components/admin/admin-mail.vue +++ b/client/components/admin/admin-mail.vue @@ -64,6 +64,7 @@ persistent-hint :hint='$t(`admin:mail.smtpTLSHint`)' prepend-icon='mdi-security-network' + inset ) v-text-field.mt-3( outlined @@ -94,6 +95,7 @@ :label='$t(`admin:mail.dkimUse`)' color='primary' prepend-icon='mdi-key' + inset ) v-text-field( outlined diff --git a/client/components/admin/admin-rendering.vue b/client/components/admin/admin-rendering.vue index 7d03b054..03cb4152 100644 --- a/client/components/admin/admin-rendering.vue +++ b/client/components/admin/admin-rendering.vue @@ -82,6 +82,7 @@ label='Enabled' v-model='currentRenderer.isEnabled' hide-details + inset ) v-card-text.pb-4.pt-2.pl-4 .overline.my-5 Rendering Module Configuration @@ -106,6 +107,7 @@ color='primary' :hint='cfg.value.hint ? cfg.value.hint : ""' persistent-hint + inset ) v-text-field( v-else diff --git a/client/components/admin/admin-search.vue b/client/components/admin/admin-search.vue index ddbd49da..1ead08ba 100644 --- a/client/components/admin/admin-search.vue +++ b/client/components/admin/admin-search.vue @@ -69,6 +69,7 @@ prepend-icon='mdi-settings-box' :hint='cfg.value.hint ? cfg.value.hint : ""' persistent-hint + inset ) v-textarea( v-else-if='cfg.value.type === "string" && cfg.value.multiline' diff --git a/client/components/admin/admin-storage.vue b/client/components/admin/admin-storage.vue index a11ca4f5..af711e1f 100644 --- a/client/components/admin/admin-storage.vue +++ b/client/components/admin/admin-storage.vue @@ -80,6 +80,15 @@ v-card.wiki-form.animated.fadeInUp.wait-p2s v-toolbar(color='primary', dense, flat, dark) .subtitle-1 {{target.title}} + v-spacer + v-switch( + dark + color='blue lighten-5' + label='Active' + v-model='target.isEnabled' + hide-details + inset + ) v-card-text v-form .targetlogo @@ -115,6 +124,7 @@ prepend-icon='mdi-settings-box' :hint='cfg.value.hint ? cfg.value.hint : ""' persistent-hint + inset ) v-textarea( v-else-if='cfg.value.type === "string" && cfg.value.multiline' diff --git a/client/components/admin/admin-theme.vue b/client/components/admin/admin-theme.vue index 5bc8a742..083f8aa7 100644 --- a/client/components/admin/admin-theme.vue +++ b/client/components/admin/admin-theme.vue @@ -44,6 +44,7 @@ ) v-divider.mt-3 v-switch( + inset v-model='darkMode' :label='$t(`admin:theme.darkMode`)' color='primary' diff --git a/client/components/common/nav-header.vue b/client/components/common/nav-header.vue index 577ab3a7..e590d98c 100644 --- a/client/components/common/nav-header.vue +++ b/client/components/common/nav-header.vue @@ -125,6 +125,11 @@ //- v-btn(depressed, color='grey darken-3', block) //- v-icon(left) mdi-cached //- span Reset + v-tooltip(bottom, v-if='isAuthenticated && isAdmin') + template(v-slot:activator='{ on }') + v-btn.ml-2.mr-0(icon, v-on='on', href='/t') + v-icon(color='grey') mdi-tag-multiple + span Browse Tags v-flex(xs6, md4) v-toolbar.nav-header-inner.pr-4(color='black', dark, flat) v-spacer diff --git a/client/components/editor/editor-modal-properties.vue b/client/components/editor/editor-modal-properties.vue index 4f172238..d0054728 100644 --- a/client/components/editor/editor-modal-properties.vue +++ b/client/components/editor/editor-modal-properties.vue @@ -90,6 +90,7 @@ :hint='$t(`editor:props.publishToggleHint`)' persistent-hint disabled + inset ) v-divider v-card-text.grey.pt-5(:class='darkMode ? `darken-3-d3` : `lighten-5`') @@ -190,6 +191,7 @@ :hint='$t(`editor:props.allowCommentsHint`)' persistent-hint disabled + inset ) v-switch( :label='$t(`editor:props.allowRatings`)' @@ -198,6 +200,7 @@ :hint='$t(`editor:props.allowRatingsHint`)' persistent-hint disabled + inset ) v-switch( :label='$t(`editor:props.displayAuthor`)' @@ -206,6 +209,7 @@ :hint='$t(`editor:props.displayAuthorHint`)' persistent-hint disabled + inset ) v-switch( :label='$t(`editor:props.displaySharingBar`)' @@ -214,6 +218,7 @@ :hint='$t(`editor:props.displaySharingBarHint`)' persistent-hint disabled + inset ) page-selector(mode='create', v-model='pageSelectorShown', :path='path', :locale='locale', :open-handler='setPath') diff --git a/client/components/tags.vue b/client/components/tags.vue new file mode 100644 index 00000000..ed9f8d9f --- /dev/null +++ b/client/components/tags.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/client/graph/common/common-pages-query-tags.gql b/client/graph/common/common-pages-query-tags.gql new file mode 100644 index 00000000..894947e1 --- /dev/null +++ b/client/graph/common/common-pages-query-tags.gql @@ -0,0 +1,8 @@ +query { + pages { + tags { + tag + title + } + } +} diff --git a/server/controllers/common.js b/server/controllers/common.js index 62cc8a98..de6cd271 100644 --- a/server/controllers/common.js +++ b/server/controllers/common.js @@ -165,6 +165,14 @@ router.get(['/s', '/s/*'], async (req, res, next) => { } }) +/** + * Tags + */ +router.get(['/t', '/t/*'], (req, res, next) => { + _.set(res.locals, 'pageMeta.title', 'Tags') + res.render('tags') +}) + /** * View document / asset */ diff --git a/server/graph/resolvers/page.js b/server/graph/resolvers/page.js index db7f84e5..e85779c6 100644 --- a/server/graph/resolvers/page.js +++ b/server/graph/resolvers/page.js @@ -76,6 +76,9 @@ module.exports = { } else { throw new WIKI.Error.PageNotFound() } + }, + async tags (obj, args, context, info) { + return WIKI.models.tags.query().orderBy('tag', 'asc') } }, PageMutation: { diff --git a/server/graph/schemas/page.graphql b/server/graph/schemas/page.graphql index c3d1b29d..f0b90280 100644 --- a/server/graph/schemas/page.graphql +++ b/server/graph/schemas/page.graphql @@ -36,6 +36,8 @@ type PageQuery { single( id: Int! ): Page @auth(requires: ["manage:pages", "delete:pages", "manage:system"]) + + tags: [PageTag]! @auth(requires: ["manage:system", "read:pages"]) } # ----------------------------------------------- @@ -109,6 +111,7 @@ type Page { privateNS: String publishStartDate: Date! publishEndDate: String! + tags: [PageTag]! content: String! render: String toc: String @@ -125,6 +128,14 @@ type Page { creatorEmail: String! } +type PageTag { + id: Int! + tag: String! + title: String + createdAt: Date! + updatedAt: Date! +} + type PageHistory { versionId: Int! authorId: Int! diff --git a/server/models/pages.js b/server/models/pages.js index a84d3f38..e98dd22e 100644 --- a/server/models/pages.js +++ b/server/models/pages.js @@ -210,6 +210,11 @@ module.exports = class Page extends Model { isPrivate: opts.isPrivate }) + // -> Save Tags + if (opts.tags.length > 0) { + await WIKI.models.tags.associateTags({ tags: opts.tags, page }) + } + // -> Render page to HTML await WIKI.models.pages.renderPage(page) @@ -260,6 +265,9 @@ module.exports = class Page extends Model { isPrivate: ogPage.isPrivate }) + // -> Save Tags + await WIKI.models.tags.associateTags({ tags: opts.tags, page }) + // -> Render page to HTML await WIKI.models.pages.renderPage(page) diff --git a/server/models/tags.js b/server/models/tags.js index 7c73876c..7acca6db 100644 --- a/server/models/tags.js +++ b/server/models/tags.js @@ -1,4 +1,7 @@ const Model = require('objection').Model +const _ = require('lodash') + +/* global WIKI */ /** * Tags model @@ -46,4 +49,51 @@ module.exports = class Tag extends Model { this.createdAt = new Date().toISOString() this.updatedAt = new Date().toISOString() } + + static async associateTags ({ tags, page }) { + let existingTags = await WIKI.models.tags.query().column('id', 'tag') + + // Create missing tags + + const newTags = _.filter(tags, t => !_.some(existingTags, ['tag', t])).map(t => ({ + tag: t, + title: t + })) + if (newTags.length > 0) { + if (WIKI.config.db.type === 'postgres') { + const createdTags = await WIKI.models.tags.query().insert(newTags) + existingTags = _.concat(existingTags, createdTags) + } else { + for (const newTag of newTags) { + const createdTag = await WIKI.models.tags.query().insert(newTag) + existingTags.push(createdTag) + } + } + } + + // Fetch current page tags + + const targetTags = _.filter(existingTags, t => _.includes(tags, t.tag)) + const currentTags = await page.$relatedQuery('tags') + + // Tags to relate + + const tagsToRelate = _.differenceBy(targetTags, currentTags, 'id') + if (tagsToRelate.length > 0) { + if (WIKI.config.db.type === 'postgres') { + await page.$relatedQuery('tags').relate(tagsToRelate) + } else { + for (const tag of tagsToRelate) { + await page.$relatedQuery('tags').relate(tag) + } + } + } + + // Tags to unrelate + + const tagsToUnrelate = _.differenceBy(currentTags, targetTags, 'id') + if (tagsToUnrelate.length > 0) { + await page.$relatedQuery('tags').unrelate().whereIn('tags.id', _.map(tagsToUnrelate, 'id')) + } + } } diff --git a/server/views/tags.pug b/server/views/tags.pug new file mode 100644 index 00000000..6443f0b4 --- /dev/null +++ b/server/views/tags.pug @@ -0,0 +1,5 @@ +extends master.pug + +block body + #root + tags