Skip to content

Commit 377b148

Browse files
committed
Implement search page
1 parent 7662f5f commit 377b148

File tree

4 files changed

+273
-0
lines changed

4 files changed

+273
-0
lines changed

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
},
1111
"dependencies": {
1212
"axios": "^0.19.1",
13+
"intersection-observer": "^0.11.0",
1314
"showdown": "^1.9.1"
1415
}
1516
}
+264
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
<template>
2+
<main id="search-page">
3+
4+
<p v-if="!isAlgoliaConfigured">
5+
This search page is not available at the moment, please use the search box in the top navigation bar.
6+
</p>
7+
8+
<template v-else>
9+
10+
<form class="search-box" @submit="visitFirstResult">
11+
12+
<input class="search-query" v-model="search" :placeholder="searchPlaceholder">
13+
14+
<div class="search-footer algolia-autocomplete">
15+
16+
<p>
17+
<template v-if="totalResults">
18+
<strong>{{ totalResults }} results</strong> found in {{ queryTime }}ms
19+
</template>
20+
</p>
21+
22+
<a class="algolia-docsearch-footer--logo" target="_blank" href="https://www.algolia.com/">Search by algolia</a>
23+
24+
</div>
25+
26+
</form>
27+
28+
<template v-if="results.length">
29+
30+
<div v-for="(result, i) in results" :key="i" class="search-result">
31+
<a class="title" :href="result.url" v-html="result.title" />
32+
<p v-if="result.summary" class="summary" v-html="result.summary" />
33+
<div class="breadcrumbs">
34+
<span v-for="(breadcrumb, j) in result.breadcrumbs" :key="j" class="breadcrumb" v-html="breadcrumb" />
35+
</div>
36+
</div>
37+
38+
</template>
39+
40+
<p v-else-if="search">No results found for query "<span v-text="search" />".</p>
41+
42+
<div ref="infiniteScrollAnchor"></div>
43+
44+
</template>
45+
46+
</main>
47+
</template>
48+
49+
<script>
50+
export default {
51+
52+
data () {
53+
return {
54+
algoliaIndex: undefined,
55+
infiniteScrollObserver: undefined,
56+
searchPlaceholder: undefined,
57+
search: '',
58+
results: [],
59+
totalResults: 0,
60+
totalPages: 0,
61+
lastPage: 0,
62+
queryTime: 0
63+
}
64+
},
65+
66+
computed: {
67+
algoliaOptions () {
68+
return (
69+
this.$themeLocaleConfig.algolia || this.$site.themeConfig.algolia || {}
70+
)
71+
},
72+
73+
isAlgoliaConfigured () {
74+
return this.algoliaOptions && this.algoliaOptions.apiKey && this.algoliaOptions.indexName
75+
}
76+
},
77+
78+
watch: {
79+
$lang (newValue) {
80+
this.initializeAlgoliaIndex(this.algoliaOptions, newValue)
81+
},
82+
83+
algoliaOptions (newValue) {
84+
this.initializeAlgoliaIndex(newValue, this.$lang)
85+
},
86+
87+
search () {
88+
this.refreshSearchResults()
89+
90+
window.history.pushState(
91+
{},
92+
'Vue.js Search',
93+
window.location.origin + window.location.pathname + '?q=' + encodeURIComponent(this.search)
94+
)
95+
}
96+
},
97+
98+
mounted () {
99+
this.search = (new URL(location)).searchParams.get('q') || '';
100+
101+
if (!this.isAlgoliaConfigured)
102+
return;
103+
104+
this.searchPlaceholder = this.$site.themeConfig.searchPlaceholder || 'Search Vue.js'
105+
this.initializeAlgoliaIndex(this.algoliaOptions, this.$lang)
106+
this.initializeInfiniteScrollObserver()
107+
},
108+
109+
destroyed () {
110+
if (!this.infiniteScrollObserver)
111+
return;
112+
113+
this.infiniteScrollObserver.disconnect()
114+
},
115+
116+
methods: {
117+
async initializeAlgoliaIndex (userOptions, lang) {
118+
const { default: algoliasearch } = await import(/* webpackChunkName: "search-page" */ 'algoliasearch/dist/algoliasearchLite.min.js')
119+
const client = algoliasearch(this.algoliaOptions.appId, this.algoliaOptions.apiKey);
120+
121+
this.algoliaIndex = client.initIndex(this.algoliaOptions.indexName);
122+
123+
this.refreshSearchResults()
124+
},
125+
126+
async initializeInfiniteScrollObserver() {
127+
await import(/* webpackChunkName: "search-page" */ 'intersection-observer/intersection-observer.js')
128+
129+
this.infiniteScrollObserver = new IntersectionObserver(([{ isIntersecting }]) => {
130+
if (!isIntersecting || this.totalResults === 0 || this.totalPages === this.lastPage + 1)
131+
return
132+
133+
this.lastPage++
134+
this.updateSearchResults()
135+
})
136+
137+
this.infiniteScrollObserver.observe(this.$refs.infiniteScrollAnchor)
138+
},
139+
140+
async updateSearchResults() {
141+
if (!this.search)
142+
return
143+
144+
const response = await this.algoliaIndex.search(this.search, { page: this.lastPage })
145+
146+
this.results.push(...response.hits.map(hit => this.parseSearchHit(hit)))
147+
this.totalResults = response.nbHits
148+
this.totalPages = response.nbPages
149+
this.queryTime = response.processingTimeMS
150+
},
151+
152+
refreshSearchResults() {
153+
this.results = []
154+
this.totalResults = 0
155+
this.totalPages = 0
156+
this.lastPage = 0
157+
this.queryTime = 0
158+
159+
this.updateSearchResults()
160+
},
161+
162+
visitFirstResult(e) {
163+
e.preventDefault()
164+
165+
if (this.results.length === 0)
166+
return;
167+
168+
window.location = this.results[0].url
169+
},
170+
171+
parseSearchHit(hit) {
172+
const hierarchy = hit._highlightResult.hierarchy
173+
const titles = []
174+
175+
let summary, levelName, level = 0
176+
while ((levelName = 'lvl' + level++) in hierarchy) {
177+
titles.push(hierarchy[levelName].value)
178+
}
179+
180+
if (hit._snippetResult && hit._snippetResult.content) {
181+
summary = hit._snippetResult.content.value + '...'
182+
}
183+
184+
return {
185+
title: titles.pop(),
186+
url: hit.url,
187+
summary: summary,
188+
breadcrumbs: titles,
189+
}
190+
}
191+
}
192+
}
193+
</script>
194+
195+
<style lang="scss">
196+
@import "@theme/styles/_settings.scss";
197+
198+
#search-page {
199+
200+
.search-box {
201+
width: 100%;
202+
display: flex;
203+
flex-direction: column;
204+
205+
.search-query {
206+
width: auto;
207+
}
208+
209+
.search-footer {
210+
display: flex;
211+
height: 35px;
212+
align-items: center;
213+
justify-content: space-between;
214+
margin-bottom: 12px;
215+
216+
p {
217+
margin: 0;
218+
padding: 0;
219+
font-size: .9rem;
220+
}
221+
222+
.algolia-docsearch-footer--logo {
223+
width: 115px;
224+
height: 16px;
225+
}
226+
227+
}
228+
229+
}
230+
231+
.search-result {
232+
margin-bottom: 15px;
233+
234+
.title {
235+
display: block;
236+
}
237+
238+
.summary {
239+
padding: 0;
240+
margin: 0;
241+
font-size: .9rem;
242+
}
243+
244+
.breadcrumb {
245+
font-size: .9rem;
246+
color: $light;
247+
248+
& + .breadcrumb::before {
249+
content: "\203A\A0";
250+
margin-left: 5px;
251+
color: $light;
252+
}
253+
254+
}
255+
256+
.algolia-docsearch-suggestion--highlight {
257+
color: darken($green, 20%);
258+
font-weight: 600;
259+
}
260+
261+
}
262+
263+
}
264+
</style>

src/search/README.md

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Search Vue.js
2+
3+
<search-index/>

yarn.lock

+5
Original file line numberDiff line numberDiff line change
@@ -4097,6 +4097,11 @@ internal-ip@^4.3.0:
40974097
default-gateway "^4.2.0"
40984098
ipaddr.js "^1.9.0"
40994099

4100+
intersection-observer@^0.11.0:
4101+
version "0.11.0"
4102+
resolved "https://registry.yarnpkg.com/intersection-observer/-/intersection-observer-0.11.0.tgz#f4ea067070326f68393ee161cc0a2ca4c0040c6f"
4103+
integrity sha512-KZArj2QVnmdud9zTpKf279m2bbGfG+4/kn16UU0NL3pTVl52ZHiJ9IRNSsnn6jaHrL9EGLFM5eWjTx2fz/+zoQ==
4104+
41004105
invariant@^2.2.2, invariant@^2.2.4:
41014106
version "2.2.4"
41024107
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"

0 commit comments

Comments
 (0)