mirror of
https://github.com/requarks/wiki.git
synced 2026-08-26 13:17:10 -05:00
feat: tags search + search improvements
This commit is contained in:
@@ -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'
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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'
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
+18
-60
@@ -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')
|
||||
static async processNewTags (tags, siteId) {
|
||||
// Validate tags
|
||||
|
||||
// Format tags
|
||||
const normalizedTags = uniq(tags.map(t => t.trim().toLowerCase().replaceAll('#', '')).filter(t => t))
|
||||
|
||||
tags = uniq(tags.map(t => t.trim().toLowerCase()))
|
||||
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 existing tags
|
||||
|
||||
const existingTags = await WIKI.db.knex('tags').column('tag').where('siteId', siteId).pluck('tag')
|
||||
|
||||
// Create missing tags
|
||||
|
||||
const newTags = tags.filter(t => !some(existingTags, ['tag', t])).map(t => ({ tag: t }))
|
||||
const newTags = difference(normalizedTags, existingTags).map(t => ({ tag: t, usageCount: 1, siteId }))
|
||||
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)
|
||||
}
|
||||
}
|
||||
await WIKI.db.tags.query().insert(newTags)
|
||||
}
|
||||
|
||||
// Fetch current page tags
|
||||
|
||||
const targetTags = existingTags.filter(t => tags.includes(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', tagsToUnrelate.map(t => t.id))
|
||||
}
|
||||
|
||||
page.tags = targetTags
|
||||
return normalizedTags
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.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 () {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user