瀏覽代碼

feat: tags search + search improvements

NGPixel 1 年之前
父節點
當前提交
5c6965b544

+ 3 - 2
server/db/migrations/3.0.0.mjs

@@ -231,7 +231,7 @@ export async function up (knex) {
       table.text('render')
       table.text('searchContent')
       table.specificType('ts', 'tsvector').index('ts_idx', { indexType: 'GIN' })
-      table.specificType('tags', 'int[]').index('tags_idx', { indexType: 'GIN' })
+      table.specificType('tags', 'text[]').index('tags_idx', { indexType: 'GIN' })
       table.jsonb('toc')
       table.string('editor').notNullable()
       table.string('contentType').notNullable()
@@ -282,6 +282,7 @@ export async function up (knex) {
     .createTable('tags', table => {
       table.uuid('id').notNullable().primary().defaultTo(knex.raw('gen_random_uuid()'))
       table.string('tag').notNullable()
+      table.integer('usageCount').notNullable().defaultTo(0)
       table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now())
       table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now())
     })
@@ -783,7 +784,7 @@ export async function up (knex) {
     },
     {
       task: 'refreshAutocomplete',
-      cron: '0 */3 * * *',
+      cron: '0 */6 * * *',
       type: 'system'
     },
     {

+ 33 - 52
server/graph/resolvers/page.mjs

@@ -1,6 +1,10 @@
 import _ from 'lodash-es'
 import { generateError, generateSuccess } from '../../helpers/graph.mjs'
 import { parsePath }from '../../helpers/page.mjs'
+import tsquery from 'pg-tsquery'
+
+const tsq = tsquery()
+const tagsInQueryRgx = /#[a-z0-9-\u3400-\u4DBF\u4E00-\u9FFF]+(?=(?:[^"]*(?:")[^"]*(?:"))*[^"]*$)/g
 
 export default {
   Query: {
@@ -43,20 +47,24 @@ export default {
      * SEARCH PAGES
      */
     async searchPages (obj, args, context) {
+      const q = args.query.trim()
+      const hasQuery = q.length > 0
+
+      // -> Validate parameters
       if (!args.siteId) {
         throw new Error('Missing Site ID')
       }
-      if (!args.query?.trim()) {
-        throw new Error('Missing Query')
-      }
       if (args.offset && args.offset < 0) {
         throw new Error('Invalid offset value.')
       }
       if (args.limit && (args.limit < 1 || args.limit > 100)) {
         throw new Error('Limit must be between 1 and 100.')
       }
+
       try {
         const dictName = 'english' // TODO: Use provided locale or fallback on site locale
+
+        // -> Select Columns
         const searchCols = [
           'id',
           'path',
@@ -64,18 +72,26 @@ export default {
           'title',
           'description',
           'icon',
+          'tags',
           'updatedAt',
-          WIKI.db.knex.raw('ts_rank_cd(ts, query) AS relevancy'),
           WIKI.db.knex.raw('count(*) OVER() AS total')
         ]
 
-        if (WIKI.config.search.termHighlighting) {
+        // -> Set relevancy
+        if (hasQuery) {
+          searchCols.push(WIKI.db.knex.raw('ts_rank_cd(ts, query) AS relevancy'))
+        } else {
+          args.orderBy = args.orderBy === 'relevancy' ? 'title' : args.orderBy
+        }
+
+        // -> Add Highlighting if enabled
+        if (WIKI.config.search.termHighlighting && hasQuery) {
           searchCols.push(WIKI.db.knex.raw(`ts_headline(?, "searchContent", query, 'MaxWords=5, MinWords=3, MaxFragments=5') AS highlight`, [dictName]))
         }
 
         const results = await WIKI.db.knex
           .select(searchCols)
-          .fromRaw('pages, websearch_to_tsquery(?, ?) query', [dictName, args.query])
+          .fromRaw(hasQuery ? 'pages, to_tsquery(?, ?) query' : 'pages', hasQuery ? [dictName, tsq(q)] : [])
           .where('siteId', args.siteId)
           .where('isSearchableComputed', true)
           .where(builder => {
@@ -91,14 +107,19 @@ export default {
             if (args.publishState) {
               builder.where('publishState', args.publishState)
             }
+            if (args.tags) {
+              builder.where('tags', '@>', args.tags)
+            }
+            if (hasQuery) {
+              builder.whereRaw('query @@ ts')
+            }
           })
-          .whereRaw('query @@ ts')
           .orderBy(args.orderBy || 'relevancy', args.orderByDirection || 'desc')
           .offset(args.offset || 0)
           .limit(args.limit || 25)
 
         // -> Remove highlights without matches
-        if (WIKI.config.search.termHighlighting) {
+        if (WIKI.config.search.termHighlighting && hasQuery) {
           for (const r of results) {
             if (r.highlight?.indexOf('<b>') < 0) {
               r.highlight = null
@@ -268,50 +289,10 @@ export default {
      * FETCH TAGS
      */
     async tags (obj, args, context, info) {
-      const pages = await WIKI.db.pages.query()
-        .column([
-          'path',
-          { locale: 'localeCode' }
-        ])
-        .withGraphJoined('tags')
-      const allTags = _.filter(pages, r => {
-        return WIKI.auth.checkAccess(context.req.user, ['read:pages'], {
-          path: r.path,
-          locale: r.locale
-        })
-      }).flatMap(r => r.tags)
-      return _.orderBy(_.uniqBy(allTags, 'id'), ['tag'], ['asc'])
-    },
-    /**
-     * SEARCH TAGS
-     */
-    async searchTags (obj, args, context, info) {
-      const query = _.trim(args.query)
-      const pages = await WIKI.db.pages.query()
-        .column([
-          'path',
-          { locale: 'localeCode' }
-        ])
-        .withGraphJoined('tags')
-        .modifyGraph('tags', builder => {
-          builder.select('tag')
-        })
-        .modify(queryBuilder => {
-          queryBuilder.andWhere(builderSub => {
-            if (WIKI.config.db.type === 'postgres') {
-              builderSub.where('tags.tag', 'ILIKE', `%${query}%`)
-            } else {
-              builderSub.where('tags.tag', 'LIKE', `%${query}%`)
-            }
-          })
-        })
-      const allTags = _.filter(pages, r => {
-        return WIKI.auth.checkAccess(context.req.user, ['read:pages'], {
-          path: r.path,
-          locale: r.locale
-        })
-      }).flatMap(r => r.tags).map(t => t.tag)
-      return _.uniq(allTags).slice(0, 5)
+      if (!args.siteId) { throw new Error('Missing Site ID')}
+      const tags = await WIKI.db.knex('tags').where('siteId', args.siteId).orderBy('tag')
+      // TODO: check permissions
+      return tags
     },
     /**
      * FETCH PAGE TREE

+ 9 - 12
server/graph/schemas/page.graphql

@@ -108,20 +108,18 @@ extend type Query {
     alias: String!
   ): PageAliasPath
 
-  searchTags(
-    query: String!
-  ): [String]!
-
-  tags: [PageTag]!
+  tags(
+    siteId: UUID!
+  ): [PageTag]
 
   checkConflicts(
     id: Int!
     checkoutDate: Date!
-  ): Boolean!
+  ): Boolean
 
   checkConflictsLatest(
     id: Int!
-  ): PageConflictLatest!
+  ): PageConflictLatest
 }
 
 extend type Mutation {
@@ -253,7 +251,7 @@ type Page {
   showTags: Boolean
   showToc: Boolean
   siteId: UUID
-  tags: [PageTag]
+  tags: [String]
   title: String
   toc: [JSON]
   tocDepth: PageTocDepth
@@ -261,9 +259,10 @@ type Page {
 }
 
 type PageTag {
-  id: Int
+  id: UUID
   tag: String
-  title: String
+  usageCount: Int
+  siteId: UUID
   createdAt: Date
   updatedAt: Date
 }
@@ -401,9 +400,7 @@ input PageUpdateInput {
   icon: String
   isBrowsable: Boolean
   isSearchable: Boolean
-  locale: String
   password: String
-  path: String
   publishEndDate: Date
   publishStartDate: Date
   publishState: PagePublishState

+ 1 - 0
server/locales/en.json

@@ -1204,6 +1204,7 @@
   "common.actions.fetch": "Fetch",
   "common.actions.filter": "Filter",
   "common.actions.generate": "Generate",
+  "common.actions.goback": "Go Back",
   "common.actions.howItWorks": "How it works",
   "common.actions.insert": "Insert",
   "common.actions.login": "Login",

+ 3 - 25
server/models/pages.mjs

@@ -320,7 +320,7 @@ export class Page extends Model {
     // -> Get Tags
     let tags = []
     if (opts.tags && opts.tags.length > 0) {
-      tags = await WIKI.db.tags.fetchIds({ tags: opts.tags, siteId: opts.siteId })
+      tags = await WIKI.db.tags.processNewTags(opts.tags, opts.siteId)
     }
 
     // -> Create page
@@ -635,6 +635,7 @@ export class Page extends Model {
 
     // -> Tags
     if ('tags' in opts.patch) {
+      patch.tags = await WIKI.db.tags.processNewTags(opts.patch.tags, ogPage.siteId)
       historyData.affectedFields.push('tags')
     }
 
@@ -646,11 +647,6 @@ export class Page extends Model {
     }).where('id', ogPage.id)
     let page = await WIKI.db.pages.getPageFromDb(ogPage.id)
 
-    // -> Save Tags
-    if (opts.patch.tags) {
-      // await WIKI.db.tags.associateTags({ tags: opts.patch.tags, page })
-    }
-
     // -> Render page to HTML
     if (opts.patch.content) {
       await WIKI.db.pages.renderPage(page)
@@ -687,24 +683,6 @@ export class Page extends Model {
     //   })
     // }
 
-    // -> Perform move?
-    if ((opts.locale && opts.locale !== page.localeCode) || (opts.path && opts.path !== page.path)) {
-      // -> Check target path access
-      if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], {
-        locale: opts.locale,
-        path: opts.path
-      })) {
-        throw new WIKI.Error.PageMoveForbidden()
-      }
-
-      await WIKI.db.pages.movePage({
-        id: page.id,
-        destinationLocale: opts.locale,
-        destinationPath: opts.path,
-        user: opts.user
-      })
-    }
-
     // -> Get latest updatedAt
     page.updatedAt = await WIKI.db.pages.query().findById(page.id).select('updatedAt').then(r => r.updatedAt)
 
@@ -745,7 +723,7 @@ export class Page extends Model {
     await WIKI.db.knex.raw(`
       INSERT INTO "autocomplete" (word)
         SELECT word FROM ts_stat(
-          'SELECT to_tsvector(''simple'', "title") || to_tsvector(''simple'', "description") || to_tsvector(''simple'', "searchContent") FROM "pages" WHERE isSearchableComputed IS TRUE'
+          'SELECT to_tsvector(''simple'', "title") || to_tsvector(''simple'', "description") || to_tsvector(''simple'', "searchContent") FROM "pages" WHERE "isSearchableComputed" IS TRUE'
         )
     `)
   }

+ 16 - 58
server/models/tags.mjs

@@ -1,7 +1,7 @@
 import { Model } from 'objection'
-import { concat, differenceBy, some, uniq } from 'lodash-es'
+import { difference, some, uniq } from 'lodash-es'
 
-import { Page } from './pages.mjs'
+const allowedCharsRgx = /^[a-z0-9-\u3400-\u4DBF\u4E00-\u9FFF]+$/
 
 /**
  * Tags model
@@ -15,7 +15,7 @@ export class Tag extends Model {
       required: ['tag'],
 
       properties: {
-        id: {type: 'integer'},
+        id: {type: 'string'},
         tag: {type: 'string'},
 
         createdAt: {type: 'string'},
@@ -24,23 +24,6 @@ export class Tag extends Model {
     }
   }
 
-  static get relationMappings() {
-    return {
-      pages: {
-        relation: Model.ManyToManyRelation,
-        modelClass: Page,
-        join: {
-          from: 'tags.id',
-          through: {
-            from: 'pageTags.tagId',
-            to: 'pageTags.pageId'
-          },
-          to: 'pages.id'
-        }
-      }
-    }
-  }
-
   $beforeUpdate() {
     this.updatedAt = new Date().toISOString()
   }
@@ -49,53 +32,28 @@ export class Tag extends Model {
     this.updatedAt = new Date().toISOString()
   }
 
-  static async associateTags ({ tags, page }) {
-    let existingTags = await WIKI.db.tags.query().column('id', 'tag')
-
-    // Format tags
+  static async processNewTags (tags, siteId) {
+    // Validate tags
 
-    tags = uniq(tags.map(t => t.trim().toLowerCase()))
-
-    // Create missing tags
+    const normalizedTags = uniq(tags.map(t => t.trim().toLowerCase().replaceAll('#', '')).filter(t => t))
 
-    const newTags = tags.filter(t => !some(existingTags, ['tag', t])).map(t => ({ tag: t }))
-    if (newTags.length > 0) {
-      if (WIKI.config.db.type === 'postgres') {
-        const createdTags = await WIKI.db.tags.query().insert(newTags)
-        existingTags = concat(existingTags, createdTags)
-      } else {
-        for (const newTag of newTags) {
-          const createdTag = await WIKI.db.tags.query().insert(newTag)
-          existingTags.push(createdTag)
-        }
+    for (const tag of normalizedTags) {
+      if (!allowedCharsRgx.test(tag)) {
+        throw new Error(`Tag #${tag} has invalid characters. Must consists of letters (no diacritics), numbers, CJK logograms and dashes only.`)
       }
     }
 
-    // Fetch current page tags
-
-    const targetTags = existingTags.filter(t => tags.includes(t.tag))
-    const currentTags = await page.$relatedQuery('tags')
+    // Fetch existing tags
 
-    // Tags to relate
+    const existingTags = await WIKI.db.knex('tags').column('tag').where('siteId', siteId).pluck('tag')
 
-    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
+    // Create missing tags
 
-    const tagsToUnrelate = differenceBy(currentTags, targetTags, 'id')
-    if (tagsToUnrelate.length > 0) {
-      await page.$relatedQuery('tags').unrelate().whereIn('tags.id', tagsToUnrelate.map(t => t.id))
+    const newTags = difference(normalizedTags, existingTags).map(t => ({ tag: t, usageCount: 1, siteId }))
+    if (newTags.length > 0) {
+      await WIKI.db.tags.query().insert(newTags)
     }
 
-    page.tags = targetTags
+    return normalizedTags
   }
 }

+ 3 - 97
ux/src/components/HeaderNav.vue

@@ -24,56 +24,7 @@ q-header.bg-header.text-white.site-header(
           style='height: 34px'
           )
       q-toolbar-title.text-h6(v-if='siteStore.logoText') {{siteStore.title}}
-    q-toolbar.gt-sm(
-      style='height: 64px;'
-      dark
-      v-if='siteStore.features.search'
-      )
-      q-input(
-        dark
-        v-model='siteStore.search'
-        standout='bg-white text-dark'
-        dense
-        rounded
-        ref='searchField'
-        style='width: 100%;'
-        label='Search...'
-        @keyup.enter='onSearchEnter'
-        @focus='state.searchKbdShortcutShown = false'
-        @blur='state.searchKbdShortcutShown = true'
-        )
-        template(v-slot:prepend)
-          q-circular-progress.q-mr-xs(
-            v-if='siteStore.searchIsLoading && route.path !== `/_search`'
-            instant-feedback
-            indeterminate
-            rounded
-            color='primary'
-            size='20px'
-            )
-          q-icon(v-else, name='las la-search')
-        template(v-slot:append)
-          q-badge.q-mr-sm(
-            v-if='state.searchKbdShortcutShown'
-            label='Ctrl+K'
-            color='grey-7'
-            outline
-            @click='searchField.focus()'
-            )
-          q-badge.q-mr-sm(
-            v-else-if='siteStore.search && siteStore.search !== siteStore.searchLastQuery'
-            label='Press Enter'
-            color='grey-7'
-            outline
-            @click='searchField.focus()'
-            )
-          q-icon.cursor-pointer(
-            name='las la-times'
-            size='20px'
-            @click='siteStore.search=``'
-            v-if='siteStore.search.length > 0'
-            color='grey-6'
-            )
+    header-search
     q-toolbar(
       style='height: 64px;'
       dark
@@ -138,7 +89,7 @@ q-header.bg-header.text-white.site-header(
 <script setup>
 import { useI18n } from 'vue-i18n'
 import { useQuasar } from 'quasar'
-import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
+import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
 import { useRouter, useRoute } from 'vue-router'
 
 import { useCommonStore } from 'src/stores/common'
@@ -147,6 +98,7 @@ import { useUserStore } from 'src/stores/user'
 
 import AccountMenu from 'src/components/AccountMenu.vue'
 import NewMenu from 'src/components/PageNewMenu.vue'
+import HeaderSearch from 'src/components/HeaderSearch.vue'
 
 // QUASAR
 
@@ -167,55 +119,9 @@ const route = useRoute()
 
 const { t } = useI18n()
 
-// DATA
-
-const state = reactive({
-  searchKbdShortcutShown: true
-})
-
-const searchField = ref(null)
-
 // METHODS
 
 function openFileManager () {
   siteStore.openFileManager()
 }
-
-function handleKeyPress (ev) {
-  if (siteStore.features.search) {
-    if (ev.ctrlKey && ev.key === 'k') {
-      ev.preventDefault()
-      searchField.value.focus()
-    }
-  }
-}
-
-function onSearchEnter () {
-  if (route.path === '/_search') {
-    router.replace({ path: '/_search', query: { q: siteStore.search } })
-  } else {
-    siteStore.searchIsLoading = true
-    router.push({ path: '/_search', query: { q: siteStore.search } })
-  }
-}
-
-// MOUNTED
-
-onMounted(() => {
-  if (process.env.CLIENT) {
-    window.addEventListener('keydown', handleKeyPress)
-  }
-  if (route.path.startsWith('/_search')) {
-    searchField.value.focus()
-  }
-})
-onBeforeUnmount(() => {
-  if (process.env.CLIENT) {
-    window.removeEventListener('keydown', handleKeyPress)
-  }
-})
 </script>
-
-<style lang="scss">
-
-</style>

+ 224 - 0
ux/src/components/HeaderSearch.vue

@@ -0,0 +1,224 @@
+<template lang="pug">
+q-toolbar(
+  style='height: 64px;'
+  dark
+  v-if='siteStore.features.search'
+  )
+  q-input(
+    dark
+    v-model='siteStore.search'
+    standout='bg-white text-dark'
+    dense
+    rounded
+    ref='searchField'
+    style='width: 100%;'
+    label='Search...'
+    @keyup.enter='onSearchEnter'
+    @focus='state.searchIsFocused = true'
+    @blur='checkSearchFocus'
+    )
+    template(v-slot:prepend)
+      q-circular-progress.q-mr-xs(
+        v-if='siteStore.searchIsLoading && route.path !== `/_search`'
+        instant-feedback
+        indeterminate
+        rounded
+        color='primary'
+        size='20px'
+        )
+      q-icon(v-else, name='las la-search')
+    template(v-slot:append)
+      q-badge.q-mr-sm(
+        v-if='!state.searchIsFocused'
+        label='Ctrl+K'
+        color='grey-7'
+        outline
+        @click='searchField.focus()'
+        )
+      q-badge.q-mr-sm(
+        v-else-if='siteStore.search && siteStore.search !== siteStore.searchLastQuery'
+        label='Press Enter'
+        color='grey-7'
+        outline
+        @click='searchField.focus()'
+        )
+      q-icon.cursor-pointer(
+        name='las la-times'
+        size='20px'
+        @click='siteStore.search=``'
+        v-if='siteStore.search.length > 0'
+        color='grey-6'
+        )
+  .searchpanel(
+    ref='searchPanel'
+    v-if='searchPanelIsShown'
+    )
+    template(v-if='siteStore.tagsLoaded && siteStore.tags.length > 0')
+      .searchpanel-header
+        span Popular Tags
+        q-space
+        q-btn.acrylic-btn(
+          flat
+          label='View All'
+          rounded
+          size='xs'
+        )
+      .flex.q-mb-md
+        q-chip(
+          v-for='tag of popularTags'
+          square
+          color='grey-8'
+          text-color='white'
+          icon='las la-hashtag'
+          size='sm'
+          clickable
+          @click='addTag(tag)'
+          ) {{ tag }}
+    .searchpanel-header Search Operators
+    .searchpanel-tip #[code !foo] or #[code -bar] to exclude "foo" and "bar".
+    .searchpanel-tip #[code bana*] for to match any term starting with "bana" (e.g. banana).
+    .searchpanel-tip #[code foo,bar] or #[code foo|bar] to search for "foo" OR "bar".
+    .searchpanel-tip #[code "foo bar"] to match exactly the phrase "foo bar".
+</template>
+
+<script setup>
+import { useI18n } from 'vue-i18n'
+import { useQuasar } from 'quasar'
+import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
+import { useRouter, useRoute } from 'vue-router'
+import { orderBy } from 'lodash-es'
+
+import { useSiteStore } from 'src/stores/site'
+
+// QUASAR
+
+const $q = useQuasar()
+
+// STORES
+
+const siteStore = useSiteStore()
+
+// ROUTER
+
+const router = useRouter()
+const route = useRoute()
+
+// I18N
+
+const { t } = useI18n()
+
+// DATA
+
+const state = reactive({
+  searchIsFocused: false
+})
+
+const searchPanel = ref(null)
+const searchField = ref(null)
+
+// COMPUTED
+
+const searchPanelIsShown = computed(() => {
+  return state.searchIsFocused && (siteStore.search !== siteStore.searchLastQuery || siteStore.search === '')
+})
+
+const popularTags = computed(() => {
+  return orderBy(siteStore.tags, ['usageCount', 'desc']).map(t => t.tag)
+})
+
+// WATCHERS
+
+watch(searchPanelIsShown, (newValue) => {
+  if (newValue) {
+    siteStore.fetchTags()
+  }
+})
+
+// METHODS
+
+function handleKeyPress (ev) {
+  if (siteStore.features.search) {
+    if (ev.ctrlKey && ev.key === 'k') {
+      ev.preventDefault()
+      searchField.value.focus()
+    }
+  }
+}
+
+function onSearchEnter () {
+  if (!siteStore.search) { return }
+  if (route.path === '/_search') {
+    router.replace({ path: '/_search', query: { q: siteStore.search } })
+  } else {
+    siteStore.searchIsLoading = true
+    router.push({ path: '/_search', query: { q: siteStore.search } })
+  }
+}
+
+function checkSearchFocus (ev) {
+  if (!searchPanel.value?.contains(ev.relatedTarget)) {
+    state.searchIsFocused = false
+  }
+}
+
+function addTag (tag) {
+  if (!siteStore.search.includes(`#${tag}`)) {
+    siteStore.search = siteStore.search ? `${siteStore.search} #${tag}` : `#${tag}`
+  }
+  searchField.value.focus()
+}
+
+// MOUNTED
+
+onMounted(() => {
+  if (process.env.CLIENT) {
+    window.addEventListener('keydown', handleKeyPress)
+  }
+  if (route.path.startsWith('/_search')) {
+    searchField.value.focus()
+  }
+})
+onBeforeUnmount(() => {
+  if (process.env.CLIENT) {
+    window.removeEventListener('keydown', handleKeyPress)
+  }
+})
+
+</script>
+
+<style lang="scss">
+.searchpanel {
+  position: absolute;
+  top: 64px;
+  left: 0;
+  background-color: rgba(0,0,0,.7);
+  border-radius: 0 0 12px 12px;
+  color: #FFF;
+  padding: .5rem 1rem 1rem;
+  width: 100%;
+  backdrop-filter: blur(7px);
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2), 0 1px 1px rgba(0, 0, 0, 0.14), 0 2px 1px -1px rgba(0, 0, 0, 0.12);
+
+  &-header {
+    font-weight: 500;
+    border-bottom: 1px solid rgba(255,255,255,.2);
+    padding: 0 0 .5rem 0;
+    margin-bottom: .5rem;
+    display: flex;
+    align-items: center;
+  }
+
+  &-tip {
+    + .searchpanel-tip {
+      margin-top: .5rem;
+    }
+  }
+
+  code {
+    background-color: rgba(0,0,0,.7);
+    padding: 2px 8px;
+    font-weight: 700;
+    border-radius: 4px;
+  }
+}
+</style>

+ 11 - 24
ux/src/components/PageHeader.vue

@@ -164,16 +164,17 @@
           @click.exact='saveChanges(false)'
           @click.ctrl.exact='saveChanges(true)'
           )
-        q-separator(vertical, dark)
-        q-btn.acrylic-btn(
-          flat
-          icon='las la-check-double'
-          color='positive'
-          :aria-label='t(`common.actions.saveAndClose`)'
-          :disabled='!editorStore.hasPendingChanges'
-          @click='saveChanges(true)'
-          )
-          q-tooltip {{ t(`common.actions.saveAndClose`) }}
+        template(v-if='editorStore.isActive')
+          q-separator(vertical, dark)
+          q-btn.acrylic-btn(
+            flat
+            icon='las la-check-double'
+            color='positive'
+            :aria-label='t(`common.actions.saveAndClose`)'
+            :disabled='!editorStore.hasPendingChanges'
+            @click='saveChanges(true)'
+            )
+            q-tooltip {{ t(`common.actions.saveAndClose`) }}
     template(v-else-if='userStore.can(`edit:pages`)')
       q-btn.acrylic-btn.q-ml-md(
         flat
@@ -222,20 +223,6 @@ const route = useRoute()
 
 const { t } = useI18n()
 
-// COMPUTED
-
-const editMode = computed(() => {
-  return pageStore.mode === 'edit'
-})
-const editCreateMode = computed(() => {
-  return pageStore.mode === 'edit' && pageStore.mode === 'create'
-})
-const editUrl = computed(() => {
-  let pagePath = siteStore.useLocales ? `${pageStore.locale}/` : ''
-  pagePath += !pageStore.path ? 'home' : pageStore.path
-  return `/_edit/${pagePath}`
-})
-
 // METHODS
 
 function openEditorSettings () {

+ 80 - 16
ux/src/components/PageTags.vue

@@ -12,32 +12,44 @@
       v-for='tag of pageStore.tags'
       :key='`tag-` + tag'
       )
-      q-icon.q-mr-xs(name='las la-tag', size='14px')
+      q-icon.q-mr-xs(name='las la-hashtag', size='14px')
       span.text-caption {{tag}}
-    q-chip(
-      v-if='!props.edit && pageStore.tags.length > 1'
-      square
-      color='secondary'
-      text-color='white'
-      dense
-      clickable
-      )
-      q-icon(name='las la-tags', size='14px')
-  q-input.q-mt-md(
+  q-select.q-mt-md(
     v-if='props.edit'
     outlined
-    v-model='state.newTag'
+    v-model='pageStore.tags'
+    :options='state.filteredTags'
     dense
-    placeholder='Add new tag...'
-  )
+    options-dense
+    use-input
+    use-chips
+    multiple
+    hide-selected
+    hide-dropdown-icon
+    :input-debounce='0'
+    new-value-mode='add-unique'
+    @new-value='createTag'
+    @filter='filterTags'
+    placeholder='Select or create tags...'
+    :loading='state.loading'
+    )
+    template(v-slot:option='scope')
+      q-item(v-bind='scope.itemProps')
+        q-item-section(side)
+          q-checkbox(:model-value='scope.selected', @update:model-value='scope.toggleOption(scope.opt)', size='sm')
+        q-item-section
+          q-item-label(v-html='scope.opt')
 </template>
 
 <script setup>
 import { useQuasar } from 'quasar'
-import { reactive } from 'vue'
+import { reactive, watch } from 'vue'
 import { useI18n } from 'vue-i18n'
+import { DateTime } from 'luxon'
 
+import { useEditorStore } from 'src/stores/editor'
 import { usePageStore } from 'src/stores/page'
+import { useSiteStore } from 'src/stores/site'
 
 // PROPS
 
@@ -54,7 +66,9 @@ const $q = useQuasar()
 
 // STORES
 
+const editorStore = useEditorStore()
 const pageStore = usePageStore()
+const siteStore = useSiteStore()
 
 // I18N
 
@@ -63,11 +77,61 @@ const { t } = useI18n()
 // DATA
 
 const state = reactive({
-  newTag: ''
+  tags: [],
+  filteredTags: [],
+  loading: false
 })
 
+// WATCHERS
+
+pageStore.$subscribe(() => {
+  if (props.edit) {
+    editorStore.$patch({
+      lastChangeTimestamp: DateTime.utc()
+    })
+  }
+})
+
+watch(() => props.edit, async (newValue) => {
+  if (newValue) {
+    state.loading = true
+    await siteStore.fetchTags()
+    state.tags = siteStore.tags.map(t => t.tag)
+    state.loading = false
+  }
+}, { immediate: true })
+
 // METHODS
 
+function filterTags (val, update) {
+  update(() => {
+    if (val === '') {
+      state.filteredTags = state.tags
+    } else {
+      const tagSearch = val.toLowerCase()
+      state.filteredTags = state.tags.filter(
+        v => v.toLowerCase().indexOf(tagSearch) >= 0
+      )
+    }
+  })
+}
+
+function createTag (val, done) {
+  if (val) {
+    const currentTags = pageStore.tags.slice()
+    for (const tag of val.split(/[,;]+/).map(v => v.trim()).filter(v => v)) {
+      if (!state.tags.includes(tag)) {
+        state.tags.push(tag)
+      }
+      if (!currentTags.includes(tag)) {
+        currentTags.push(tag)
+      }
+    }
+    done('')
+    pageStore.tags = currentTags
+  }
+}
+
 function removeTag (tag) {
   pageStore.tags = pageStore.tags.filter(t => t !== tag)
 }

+ 104 - 11
ux/src/pages/Search.vue

@@ -3,6 +3,14 @@ q-layout(view='hHh Lpr lff')
   header-nav
   q-page-container.layout-search
     .layout-search-card
+      q-btn.layout-search-back(
+        icon='las la-arrow-circle-left'
+        color='white'
+        flat
+        round
+        @click='goBack'
+        )
+        q-tooltip(anchor='center left', self='center right') {{ t('common.actions.goback') }}
       .layout-search-sd
         .text-header {{ t('search.sortBy') }}
         q-list(dense, padding)
@@ -36,13 +44,30 @@ q-layout(view='hHh Lpr lff')
             )
             template(v-slot:prepend)
               q-icon(name='las la-caret-square-right', size='xs')
-          q-input.q-mt-sm(
+          q-select.q-mt-sm(
             outlined
+            v-model='state.selectedTags'
+            :options='state.filteredTags'
             dense
-            :placeholder='t(`search.filterTags`)'
+            options-dense
+            use-input
+            use-chips
+            multiple
+            hide-dropdown-icon
+            :input-debounce='0'
+            @update:model-value='v => syncTags(v)'
+            @filter='filterTags'
+            :placeholder='state.selectedTags.length < 1 ? t(`search.filterTags`) : ``'
+            :loading='state.loading > 0'
             )
             template(v-slot:prepend)
               q-icon(name='las la-hashtag', size='xs')
+            template(v-slot:option='scope')
+              q-item(v-bind='scope.itemProps')
+                q-item-section(side)
+                  q-checkbox(:model-value='scope.selected', @update:model-value='scope.toggleOption(scope.opt)', size='sm')
+                q-item-section
+                  q-item-label(v-html='scope.opt')
           //- q-input.q-mt-sm(
           //-   outlined
           //-   dense
@@ -131,15 +156,15 @@ q-layout(view='hHh Lpr lff')
               q-item-label(v-if='item.description', caption) {{ item.description }}
               q-item-label.text-highlight(v-if='item.highlight', caption, v-html='item.highlight')
             q-item-section(side)
-              .flex
+              .flex.layout-search-itemtags
                 q-chip(
-                  v-for='tag of 3'
+                  v-for='tag of item.tags'
                   square
-                  :color='$q.dark.isActive ? `dark-2` : `grey-3`'
-                  :text-color='$q.dark.isActive ? `grey-4` : `grey-8`'
+                  color='secondary'
+                  text-color='white'
                   icon='las la-hashtag'
                   size='sm'
-                  ) tag {{ tag }}
+                  ) {{ tag }}
               .flex
                 .text-caption.q-mr-sm.text-grey /{{ item.path }}
                 .text-caption {{ humanizeDate(item.updatedAt) }}
@@ -155,7 +180,7 @@ import { useMeta, useQuasar } from 'quasar'
 import { computed, onMounted, onUnmounted, reactive, watch } from 'vue'
 import { useRouter, useRoute } from 'vue-router'
 import gql from 'graphql-tag'
-import { cloneDeep, debounce } from 'lodash-es'
+import { cloneDeep, debounce, difference } from 'lodash-es'
 import { DateTime } from 'luxon'
 
 import { useFlagsStore } from 'src/stores/flags'
@@ -166,6 +191,8 @@ import HeaderNav from 'src/components/HeaderNav.vue'
 import FooterNav from 'src/components/FooterNav.vue'
 import MainOverlayDialog from 'src/components/MainOverlayDialog.vue'
 
+const tagsInQueryRgx = /#[a-z0-9-\u3400-\u4DBF\u4E00-\u9FFF]+(?=(?:[^"]*(?:")[^"]*(?:"))*[^"]*$)/g
+
 // QUASAR
 
 const $q = useQuasar()
@@ -197,13 +224,14 @@ const state = reactive({
   loading: 0,
   params: {
     filterPath: '',
-    filterTags: [],
     filterLocale: [],
     filterEditor: '',
     filterPublishState: '',
     orderBy: 'relevancy',
     orderByDirection: 'desc'
   },
+  selectedTags: [],
+  filteredTags: [],
   results: [],
   total: 0
 })
@@ -234,11 +262,14 @@ const publishStates = computed(() => {
   ]
 })
 
+const tags = computed(() => siteStore.tags.map(t => t.tag))
+
 // WATCHERS
 
 watch(() => route.query, async (newQueryObj) => {
   if (newQueryObj.q) {
-    siteStore.search = newQueryObj.q
+    siteStore.search = newQueryObj.q.trim()
+    syncTags()
     performSearch()
   }
 }, { immediate: true })
@@ -266,9 +297,50 @@ function setOrderBy (val) {
   }
 }
 
+function filterTags (val, update) {
+  update(() => {
+    if (val === '') {
+      state.filteredTags = tags.value
+    } else {
+      const tagSearch = val.toLowerCase()
+      state.filteredTags = tags.value.filter(
+        v => v.toLowerCase().indexOf(tagSearch) >= 0
+      )
+    }
+  })
+}
+
+function syncTags (newSelection) {
+  const queryTags = Array.from(siteStore.search.matchAll(tagsInQueryRgx)).map(t => t[0].substring(1))
+  if (!newSelection) {
+    state.selectedTags = queryTags
+  } else {
+    let newQuery = siteStore.search
+    for (const tag of newSelection) {
+      if (!newQuery.includes(`#${tag}`)) {
+        newQuery = `${newQuery} #${tag}`
+      }
+    }
+    for (const tag of difference(queryTags, newSelection)) {
+      newQuery = newQuery.replaceAll(`#${tag}`, '')
+    }
+    newQuery = newQuery.replaceAll('  ', ' ').trim()
+    router.replace({ path: '/_search', query: { q: newQuery } })
+  }
+}
+
 async function performSearch () {
   siteStore.searchIsLoading = true
   try {
+    let q = siteStore.search
+
+    // -> Extract tags
+    const queryTags = Array.from(q.matchAll(tagsInQueryRgx)).map(t => t[0].substring(1))
+    for (const tag of queryTags) {
+      q = q.replaceAll(`#${tag}`, '')
+    }
+    q = q.trim().replaceAll(/\s\s+/g, ' ')
+
     const resp = await APOLLO_CLIENT.query({
       query: gql`
         query searchPages (
@@ -304,6 +376,7 @@ async function performSearch () {
               title
               description
               icon
+              tags
               updatedAt
               relevancy
               highlight
@@ -314,8 +387,9 @@ async function performSearch () {
       `,
       variables: {
         siteId: siteStore.id,
-        query: siteStore.search,
+        query: q,
         path: state.params.filterPath,
+        tags: queryTags,
         locale: state.params.filterLocale,
         editor: state.params.filterEditor,
         publishState: state.params.filterPublishState || null,
@@ -340,6 +414,14 @@ async function performSearch () {
   siteStore.searchIsLoading = false
 }
 
+function goBack () {
+  if (history.length > 0) {
+    router.back()
+  } else {
+    router.push('/')
+  }
+}
+
 // MOUNTED
 
 onMounted(() => {
@@ -388,6 +470,11 @@ onUnmounted(() => {
     background: linear-gradient(to right, transparent 0%, rgba(255,255,255,.1) 50%, transparent 100%);
   }
 
+  &-back {
+    position: absolute;
+    left: -50px;
+  }
+
   &-card {
     position: relative;
     width: 90%;
@@ -461,6 +548,12 @@ onUnmounted(() => {
       border-left: 1px solid rgba($dark-6, .75);
     }
   }
+
+  &-itemtags {
+    .q-chip:last-child {
+      margin-right: 0;
+    }
+  }
 }
 
 body.body--dark {

+ 1 - 6
ux/src/stores/page.js

@@ -41,10 +41,7 @@ const pagePropsFragment = gql`
     showSidebar
     showTags
     showToc
-    tags {
-      tag
-      title
-    }
+    tags
     title
     toc
     tocDepth {
@@ -499,9 +496,7 @@ export const usePageStore = defineStore('page', {
                   'icon',
                   'isBrowsable',
                   'isSearchable',
-                  'locale',
                   'password',
-                  'path',
                   'publishEndDate',
                   'publishStartDate',
                   'publishState',

+ 31 - 0
ux/src/stores/site.js

@@ -44,6 +44,8 @@ export const useSiteStore = defineStore('site', {
         nativeName: 'English'
       }]
     },
+    tags: [],
+    tagsLoaded: false,
     theme: {
       dark: false,
       injectCSS: '',
@@ -192,6 +194,8 @@ export const useSiteStore = defineStore('site', {
               primary: clone(siteInfo.locales.primary),
               active: sortBy(clone(siteInfo.locales.active), ['nativeName', 'name'])
             },
+            tags: [],
+            tagsLoaded: false,
             theme: {
               ...this.theme,
               ...clone(siteInfo.theme)
@@ -204,6 +208,33 @@ export const useSiteStore = defineStore('site', {
         console.warn(err.networkError?.result ?? err.message)
         throw err
       }
+    },
+    async fetchTags (forceRefresh = false) {
+      if (this.tagsLoaded && !forceRefresh) { return }
+      try {
+        const resp = await APOLLO_CLIENT.query({
+          query: gql`
+            query getSiteTags ($siteId: UUID!) {
+              tags (
+                siteId: $siteId
+                ) {
+                tag
+                usageCount
+              }
+            }
+          `,
+          variables: {
+            siteId: this.id
+          }
+        })
+        this.$patch({
+          tags: resp.data.tags ?? [],
+          tagsLoaded: true
+        })
+      } catch (err) {
+        console.warn(err.networkError?.result ?? err.message)
+        throw err
+      }
     }
   }
 })