Search.vue 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. <template>
  2. <TransitionRoot
  3. :show="show"
  4. as="template"
  5. @after-leave="query = ''"
  6. appear>
  7. <Dialog as="div" class="relative z-[999]" @close="show = false">
  8. <TransitionChild as="template"
  9. enter="ease-out duration-300"
  10. enter-from="opacity-0"
  11. enter-to="opacity-100"
  12. leave="ease-in duration-200"
  13. leave-from="opacity-100"
  14. leave-to="opacity-0">
  15. <div class="fixed inset-0 bg-gray-500 bg-opacity-25 transition-opacity" />
  16. </TransitionChild>
  17. <div class="fixed inset-0 w-screen overflow-y-auto p-4 sm:p-6 md:p-20">
  18. <TransitionChild
  19. as="template"
  20. enter="ease-out duration-300"
  21. enter-from="opacity-0 scale-95"
  22. enter-to="opacity-100 scale-100"
  23. leave="ease-in duration-200"
  24. leave-from="opacity-100 scale-100"
  25. leave-to="opacity-0 scale-95">
  26. <DialogPanel class="mx-auto max-w-3xl transform divide-y divide-gray-500 divide-opacity-20 overflow-hidden rounded-xl bg-black shadow-2xl transition-all">
  27. <Combobox @update:modelValue="onSelect">
  28. <div class="relative">
  29. <MagnifyingGlassIcon class="pointer-events-none absolute left-4 top-3.5 h-5 w-5 text-gray-500" aria-hidden="true" />
  30. <ComboboxInput class="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-white outline-none sm:text-sm" placeholder="Search..." @input="search()" @change="query = $event.target.value" />
  31. </div>
  32. <ComboboxOptions static class="h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-20 overflow-y-auto">
  33. <ComboboxOptions v-if="query === '' || results.length > 0" static class="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-20 overflow-y-auto">
  34. <li class="p-2" v-if="query !== ''">
  35. <h2 class="mb-2 mt-4 px-3 text-xs font-semibold text-gray-200">Results</h2>
  36. <ul class="text-sm text-gray-400">
  37. <ComboboxOption v-for="link in results" :key="link.id" :value="link" as="template" v-slot="{ active }">
  38. <li :class="['flex cursor-default select-none items-center rounded-md px-3 py-2', active && 'bg-gray-800 text-white']">
  39. <NuxtLink :to="link.id" class="flex-auto flex items-center truncate text-sm">
  40. <DocumentTextIcon :class="['h-5 w-5 flex-none mr-1', active ? 'text-white' : 'text-gray-500']" aria-hidden="true" />
  41. <span class="w-[calc(100%-50px)] truncate" v-html="buildSearchResultTitle(link)"></span>
  42. </NuxtLink>
  43. <span v-if="active" class="ml-3 flex-none text-gray-400">Jump to...</span>
  44. </li>
  45. </ComboboxOption>
  46. </ul>
  47. </li>
  48. <li v-if="query === ''" class="p-2">
  49. <div v-for="group in links" class="border-b border-gray-500 pb-2">
  50. <h2 class="mb-2 mt-4 px-3 text-xs font-semibold text-gray-200">{{ group.title }}</h2>
  51. <ul class="text-sm text-gray-400">
  52. <ComboboxOption v-for="link in group.links" :key="link.id" :value="link" as="template" v-slot="{ active }">
  53. <li :class="['flex cursor-pointer select-none items-center rounded-md px-3 py-2', active && 'bg-gray-800 text-white']">
  54. <component :is="link.icon" :class="['h-6 w-6 flex-none', active ? 'text-white' : 'text-gray-500']" aria-hidden="true" />
  55. <span class="ml-3 flex-auto truncate">{{ link.name }}</span>
  56. </li>
  57. </ComboboxOption>
  58. </ul>
  59. </div>
  60. </li>
  61. </ComboboxOptions>
  62. <li class="px-6 py-14 text-center sm:px-14" v-if="query !== '' && results.length === 0 && !searching">
  63. <FolderIcon class="mx-auto h-6 w-6 text-gray-500" aria-hidden="true" />
  64. <p class="mt-4 text-sm text-gray-200">We couldn't find any results with that term. Please try again.</p>
  65. </li>
  66. </ComboboxOptions>
  67. </Combobox>
  68. </DialogPanel>
  69. </TransitionChild>
  70. </div>
  71. </Dialog>
  72. </TransitionRoot>
  73. </template>
  74. <script setup>
  75. import {
  76. MagnifyingGlassIcon
  77. } from '@heroicons/vue/20/solid'
  78. import {
  79. DocumentTextIcon,
  80. FolderIcon,
  81. FolderPlusIcon,
  82. HashtagIcon,
  83. TagIcon
  84. } from '@heroicons/vue/24/outline'
  85. import {
  86. Combobox,
  87. ComboboxInput,
  88. ComboboxOptions,
  89. ComboboxOption,
  90. Dialog,
  91. DialogPanel,
  92. TransitionChild,
  93. TransitionRoot,
  94. } from '@headlessui/vue'
  95. import DiscordIcon from './DiscordIcon.vue';
  96. import DocsIcon from './DocsIcon.vue';
  97. import HeartIcon from './HeartIcon.vue';
  98. import GitHubIcon from './GitHubIcon.vue';
  99. /**
  100. * CMD + K shortcut for activating the modal
  101. */
  102. const show = ref(false);
  103. const { meta, k } = useMagicKeys();
  104. watchEffect(() => {
  105. if (meta.value && k.value) {
  106. show.value = true;
  107. }
  108. });
  109. /**
  110. * Event handler for opening the modal
  111. */
  112. const docsEventBus = useEventBus('docker-docs-event-bus');
  113. const listener = ( event ) => {
  114. if( event == 'prompt-search' ) {
  115. show.value = true;
  116. }
  117. }
  118. docsEventBus.on(listener);
  119. /**
  120. * Default links
  121. */
  122. const defaultLinks = [
  123. { name: 'Docs', id: '/docs', icon: DocsIcon },
  124. { name: 'Discord', id: 'https://serversideup.net/discord', icon: DiscordIcon, external: true },
  125. { name: 'GitHub', id: 'https://github.com/serversideup', icon: GitHubIcon, external: true },
  126. { name: 'Sponsor', id: 'https://github.com/sponsors/serversideup', icon: HeartIcon, external: true },
  127. ]
  128. const { navigation } = useContent();
  129. const links = computed(() => {
  130. let computedLinks = [];
  131. computedLinks.push({
  132. 'title': 'Links',
  133. 'links': defaultLinks
  134. });
  135. computedLinks.push({
  136. 'title': navigation.value[0].title,
  137. 'links': [{
  138. name: 'Docs',
  139. id: navigation.value[0]._path,
  140. icon: DocumentTextIcon
  141. }]
  142. });
  143. navigation.value[0].children.forEach((link) => {
  144. if( link.children ){
  145. let childLinks = [];
  146. link.children.forEach((link) => {
  147. childLinks.push({
  148. name: link.title,
  149. id: link._path,
  150. icon: DocumentTextIcon
  151. });
  152. });
  153. computedLinks.push({
  154. 'title': link.title,
  155. 'links': childLinks
  156. });
  157. }
  158. });
  159. return computedLinks;
  160. })
  161. /**
  162. * Performs the actual search
  163. */
  164. const query = ref('')
  165. const results = ref([])
  166. const searching = ref(false)
  167. const search = async () => {
  168. searching.value = true
  169. const res = await searchContent(query.value, {})
  170. results.value = res.value // res is a computed so we pluck out the .value and just add it to our ref
  171. searching.value = false
  172. }
  173. /**
  174. * Builds the link text from the search result
  175. */
  176. const buildSearchResultTitle = (link) => {
  177. let highlightedContent = link.content.replace(query.value, '<span class="bg-blue-600 text-white">'+query.value+'</span>');
  178. let title = '<span class="text-[#E2E8F0]">'+( link.titles.length > 0 ? link.titles.join(' > ')+ ' > ' : '' )+ link.title+ ' </span><span class="text-[10px] hidden md:inline">'+highlightedContent+'</span>';
  179. return title;
  180. }
  181. const onSelect = (link) => {
  182. if( link.external ){
  183. window.open(link.id, '_blank');
  184. return;
  185. }else{
  186. navigateTo(link.id);
  187. }
  188. }
  189. </script>