浏览代码

feat: tags UI (wip) + save tags from page

Nick 5 年之前
父节点
当前提交
5a7fd2d73e

+ 2 - 1
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'))

+ 10 - 0
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'

+ 14 - 1
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

+ 2 - 2
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')}}

+ 2 - 0
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
             )
 </template>
 

+ 8 - 0
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'

+ 2 - 0
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'

+ 2 - 0
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

+ 2 - 0
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

+ 1 - 0
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'

+ 10 - 0
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'

+ 1 - 0
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'

+ 5 - 0
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

+ 5 - 0
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')

+ 167 - 0
client/components/tags.vue

@@ -0,0 +1,167 @@
+<template lang='pug'>
+  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
+</template>
+
+<script>
+import { get } from 'vuex-pathify'
+import _ from 'lodash'
+
+import tagsQuery from 'gql/common/common-pages-query-tags.gql'
+
+export default {
+  data() {
+    return {
+      tags: [],
+      selection: [],
+      displayStyle: 0,
+      orderBy: 'TITLE',
+      orderByItems: [
+        { text: 'Creation Date', value: 'CREATED' },
+        { text: 'ID', value: 'ID' },
+        { text: 'Last Modified', value: 'UPDATED' },
+        { text: 'Path', value: 'PATH' },
+        { text: 'Title', value: 'TITLE' }
+      ],
+      scrollStyle: {
+        vuescroll: {},
+        scrollPanel: {
+          initialScrollY: 0,
+          initialScrollX: 0,
+          scrollingX: false,
+          easing: 'easeOutQuad',
+          speed: 1000,
+          verticalNativeBarPos: this.$vuetify.rtl ? `left` : `right`
+        },
+        rail: {
+          gutterOfEnds: '2px'
+        },
+        bar: {
+          onlyShowBarOnScroll: false,
+          background: '#CCC',
+          hoverStyle: {
+            background: '#999'
+          }
+        }
+      }
+    }
+  },
+  computed: {
+    darkMode: get('site/dark'),
+    tagsGrouped () {
+      return _.groupBy(this.tags, t => t.title.charAt(0).toUpperCase())
+    },
+    tagsSelected () {
+      return _.filter(this.tags, t => _.includes(this.selection, t.tag))
+    }
+  },
+  created () {
+    this.$store.commit('page/SET_MODE', 'tags')
+  },
+  methods: {
+    toggleTag (tag) {
+      if (_.includes(this.selection, tag)) {
+        this.selection = _.without(this.selection, tag)
+      } else {
+        this.selection.push(tag)
+      }
+    },
+    isSelected (tag) {
+      return _.includes(this.selection, tag)
+    }
+  },
+  apollo: {
+    tags: {
+      query: tagsQuery,
+      fetchPolicy: 'cache-and-network',
+      update: (data) => _.cloneDeep(data.pages.tags),
+      watchLoading (isLoading) {
+        this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'tags-refresh')
+      }
+    }
+  }
+}
+</script>
+
+<style lang='scss'>
+.tags-search {
+  .v-input__control {
+    min-height: initial !important;
+  }
+  .v-input__prepend-outer {
+    margin-top: 8px !important;
+  }
+}
+</style>

+ 8 - 0
client/graph/common/common-pages-query-tags.gql

@@ -0,0 +1,8 @@
+query {
+  pages {
+    tags {
+      tag
+      title
+    }
+  }
+}

+ 8 - 0
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
  */

+ 3 - 0
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: {

+ 11 - 0
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!

+ 8 - 0
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)
 

+ 50 - 0
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'))
+    }
+  }
 }

+ 5 - 0
server/views/tags.pug

@@ -0,0 +1,5 @@
+extends master.pug
+
+block body
+  #root
+    tags