feat: tags search + search improvements

pull/6775/head
NGPixel 1 year ago
parent c87f4ce770
commit 5c6965b544
No known key found for this signature in database
GPG Key ID: B755FB6870B30F63

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

@ -1,6 +1,10 @@
import _ from 'lodash-es' import _ from 'lodash-es'
import { generateError, generateSuccess } from '../../helpers/graph.mjs' import { generateError, generateSuccess } from '../../helpers/graph.mjs'
import { parsePath }from '../../helpers/page.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 { export default {
Query: { Query: {
@ -43,20 +47,24 @@ export default {
* SEARCH PAGES * SEARCH PAGES
*/ */
async searchPages (obj, args, context) { async searchPages (obj, args, context) {
const q = args.query.trim()
const hasQuery = q.length > 0
// -> Validate parameters
if (!args.siteId) { if (!args.siteId) {
throw new Error('Missing Site ID') throw new Error('Missing Site ID')
} }
if (!args.query?.trim()) {
throw new Error('Missing Query')
}
if (args.offset && args.offset < 0) { if (args.offset && args.offset < 0) {
throw new Error('Invalid offset value.') throw new Error('Invalid offset value.')
} }
if (args.limit && (args.limit < 1 || args.limit > 100)) { if (args.limit && (args.limit < 1 || args.limit > 100)) {
throw new Error('Limit must be between 1 and 100.') throw new Error('Limit must be between 1 and 100.')
} }
try { try {
const dictName = 'english' // TODO: Use provided locale or fallback on site locale const dictName = 'english' // TODO: Use provided locale or fallback on site locale
// -> Select Columns
const searchCols = [ const searchCols = [
'id', 'id',
'path', 'path',
@ -64,18 +72,26 @@ export default {
'title', 'title',
'description', 'description',
'icon', 'icon',
'tags',
'updatedAt', 'updatedAt',
WIKI.db.knex.raw('ts_rank_cd(ts, query) AS relevancy'),
WIKI.db.knex.raw('count(*) OVER() AS total') 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])) 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 const results = await WIKI.db.knex
.select(searchCols) .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('siteId', args.siteId)
.where('isSearchableComputed', true) .where('isSearchableComputed', true)
.where(builder => { .where(builder => {
@ -91,14 +107,19 @@ export default {
if (args.publishState) { if (args.publishState) {
builder.where('publishState', 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') .orderBy(args.orderBy || 'relevancy', args.orderByDirection || 'desc')
.offset(args.offset || 0) .offset(args.offset || 0)
.limit(args.limit || 25) .limit(args.limit || 25)
// -> Remove highlights without matches // -> Remove highlights without matches
if (WIKI.config.search.termHighlighting) { if (WIKI.config.search.termHighlighting && hasQuery) {
for (const r of results) { for (const r of results) {
if (r.highlight?.indexOf('<b>') < 0) { if (r.highlight?.indexOf('<b>') < 0) {
r.highlight = null r.highlight = null
@ -268,50 +289,10 @@ export default {
* FETCH TAGS * FETCH TAGS
*/ */
async tags (obj, args, context, info) { async tags (obj, args, context, info) {
const pages = await WIKI.db.pages.query() if (!args.siteId) { throw new Error('Missing Site ID')}
.column([ const tags = await WIKI.db.knex('tags').where('siteId', args.siteId).orderBy('tag')
'path', // TODO: check permissions
{ locale: 'localeCode' } return tags
])
.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)
}, },
/** /**
* FETCH PAGE TREE * FETCH PAGE TREE

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

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

@ -320,7 +320,7 @@ export class Page extends Model {
// -> Get Tags // -> Get Tags
let tags = [] let tags = []
if (opts.tags && opts.tags.length > 0) { 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 // -> Create page
@ -635,6 +635,7 @@ export class Page extends Model {
// -> Tags // -> Tags
if ('tags' in opts.patch) { if ('tags' in opts.patch) {
patch.tags = await WIKI.db.tags.processNewTags(opts.patch.tags, ogPage.siteId)
historyData.affectedFields.push('tags') historyData.affectedFields.push('tags')
} }
@ -646,11 +647,6 @@ export class Page extends Model {
}).where('id', ogPage.id) }).where('id', ogPage.id)
let page = await WIKI.db.pages.getPageFromDb(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 // -> Render page to HTML
if (opts.patch.content) { if (opts.patch.content) {
await WIKI.db.pages.renderPage(page) 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 // -> Get latest updatedAt
page.updatedAt = await WIKI.db.pages.query().findById(page.id).select('updatedAt').then(r => r.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(` await WIKI.db.knex.raw(`
INSERT INTO "autocomplete" (word) INSERT INTO "autocomplete" (word)
SELECT word FROM ts_stat( 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'
) )
`) `)
} }

@ -1,7 +1,7 @@
import { Model } from 'objection' 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 * Tags model
@ -15,7 +15,7 @@ export class Tag extends Model {
required: ['tag'], required: ['tag'],
properties: { properties: {
id: {type: 'integer'}, id: {type: 'string'},
tag: {type: 'string'}, tag: {type: 'string'},
createdAt: {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() { $beforeUpdate() {
this.updatedAt = new Date().toISOString() this.updatedAt = new Date().toISOString()
} }
@ -49,53 +32,28 @@ export class Tag extends Model {
this.updatedAt = new Date().toISOString() this.updatedAt = new Date().toISOString()
} }
static async associateTags ({ tags, page }) { static async processNewTags (tags, siteId) {
let existingTags = await WIKI.db.tags.query().column('id', 'tag') // Validate tags
// Format tags
tags = uniq(tags.map(t => t.trim().toLowerCase())) const normalizedTags = uniq(tags.map(t => t.trim().toLowerCase().replaceAll('#', '')).filter(t => t))
// Create missing tags
const newTags = tags.filter(t => !some(existingTags, ['tag', t])).map(t => ({ tag: t })) for (const tag of normalizedTags) {
if (newTags.length > 0) { if (!allowedCharsRgx.test(tag)) {
if (WIKI.config.db.type === 'postgres') { throw new Error(`Tag #${tag} has invalid characters. Must consists of letters (no diacritics), numbers, CJK logograms and dashes only.`)
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)
}
} }
} }
// Fetch current page tags // Fetch existing tags
const targetTags = existingTags.filter(t => tags.includes(t.tag))
const currentTags = await page.$relatedQuery('tags')
// Tags to relate const existingTags = await WIKI.db.knex('tags').column('tag').where('siteId', siteId).pluck('tag')
const tagsToRelate = differenceBy(targetTags, currentTags, 'id') // Create missing tags
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') const newTags = difference(normalizedTags, existingTags).map(t => ({ tag: t, usageCount: 1, siteId }))
if (tagsToUnrelate.length > 0) { if (newTags.length > 0) {
await page.$relatedQuery('tags').unrelate().whereIn('tags.id', tagsToUnrelate.map(t => t.id)) await WIKI.db.tags.query().insert(newTags)
} }
page.tags = targetTags return normalizedTags
} }
} }

@ -24,56 +24,7 @@ q-header.bg-header.text-white.site-header(
style='height: 34px' style='height: 34px'
) )
q-toolbar-title.text-h6(v-if='siteStore.logoText') {{siteStore.title}} q-toolbar-title.text-h6(v-if='siteStore.logoText') {{siteStore.title}}
q-toolbar.gt-sm( header-search
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'
)
q-toolbar( q-toolbar(
style='height: 64px;' style='height: 64px;'
dark dark
@ -138,7 +89,7 @@ q-header.bg-header.text-white.site-header(
<script setup> <script setup>
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useQuasar } from 'quasar' 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 { useRouter, useRoute } from 'vue-router'
import { useCommonStore } from 'src/stores/common' import { useCommonStore } from 'src/stores/common'
@ -147,6 +98,7 @@ import { useUserStore } from 'src/stores/user'
import AccountMenu from 'src/components/AccountMenu.vue' import AccountMenu from 'src/components/AccountMenu.vue'
import NewMenu from 'src/components/PageNewMenu.vue' import NewMenu from 'src/components/PageNewMenu.vue'
import HeaderSearch from 'src/components/HeaderSearch.vue'
// QUASAR // QUASAR
@ -167,55 +119,9 @@ const route = useRoute()
const { t } = useI18n() const { t } = useI18n()
// DATA
const state = reactive({
searchKbdShortcutShown: true
})
const searchField = ref(null)
// METHODS // METHODS
function openFileManager () { function openFileManager () {
siteStore.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> </script>
<style lang="scss">
</style>

@ -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>

@ -164,16 +164,17 @@
@click.exact='saveChanges(false)' @click.exact='saveChanges(false)'
@click.ctrl.exact='saveChanges(true)' @click.ctrl.exact='saveChanges(true)'
) )
q-separator(vertical, dark) template(v-if='editorStore.isActive')
q-btn.acrylic-btn( q-separator(vertical, dark)
flat q-btn.acrylic-btn(
icon='las la-check-double' flat
color='positive' icon='las la-check-double'
:aria-label='t(`common.actions.saveAndClose`)' color='positive'
:disabled='!editorStore.hasPendingChanges' :aria-label='t(`common.actions.saveAndClose`)'
@click='saveChanges(true)' :disabled='!editorStore.hasPendingChanges'
) @click='saveChanges(true)'
q-tooltip {{ t(`common.actions.saveAndClose`) }} )
q-tooltip {{ t(`common.actions.saveAndClose`) }}
template(v-else-if='userStore.can(`edit:pages`)') template(v-else-if='userStore.can(`edit:pages`)')
q-btn.acrylic-btn.q-ml-md( q-btn.acrylic-btn.q-ml-md(
flat flat
@ -222,20 +223,6 @@ const route = useRoute()
const { t } = useI18n() 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 // METHODS
function openEditorSettings () { function openEditorSettings () {

@ -12,32 +12,44 @@
v-for='tag of pageStore.tags' v-for='tag of pageStore.tags'
:key='`tag-` + tag' :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}} span.text-caption {{tag}}
q-chip( q-select.q-mt-md(
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(
v-if='props.edit' v-if='props.edit'
outlined outlined
v-model='state.newTag' v-model='pageStore.tags'
:options='state.filteredTags'
dense 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> </template>
<script setup> <script setup>
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import { reactive } from 'vue' import { reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { DateTime } from 'luxon'
import { useEditorStore } from 'src/stores/editor'
import { usePageStore } from 'src/stores/page' import { usePageStore } from 'src/stores/page'
import { useSiteStore } from 'src/stores/site'
// PROPS // PROPS
@ -54,7 +66,9 @@ const $q = useQuasar()
// STORES // STORES
const editorStore = useEditorStore()
const pageStore = usePageStore() const pageStore = usePageStore()
const siteStore = useSiteStore()
// I18N // I18N
@ -63,11 +77,61 @@ const { t } = useI18n()
// DATA // DATA
const state = reactive({ 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 // 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) { function removeTag (tag) {
pageStore.tags = pageStore.tags.filter(t => t !== tag) pageStore.tags = pageStore.tags.filter(t => t !== tag)
} }

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

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

@ -44,6 +44,8 @@ export const useSiteStore = defineStore('site', {
nativeName: 'English' nativeName: 'English'
}] }]
}, },
tags: [],
tagsLoaded: false,
theme: { theme: {
dark: false, dark: false,
injectCSS: '', injectCSS: '',
@ -192,6 +194,8 @@ export const useSiteStore = defineStore('site', {
primary: clone(siteInfo.locales.primary), primary: clone(siteInfo.locales.primary),
active: sortBy(clone(siteInfo.locales.active), ['nativeName', 'name']) active: sortBy(clone(siteInfo.locales.active), ['nativeName', 'name'])
}, },
tags: [],
tagsLoaded: false,
theme: { theme: {
...this.theme, ...this.theme,
...clone(siteInfo.theme) ...clone(siteInfo.theme)
@ -204,6 +208,33 @@ export const useSiteStore = defineStore('site', {
console.warn(err.networkError?.result ?? err.message) console.warn(err.networkError?.result ?? err.message)
throw err 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
}
} }
} }
}) })

Loading…
Cancel
Save