Search.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  1. <template lang="pug">
  2. q-layout(view='hHh Lpr lff')
  3. header-nav
  4. q-page-container.layout-search
  5. .layout-search-card
  6. q-btn.layout-search-back(
  7. icon='las la-arrow-circle-left'
  8. color='white'
  9. flat
  10. round
  11. @click='goBack'
  12. )
  13. q-tooltip(anchor='center left', self='center right') {{ t('common.actions.goback') }}
  14. .layout-search-sd
  15. .text-header {{ t('search.sortBy') }}
  16. q-list(dense, padding)
  17. q-item(
  18. v-for='item of orderByOptions'
  19. clickable
  20. :active='item.value === state.params.orderBy'
  21. @click='setOrderBy(item.value)'
  22. )
  23. q-item-section(side)
  24. q-icon(:name='item.icon', :color='item.value === state.params.orderBy ? `primary` : ``')
  25. q-item-section
  26. q-item-label {{ item.label }}
  27. q-item-section(
  28. v-if='item.value === state.params.orderBy'
  29. side
  30. )
  31. q-icon(
  32. :name='state.params.orderByDirection === `desc` ? `mdi-transfer-down` : `mdi-transfer-up`'
  33. size='sm'
  34. color='primary'
  35. )
  36. .text-header {{ t('search.filters') }}
  37. .q-pa-sm
  38. q-input(
  39. outlined
  40. dense
  41. :placeholder='t(`search.filterPath`)'
  42. prefix='/'
  43. v-model='state.params.filterPath'
  44. )
  45. template(v-slot:prepend)
  46. q-icon(name='las la-caret-square-right', size='xs')
  47. q-select.q-mt-sm(
  48. outlined
  49. v-model='state.selectedTags'
  50. :options='state.filteredTags'
  51. dense
  52. options-dense
  53. use-input
  54. use-chips
  55. multiple
  56. hide-dropdown-icon
  57. :input-debounce='0'
  58. @update:model-value='v => syncTags(v)'
  59. @filter='filterTags'
  60. :placeholder='state.selectedTags.length < 1 ? t(`search.filterTags`) : ``'
  61. :loading='state.loading > 0'
  62. )
  63. template(v-slot:prepend)
  64. q-icon(name='las la-hashtag', size='xs')
  65. template(v-slot:option='scope')
  66. q-item(v-bind='scope.itemProps')
  67. q-item-section(side)
  68. q-checkbox(:model-value='scope.selected', @update:model-value='scope.toggleOption(scope.opt)', size='sm')
  69. q-item-section
  70. q-item-label(v-html='scope.opt')
  71. //- q-input.q-mt-sm(
  72. //- outlined
  73. //- dense
  74. //- placeholder='Last updated...'
  75. //- )
  76. //- template(v-slot:prepend)
  77. //- q-icon(name='las la-calendar', size='xs')
  78. //- q-input.q-mt-sm(
  79. //- outlined
  80. //- dense
  81. //- placeholder='Last edited by...'
  82. //- )
  83. //- template(v-slot:prepend)
  84. //- q-icon(name='las la-user-edit', size='xs')
  85. q-select.q-mt-sm(
  86. outlined
  87. v-model='state.params.filterLocale'
  88. emit-value
  89. map-options
  90. dense
  91. :aria-label='t(`search.filterLocale`)'
  92. :options='siteStore.locales.active'
  93. option-value='code'
  94. option-label='name'
  95. options-dense
  96. multiple
  97. :display-value='t(`search.filterLocaleDisplay`, { n: state.params.filterLocale.length > 0 ? state.params.filterLocale[0].toUpperCase() : state.params.filterLocale.length }, state.params.filterLocale.length)'
  98. )
  99. template(v-slot:prepend)
  100. q-icon(name='las la-language', size='xs')
  101. template(v-slot:option='scope')
  102. q-item(v-bind='scope.itemProps')
  103. q-item-section(side)
  104. q-checkbox(:model-value='scope.selected', @update:model-value='scope.toggleOption(scope.opt)')
  105. q-item-section
  106. q-item-label(v-html='scope.opt.name')
  107. q-select.q-mt-sm(
  108. outlined
  109. v-model='state.params.filterEditor'
  110. emit-value
  111. map-options
  112. dense
  113. :aria-label='t(`search.filterEditor`)'
  114. :options='editors'
  115. )
  116. template(v-slot:prepend)
  117. q-icon(name='las la-pen-nib', size='xs')
  118. q-select.q-mt-sm(
  119. outlined
  120. v-model='state.params.filterPublishState'
  121. emit-value
  122. map-options
  123. dense
  124. :aria-label='t(`search.filterPublishState`)'
  125. :options='publishStates'
  126. )
  127. template(v-slot:prepend)
  128. q-icon(name='las la-traffic-light', size='xs')
  129. q-page(:style-fn='pageStyle')
  130. .text-header.flex
  131. span {{t('search.results')}}
  132. q-space
  133. transition(name='slide-up', mode='out-in')
  134. i18n-t(
  135. v-if='!siteStore.searchIsLoading'
  136. keypath='search.totalResults'
  137. tag='span'
  138. class='text-caption'
  139. :plural='state.total'
  140. )
  141. strong {{ state.total }}
  142. .q-pa-lg(v-if='state.results.length < 1')
  143. i18n-t(keypath='search.noResults', tag='span', v-if='siteStore.search && siteStore.searchLastQuery')
  144. strong {{ siteStore.searchLastQuery }}
  145. span(v-else): em {{ t('search.emptyQuery') }}
  146. q-list(separator)
  147. q-item(
  148. v-for='item of state.results'
  149. clickable
  150. :to='`/` + item.path'
  151. )
  152. q-item-section(avatar)
  153. q-avatar(color='primary' text-color='white' rounded :icon='item.icon')
  154. q-item-section
  155. q-item-label {{ item.title }}
  156. q-item-label(v-if='item.description', caption) {{ item.description }}
  157. q-item-label.text-highlight(v-if='item.highlight', caption, v-html='item.highlight')
  158. q-item-section(side)
  159. .flex.layout-search-itemtags
  160. q-chip(
  161. v-for='tag of item.tags'
  162. square
  163. color='secondary'
  164. text-color='white'
  165. icon='las la-hashtag'
  166. size='sm'
  167. ) {{ tag }}
  168. .flex
  169. .text-caption.q-mr-sm.text-grey /{{ item.path }}
  170. .text-caption {{ humanizeDate(item.updatedAt) }}
  171. q-inner-loading(:showing='state.loading > 0')
  172. main-overlay-dialog
  173. footer-nav
  174. </template>
  175. <script setup>
  176. import { useI18n } from 'vue-i18n'
  177. import { useMeta, useQuasar } from 'quasar'
  178. import { computed, onMounted, onUnmounted, reactive, watch } from 'vue'
  179. import { useRouter, useRoute } from 'vue-router'
  180. import gql from 'graphql-tag'
  181. import { cloneDeep, debounce, difference } from 'lodash-es'
  182. import { DateTime } from 'luxon'
  183. import { useFlagsStore } from 'src/stores/flags'
  184. import { useSiteStore } from 'src/stores/site'
  185. import { useUserStore } from 'src/stores/user'
  186. import HeaderNav from 'src/components/HeaderNav.vue'
  187. import FooterNav from 'src/components/FooterNav.vue'
  188. import MainOverlayDialog from 'src/components/MainOverlayDialog.vue'
  189. const tagsInQueryRgx = /#[a-z0-9-\u3400-\u4DBF\u4E00-\u9FFF]+(?=(?:[^"]*(?:")[^"]*(?:"))*[^"]*$)/g
  190. // QUASAR
  191. const $q = useQuasar()
  192. // STORES
  193. const flagsStore = useFlagsStore()
  194. const siteStore = useSiteStore()
  195. const userStore = useUserStore()
  196. // ROUTER
  197. const router = useRouter()
  198. const route = useRoute()
  199. // I18N
  200. const { t } = useI18n()
  201. // META
  202. useMeta({
  203. titleTemplate: title => `${title} - ${t('profile.title')} - Wiki.js`
  204. })
  205. // DATA
  206. const state = reactive({
  207. loading: 0,
  208. params: {
  209. filterPath: '',
  210. filterLocale: [],
  211. filterEditor: '',
  212. filterPublishState: '',
  213. orderBy: 'relevancy',
  214. orderByDirection: 'desc'
  215. },
  216. selectedTags: [],
  217. filteredTags: [],
  218. results: [],
  219. total: 0
  220. })
  221. const orderByOptions = computed(() => {
  222. return [
  223. { label: t('search.sortByRelevance'), value: 'relevancy', icon: 'las la-stream' },
  224. { label: t('search.sortByTitle'), value: 'title', icon: 'las la-heading' },
  225. { label: t('search.sortByLastUpdated'), value: 'updatedAt', icon: 'las la-calendar' }
  226. ]
  227. })
  228. const editors = computed(() => {
  229. return [
  230. { label: t('search.editorAny'), value: '' },
  231. { label: 'AsciiDoc', value: 'asciidoc' },
  232. { label: 'Markdown', value: 'markdown' },
  233. { label: 'Visual Editor', value: 'wysiwyg' }
  234. ]
  235. })
  236. const publishStates = computed(() => {
  237. return [
  238. { label: t('search.publishStateAny'), value: '' },
  239. { label: t('search.publishStateDraft'), value: 'draft' },
  240. { label: t('search.publishStatePublished'), value: 'published' },
  241. { label: t('search.publishStateScheduled'), value: 'scheduled' }
  242. ]
  243. })
  244. const tags = computed(() => siteStore.tags.map(t => t.tag))
  245. // WATCHERS
  246. watch(() => route.query, async (newQueryObj) => {
  247. if (newQueryObj.q) {
  248. siteStore.search = newQueryObj.q.trim()
  249. syncTags()
  250. performSearch()
  251. }
  252. }, { immediate: true })
  253. watch(() => state.params, debounce(performSearch, 500), { deep: true })
  254. // METHODS
  255. function pageStyle (offset, height) {
  256. return {
  257. 'min-height': `${height - 100 - offset}px`
  258. }
  259. }
  260. function humanizeDate (val) {
  261. return DateTime.fromISO(val).toFormat(userStore.preferredDateFormat)
  262. }
  263. function setOrderBy (val) {
  264. if (val === state.params.orderBy) {
  265. state.params.orderByDirection = state.params.orderByDirection === 'desc' ? 'asc' : 'desc'
  266. } else {
  267. state.params.orderBy = val
  268. state.params.orderByDirection = val === 'title' ? 'asc' : 'desc'
  269. }
  270. }
  271. function filterTags (val, update) {
  272. update(() => {
  273. if (val === '') {
  274. state.filteredTags = tags.value
  275. } else {
  276. const tagSearch = val.toLowerCase()
  277. state.filteredTags = tags.value.filter(
  278. v => v.toLowerCase().indexOf(tagSearch) >= 0
  279. )
  280. }
  281. })
  282. }
  283. function syncTags (newSelection) {
  284. const queryTags = Array.from(siteStore.search.matchAll(tagsInQueryRgx)).map(t => t[0].substring(1))
  285. if (!newSelection) {
  286. state.selectedTags = queryTags
  287. } else {
  288. let newQuery = siteStore.search
  289. for (const tag of newSelection) {
  290. if (!newQuery.includes(`#${tag}`)) {
  291. newQuery = `${newQuery} #${tag}`
  292. }
  293. }
  294. for (const tag of difference(queryTags, newSelection)) {
  295. newQuery = newQuery.replaceAll(`#${tag}`, '')
  296. }
  297. newQuery = newQuery.replaceAll(' ', ' ').trim()
  298. router.replace({ path: '/_search', query: { q: newQuery } })
  299. }
  300. }
  301. async function performSearch () {
  302. siteStore.searchIsLoading = true
  303. try {
  304. let q = siteStore.search
  305. // -> Extract tags
  306. const queryTags = Array.from(q.matchAll(tagsInQueryRgx)).map(t => t[0].substring(1))
  307. for (const tag of queryTags) {
  308. q = q.replaceAll(`#${tag}`, '')
  309. }
  310. q = q.trim().replaceAll(/\s\s+/g, ' ')
  311. const resp = await APOLLO_CLIENT.query({
  312. query: gql`
  313. query searchPages (
  314. $siteId: UUID!
  315. $query: String!
  316. $path: String
  317. $locale: [String]
  318. $tags: [String]
  319. $editor: String
  320. $publishState: PagePublishState
  321. $orderBy: PageSearchSort
  322. $orderByDirection: OrderByDirection
  323. $offset: Int
  324. $limit: Int
  325. ) {
  326. searchPages(
  327. siteId: $siteId
  328. query: $query
  329. path: $path
  330. locale: $locale
  331. tags: $tags
  332. editor: $editor
  333. publishState: $publishState
  334. orderBy: $orderBy
  335. orderByDirection: $orderByDirection
  336. offset: $offset
  337. limit: $limit
  338. ) {
  339. results {
  340. id
  341. path
  342. locale
  343. title
  344. description
  345. icon
  346. tags
  347. updatedAt
  348. relevancy
  349. highlight
  350. }
  351. totalHits
  352. }
  353. }
  354. `,
  355. variables: {
  356. siteId: siteStore.id,
  357. query: q,
  358. path: state.params.filterPath,
  359. tags: queryTags,
  360. locale: state.params.filterLocale,
  361. editor: state.params.filterEditor,
  362. publishState: state.params.filterPublishState || null,
  363. orderBy: state.params.orderBy,
  364. orderByDirection: state.params.orderByDirection
  365. },
  366. fetchPolicy: 'network-only'
  367. })
  368. if (!resp?.data?.searchPages) {
  369. throw new Error('Unexpected error')
  370. }
  371. state.results = cloneDeep(resp.data.searchPages.results).map(r => { r.tags.sort(); return r })
  372. state.total = resp.data.searchPages.totalHits
  373. siteStore.searchLastQuery = siteStore.search
  374. } catch (err) {
  375. $q.notify({
  376. type: 'negative',
  377. message: 'Failed to perform search query.',
  378. caption: err.message
  379. })
  380. }
  381. siteStore.searchIsLoading = false
  382. }
  383. function goBack () {
  384. if (history.length > 0) {
  385. router.back()
  386. } else {
  387. router.push('/')
  388. }
  389. }
  390. // MOUNTED
  391. onMounted(() => {
  392. if (!siteStore.search) {
  393. siteStore.searchIsLoading = false
  394. }
  395. })
  396. onUnmounted(() => {
  397. siteStore.search = ''
  398. siteStore.searchLastQuery = ''
  399. siteStore.searchIsLoading = false
  400. })
  401. </script>
  402. <style lang="scss">
  403. .layout-search {
  404. @at-root .body--light & {
  405. background-color: $grey-3;
  406. }
  407. @at-root .body--dark & {
  408. background-color: $dark-6;
  409. }
  410. &:before {
  411. content: '';
  412. height: 200px;
  413. position: fixed;
  414. top: 0;
  415. width: 100%;
  416. background: radial-gradient(ellipse at bottom, $dark-3, $dark-6);
  417. border-bottom: 1px solid #FFF;
  418. @at-root .body--dark & {
  419. border-bottom-color: $dark-3;
  420. }
  421. }
  422. &:after {
  423. content: '';
  424. height: 1px;
  425. position: fixed;
  426. top: 64px;
  427. width: 100%;
  428. background: linear-gradient(to right, transparent 0%, rgba(255,255,255,.1) 50%, transparent 100%);
  429. }
  430. &-back {
  431. position: absolute;
  432. left: -50px;
  433. }
  434. &-card {
  435. position: relative;
  436. width: 90%;
  437. max-width: 1400px;
  438. margin: 50px auto;
  439. box-shadow: $shadow-2;
  440. border-radius: 7px;
  441. display: flex;
  442. align-items: stretch;
  443. height: 100%;
  444. @at-root .body--light & {
  445. background-color: #FFF;
  446. }
  447. @at-root .body--dark & {
  448. background-color: $dark-3;
  449. }
  450. }
  451. &-sd {
  452. flex: 0 0 300px;
  453. border-radius: 8px 0 0 8px;
  454. overflow: hidden;
  455. @at-root .body--light & {
  456. background-color: $grey-1;
  457. border-right: 1px solid rgba($dark-3, .1);
  458. box-shadow: inset -1px 0 0 #FFF;
  459. }
  460. @at-root .body--dark & {
  461. background-color: $dark-4;
  462. border-right: 1px solid rgba(#FFF, .12);
  463. box-shadow: inset -1px 0 0 rgba($dark-6, .5);
  464. }
  465. }
  466. .text-header {
  467. padding: .75rem 1rem;
  468. font-weight: 500;
  469. @at-root .body--light & {
  470. background-color: $grey-1;
  471. border-bottom: 1px solid $grey-3;
  472. }
  473. @at-root .body--dark & {
  474. background-color: $dark-3;
  475. border-bottom: 1px solid $dark-2;
  476. }
  477. }
  478. .text-highlight {
  479. font-style: italic;
  480. > b {
  481. background-color: rgba($yellow-7, .5);
  482. border-radius: 3px;
  483. }
  484. }
  485. .q-page {
  486. flex: 1 1;
  487. .text-header:first-child {
  488. border-top-right-radius: 7px;
  489. }
  490. @at-root .body--light & {
  491. border-left: 1px solid #FFF;
  492. }
  493. @at-root .body--dark & {
  494. border-left: 1px solid rgba($dark-6, .75);
  495. }
  496. }
  497. &-itemtags {
  498. .q-chip:last-child {
  499. margin-right: 0;
  500. }
  501. }
  502. }
  503. body.body--dark {
  504. background-color: $dark-6;
  505. }
  506. .q-footer {
  507. .q-bar {
  508. @at-root .body--light & {
  509. background-color: $grey-3;
  510. color: $grey-7;
  511. }
  512. @at-root .body--dark & {
  513. background-color: $dark-4;
  514. color: rgba(255,255,255,.3);
  515. }
  516. }
  517. }
  518. </style>