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 @@
+
+ v-app(:dark='darkMode').tags
+ nav-header
+ v-navigation-drawer.pb-0.elevation-1(app, fixed, clipped, :right='$vuetify.rtl', permanent, width='300')
+ vue-scroll(:ops='scrollStyle')
+ v-list(dense, nav)
+ v-list-item(href='/')
+ v-list-item-icon: v-icon mdi-home
+ v-list-item-title {{$t('common:header.home')}}
+ template(v-for='(tags, groupName) in tagsGrouped')
+ v-divider.my-2
+ v-subheader.pl-4(:key='`tagGroup-` + groupName') {{groupName}}
+ v-list-item(v-for='tag of tags', @click='toggleTag(tag.tag)', :key='`tag-` + tag.tag')
+ v-list-item-icon
+ v-icon(v-if='isSelected(tag.tag)', color='primary') mdi-checkbox-intermediate
+ v-icon(v-else) mdi-checkbox-blank-outline
+ v-list-item-title {{tag.title}}
+ v-content
+ v-toolbar(color='primary', dark, flat, height='58')
+ template(v-if='selection.length > 0')
+ .overline.mr-3.animated.fadeInLeft Current Selection
+ v-chip.mr-3.primary--text(
+ v-for='tag of tagsSelected'
+ color='white'
+ close
+ @click:close='toggleTag(tag.tag)'
+ ) {{tag.title}}
+ v-spacer
+ v-btn.animated.fadeIn(
+ small
+ outlined
+ color='blue lighten-4'
+ rounded
+ @click='selection = []'
+ )
+ v-icon(left) mdi-close
+ span Clear Selection
+ template(v-else)
+ v-icon.mr-3.animated.fadeInRight mdi-arrow-left
+ .overline.animated.fadeInRight Select one or more tags
+ v-toolbar(color='grey lighten-4', flat, height='58')
+ v-text-field.tags-search(
+ label='Search within results...'
+ solo
+ hide-details
+ flat
+ rounded
+ single-line
+ height='40'
+ prepend-icon='mdi-file-document-box-search-outline'
+ append-icon='mdi-arrow-right'
+ )
+ v-divider.mx-3(vertical)
+ .overline Order By
+ v-select.ml-2(
+ :items='orderByItems'
+ v-model='orderBy'
+ background-color='white'
+ hide-details
+ label='Order By'
+ rounded
+ single-line
+ dense
+ height='40'
+ style='max-width: 250px;'
+ )
+ v-divider.mx-3(vertical)
+ v-btn-toggle(v-model='displayStyle', rounded, mandatory)
+ v-btn(text, height='40'): v-icon(small) mdi-view-list
+ v-btn(text, height='40'): v-icon(small) mdi-cards-variant
+ v-btn(text, height='40'): v-icon(small) mdi-format-align-justify
+ v-divider
+ nav-footer
+ notify
+ search-results
+
+
+
+
+
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