|
| 1 | +<script setup lang="ts"> |
| 2 | +import { useDebounceFn } from '@vueuse/core' |
| 3 | +
|
| 4 | +const route = useRoute() |
| 5 | +const searchQuery = ref('') |
| 6 | +const searchResults = ref([]) |
| 7 | +const isSearching = ref(false) |
| 8 | +const showResults = ref(false) |
| 9 | +const searchInput = ref<HTMLInputElement>() |
| 10 | +
|
| 11 | +// Determine which docs collection to search based on current route |
| 12 | +const docsCollection = computed(() => { |
| 13 | + const productSlug = route.path.split('/')[1] |
| 14 | + if (productSlug === 'feathers') return 'feathersDocs' |
| 15 | + if (productSlug === 'pinion') return 'pinionDocs' |
| 16 | + if (productSlug === 'auth') return 'authDocs' |
| 17 | + if (productSlug === 'lofi') return 'lofiDocs' |
| 18 | + return 'feathersDocs' // default |
| 19 | +}) |
| 20 | +
|
| 21 | +const productName = computed(() => { |
| 22 | + const slug = route.path.split('/')[1] |
| 23 | + return slug.charAt(0).toUpperCase() + slug.slice(1) |
| 24 | +}) |
| 25 | +
|
| 26 | +// Debounced search function |
| 27 | +const searchDocs = useDebounceFn(async () => { |
| 28 | + if (!searchQuery.value || searchQuery.value.length < 2) { |
| 29 | + searchResults.value = [] |
| 30 | + showResults.value = false |
| 31 | + return |
| 32 | + } |
| 33 | +
|
| 34 | + isSearching.value = true |
| 35 | + showResults.value = true |
| 36 | +
|
| 37 | + try { |
| 38 | + const query = searchQuery.value.toLowerCase() |
| 39 | +
|
| 40 | + // Get all docs for the current collection and filter client-side |
| 41 | + const allDocs = await queryCollection(docsCollection.value).all() |
| 42 | +
|
| 43 | + // Search through title, description, path, and content |
| 44 | + const results = allDocs.filter((doc: any) => { |
| 45 | + const titleMatch = doc.title?.toLowerCase().includes(query) |
| 46 | + const descriptionMatch = doc.description?.toLowerCase().includes(query) |
| 47 | + const pathMatch = doc.path?.toLowerCase().includes(query) |
| 48 | + const stemMatch = doc.stem?.toLowerCase().includes(query) |
| 49 | +
|
| 50 | + // Search through the actual document content |
| 51 | + let contentMatch = false |
| 52 | + if (doc.body) { |
| 53 | + // Convert body to string for searching (body might be structured) |
| 54 | + const bodyText = JSON.stringify(doc.body).toLowerCase() |
| 55 | + contentMatch = bodyText.includes(query) |
| 56 | + } |
| 57 | +
|
| 58 | + return titleMatch || descriptionMatch || pathMatch || stemMatch || contentMatch |
| 59 | + }) |
| 60 | +
|
| 61 | + // Sort results by relevance (title matches first, then description, then content) |
| 62 | + const sortedResults = results.sort((a: any, b: any) => { |
| 63 | + const aTitle = a.title?.toLowerCase().includes(query) ? 3 : 0 |
| 64 | + const aDescription = a.description?.toLowerCase().includes(query) ? 2 : 0 |
| 65 | + const aPath = a.path?.toLowerCase().includes(query) ? 1 : 0 |
| 66 | + const scoreA = aTitle + aDescription + aPath |
| 67 | +
|
| 68 | + const bTitle = b.title?.toLowerCase().includes(query) ? 3 : 0 |
| 69 | + const bDescription = b.description?.toLowerCase().includes(query) ? 2 : 0 |
| 70 | + const bPath = b.path?.toLowerCase().includes(query) ? 1 : 0 |
| 71 | + const scoreB = bTitle + bDescription + bPath |
| 72 | +
|
| 73 | + return scoreB - scoreA |
| 74 | + }) |
| 75 | +
|
| 76 | + searchResults.value = sortedResults.slice(0, 10) // Limit to 10 results |
| 77 | + } catch (error) { |
| 78 | + console.error('Search error:', error) |
| 79 | + searchResults.value = [] |
| 80 | + } finally { |
| 81 | + isSearching.value = false |
| 82 | + } |
| 83 | +}, 300) |
| 84 | +
|
| 85 | +// Watch for search query changes |
| 86 | +watch(searchQuery, () => { |
| 87 | + searchDocs() |
| 88 | +}) |
| 89 | +
|
| 90 | +// Handle keyboard navigation |
| 91 | +const handleKeydown = (event: KeyboardEvent) => { |
| 92 | + if (event.key === 'Escape') { |
| 93 | + showResults.value = false |
| 94 | + searchInput.value?.blur() |
| 95 | + } |
| 96 | +} |
| 97 | +
|
| 98 | +// Close results when clicking outside |
| 99 | +const handleClickOutside = () => { |
| 100 | + setTimeout(() => { |
| 101 | + showResults.value = false |
| 102 | + }, 200) |
| 103 | +} |
| 104 | +
|
| 105 | +const navigateToResult = (path: string) => { |
| 106 | + showResults.value = false |
| 107 | + searchQuery.value = '' |
| 108 | + navigateTo(path) |
| 109 | +} |
| 110 | +</script> |
| 111 | + |
| 112 | +<template> |
| 113 | + <div class="relative w-full max-w-md"> |
| 114 | + <div class="relative"> |
| 115 | + <Input |
| 116 | + ref="searchInput" |
| 117 | + v-model="searchQuery" |
| 118 | + type="text" |
| 119 | + placeholder="Search docs..." |
| 120 | + class="w-full pr-10" |
| 121 | + @keydown="handleKeydown" |
| 122 | + @focus="showResults = searchQuery.length >= 2" |
| 123 | + @blur="handleClickOutside" |
| 124 | + /> |
| 125 | + <div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none"> |
| 126 | + <Icon |
| 127 | + v-if="!isSearching" |
| 128 | + name="heroicons:magnifying-glass" |
| 129 | + class="w-5 h-5 text-base-content/50" |
| 130 | + /> |
| 131 | + <span |
| 132 | + v-else |
| 133 | + class="loading loading-spinner loading-sm" |
| 134 | + /> |
| 135 | + </div> |
| 136 | + </div> |
| 137 | + |
| 138 | + <!-- Search Results Dropdown --> |
| 139 | + <Transition |
| 140 | + enter-active-class="transition ease-out duration-100" |
| 141 | + enter-from-class="transform opacity-0 scale-95" |
| 142 | + enter-to-class="transform opacity-100 scale-100" |
| 143 | + leave-active-class="transition ease-in duration-75" |
| 144 | + leave-from-class="transform opacity-100 scale-100" |
| 145 | + leave-to-class="transform opacity-0 scale-95" |
| 146 | + > |
| 147 | + <div |
| 148 | + v-if="showResults && searchQuery.length >= 2" |
| 149 | + class="absolute z-50 w-full mt-2 bg-base-100 rounded-lg shadow-xl border border-base-300 max-h-96 overflow-y-auto" |
| 150 | + > |
| 151 | + <div v-if="isSearching" class="p-4 text-center text-base-content/70"> |
| 152 | + <span class="loading loading-spinner loading-md" /> |
| 153 | + </div> |
| 154 | + |
| 155 | + <div v-else-if="searchResults.length === 0" class="p-4 text-center text-base-content/70"> |
| 156 | + No results found for "{{ searchQuery }}" in {{ productName }} docs |
| 157 | + </div> |
| 158 | + |
| 159 | + <ul v-else class="py-2"> |
| 160 | + <li |
| 161 | + v-for="result in searchResults" |
| 162 | + :key="result.path" |
| 163 | + class="hover:bg-base-200 transition-colors" |
| 164 | + > |
| 165 | + <a |
| 166 | + :href="result.path" |
| 167 | + class="block px-4 py-3 cursor-pointer" |
| 168 | + @click.prevent="navigateToResult(result.path)" |
| 169 | + > |
| 170 | + <div class="font-medium text-base-content"> |
| 171 | + {{ result.title }} |
| 172 | + </div> |
| 173 | + <div v-if="result.description" class="text-sm text-base-content/70 mt-1 line-clamp-2"> |
| 174 | + {{ result.description }} |
| 175 | + </div> |
| 176 | + <div class="text-xs text-base-content/50 mt-1"> |
| 177 | + {{ result.path }} |
| 178 | + </div> |
| 179 | + </a> |
| 180 | + </li> |
| 181 | + </ul> |
| 182 | + </div> |
| 183 | + </Transition> |
| 184 | + </div> |
| 185 | +</template> |
| 186 | + |
| 187 | +<style scoped> |
| 188 | +.line-clamp-2 { |
| 189 | + display: -webkit-box; |
| 190 | + -webkit-line-clamp: 2; |
| 191 | + -webkit-box-orient: vertical; |
| 192 | + overflow: hidden; |
| 193 | +} |
| 194 | +</style> |
0 commit comments