Skip to content

Instantly share code, notes, and snippets.

@onlime
Created Aug 30, 2021
Embed
What would you like to do?
Algolia Vue InstantSearch component in Nuxt/content
ALGOLIA_INDEX=dev_articles
ALGOLIA_APP_ID=ABCDE12345
#ALGOLIA_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
ALGOLIA_SEARCH_ONLY_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
ALGOLIA_HITS_PER_PAGE=5
ALGOLIA_QUERY_BUFFER_TIME=300
<template>
<!-- https://www.algolia.com/doc/api-reference/widgets/instantsearch/vue/ -->
<ais-instant-search
:search-client="searchClient"
:index-name="$config.algoliaIndex"
:search-function="searchFunction"
>
<!-- https://www.algolia.com/doc/api-reference/widgets/configure/vue/ -->
<!-- eslint-disable-next-line vue/attribute-hyphenation -->
<ais-configure :attributesToSnippet="['bodyPlainText']" :hits-per-page.camel="$config.algoliaHitsPerPage" />
<!-- https://www.algolia.com/doc/api-reference/widgets/autocomplete/vue/ -->
<ais-autocomplete v-click-outside="onClickOutside">
<div slot-scope="{ currentRefinement, indices, refine }" class="relative">
<div class="px-3 py-2 rounded-full flex items-center bg-gray-950 dark:bg-gray-700 dark:bg-opacity-70">
<SvgSearchIcon />
<input
ref="searchInput"
autocomplete="off"
type="search"
:value="currentRefinement"
:placeholder="searchPlaceholder"
class="
ml-2
w-full
sm:w-32
md:w-40 md:focus:w-52
bg-transparent
outline-none
transition-all
ease-in-out
duration-300
placeholder-gray-400
"
@input="refine($event.currentTarget.value)"
@focus="showResults = true"
@keydown.esc.prevent="onClickOutside"
@keydown.up.prevent="highlightPrevious(indices[0].hits.length)"
@keydown.down.prevent="highlightNext(indices[0].hits.length)"
@keydown.enter="goToArticle(indices)"
/>
</div>
<div
v-if="currentRefinement.length && showResults"
class="absolute sm:right-4 w-auto sm:w-104 z-10 transform mt-2 px-2 max-w-md sm:px-0"
>
<div class="rounded-md shadow-lg overflow-hidden">
<!-- Lo-fi Tailwind CSS Tooltip - https://codepen.io/robstinson/pen/eYZLRdv -->
<div
class="
absolute
-top-3
left-10
sm:left-auto sm:right-36
w-6
h-6
rotate-45
rounded-sm
bg-indigo-900
"
/>
<div class="relative grid gap-6 text-gray-100 px-5 py-6 sm:gap-8 sm:p-5 bg-indigo-900">
<div v-for="section in indices" :key="section.objectID" class="divide-y divide-indigo-600">
<NuxtLink
v-for="(hit, index) in section.hits"
:key="hit.objectID"
:to="{ name: 'articles-slug', params: { slug: hit.objectID } }"
class="block text-sm col-span-2 py-2 transition ease-in-out duration-150"
:class="{
'bg-indigo-700 transition duration-200 ease-in-out transform hover:scale-105':
isCurrentIndex(index),
}"
@click.native="onSearchResultClick"
>
<div class="px-2 text-left" @mouseover="highlightedIndex = index">
<!-- https://www.algolia.com/doc/api-reference/widgets/highlight/vue/ -->
<ais-highlight
attribute="title"
:hit="hit"
class="block text-green-400 font-semibold text-base"
/>
<!-- https://www.algolia.com/doc/api-reference/widgets/snippet/vue/ -->
<ais-snippet attribute="bodyPlainText" :hit="hit" class="block text-gray-200" />
</div>
</NuxtLink>
</div>
<!-- https://www.algolia.com/doc/api-reference/widgets/powered-by/vue/ -->
<ais-powered-by theme="dark" class="px-2" />
</div>
</div>
</div>
</div>
</ais-autocomplete>
</ais-instant-search>
</template>
<script>
import algoliasearch from 'algoliasearch/lite'
import vClickOutside from 'v-click-outside'
export default {
directives: {
clickOutside: vClickOutside.directive,
},
emits: ['results-closed'],
data() {
return {
showResults: false,
highlightedIndex: -1,
currentQuery: null,
}
},
computed: {
searchPlaceholder() {
// navigator.platform may be 'iPhone', 'MacIntel', 'Mac???', 'Win32', 'Windows'
if (navigator.platform.includes('Mac')) {
return 'Search - ⌘k to focus'
} else if (navigator.platform.includes('Win')) {
return 'Search - ⊞k to focus'
} else {
return 'Search'
}
},
searchClient() {
// By default, InstantSearch sends an initial request to Algolia’s servers with an empty query.
// This connection helps speed up later requests.
// But we want to limit the number of search requests and reduce your overall Algolia usage.
// https://www.algolia.com/doc/guides/building-search-ui/going-further/conditional-requests/vue/
// https://github.com/algolia/doc-code-samples/tree/master/Vue%20InstantSearch/conditional-request
const algoliaClient = algoliasearch(this.$config.algoliaAppId, this.$config.algoliaSearchOnlyKey)
return {
...algoliaClient,
search(requests) {
if (requests.every(({ params }) => !params.query)) {
return Promise.resolve({
results: requests.map(() => ({
hits: [],
nbHits: 0,
processingTimeMS: 0,
})),
})
}
return algoliaClient.search(requests)
},
}
},
},
watch: {
$route() {
this.showResults = false
this.$refs.searchInput.blur()
},
},
mounted() {
this.$nextTick(function () {
window.addEventListener('keydown', (event) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
this.$refs.searchInput.select()
event.preventDefault()
}
})
})
},
methods: {
// https://www.algolia.com/doc/api-reference/widgets/instantsearch/vue/#widget-param-search-function
searchFunction(helper) {
this.currentQuery = helper.state.query
if (helper.state.query) {
// buffer search queries, so that an Algolia search is not triggered if another
// search query has overwritten the currentQuery during the same 300ms (ALGOLIA_QUERY_BUFFER_TIME env var)
setTimeout(() => {
if (this.currentQuery === helper.state.query) {
// console.log('Algolia search called with query: ' + helper.state.query)
// console.log(helper)
helper.search()
// ensure that search results are always shown, even if they were hidden by previous navigation to
// same route that was already active
this.showResults = true
}
}, this.$config.algoliaQueryBufferTime)
} else {
// on empty search query, fire search immediately (will send local response, see computed searchClient())
// console.log('Algolia search called with query: ' + helper.state.query)
helper.search()
}
},
onClickOutside() {
this.showResults = false
// make sure focus is removed from search field, e.g. on esc key
this.$refs.searchInput.blur()
},
highlightPrevious(resultsCount) {
if (this.highlightedIndex > 0 && this.highlightedIndex <= resultsCount) {
this.highlightedIndex -= 1
} else {
this.highlightedIndex = resultsCount - 1
}
},
highlightNext(resultsCount) {
if (this.highlightedIndex < resultsCount - 1) {
this.highlightedIndex += 1
} else {
this.highlightedIndex = 0
}
},
isCurrentIndex(index) {
return index === this.highlightedIndex
},
onSearchResultClick() {
// ensure search results are closed, even if navigating to current route
this.onClickOutside()
// ensure mobile menu is closed, once we navigate to search result via click
this.$emit('results-closed')
},
goToArticle(indices) {
if (indices[0].hits[this.highlightedIndex] === undefined) {
// search results are not yet loaded, maybe enter key pressed to early
return
}
this.onClickOutside()
// ensure mobile menu is closed, once we navigate to search result via keyboard
this.$emit('results-closed')
// this.$nuxt.$router.push(indices[0].hits[this.highlightedIndex].objectID)
this.$nuxt.$router.push({
name: 'articles-slug',
params: { slug: indices[0].hits[this.highlightedIndex].objectID },
})
},
},
}
</script>
<style>
.ais-Highlight-highlighted,
.ais-Snippet-highlighted {
@apply bg-pink-500 text-white p-0.5 rounded-sm;
}
</style>
export default {
// ...
publicRuntimeConfig: {
algoliaAppId: process.env.ALGOLIA_APP_ID,
algoliaSearchOnlyKey: process.env.ALGOLIA_SEARCH_ONLY_KEY,
algoliaIndex: process.env.ALGOLIA_INDEX || 'articles',
algoliaHitsPerPage: process.env.ALGOLIA_HITS_PER_PAGE || 5,
algoliaQueryBufferTime: process.env.ALGOLIA_QUERY_BUFFER_TIME || 300,
},
plugins: [
'~/plugins/vue-instantsearch',
],
build: {
transpile: [
'vue-instantsearch',
'instantsearch.js/es',
],
},
buildModules: [
'nuxt-content-algolia',
],
nuxtContentAlgolia: {
appId: process.env.ALGOLIA_APP_ID,
// !IMPORTANT secret key should always be an environment variable
// this is not your search only key but the key that grants access to modify the index
apiKey: process.env.ALGOLIA_API_KEY,
// relative to content directory - each path get's its own index
paths: [
{
name: 'articles',
index: process.env.ALGOLIA_INDEX || 'articles',
fields: ['title', 'description', 'bodyPlainText', 'tags'],
},
],
},
hooks: {
'content:file:beforeInsert': (document) => {
if (document.extension === '.md') {
const removeMd = require('remove-markdown')
document.bodyPlainText = removeMd(document.text)
}
},
},
// ...
}
import Vue from 'vue'
import InstantSearch from 'vue-instantsearch'
Vue.use(InstantSearch)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment