Skip to content

Commit cac93a9

Browse files
committed
Implement search page
1 parent 7662f5f commit cac93a9

File tree

4 files changed

+271
-0
lines changed

4 files changed

+271
-0
lines changed

package.json

Lines changed: 1 addition & 0 deletions
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
}
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
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: (new URL(location)).searchParams.get('q') || '',
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+
if (!this.isAlgoliaConfigured)
100+
return;
101+
102+
this.searchPlaceholder = this.$site.themeConfig.searchPlaceholder || 'Search Vue.js'
103+
this.initializeAlgoliaIndex(this.algoliaOptions, this.$lang)
104+
this.initializeInfiniteScrollObserver()
105+
},
106+
107+
destroyed () {
108+
if (!this.infiniteScrollObserver)
109+
return;
110+
111+
this.infiniteScrollObserver.disconnect()
112+
},
113+
114+
methods: {
115+
async initializeAlgoliaIndex (userOptions, lang) {
116+
const { default: algoliasearch } = await import(/* webpackChunkName: "search-page" */ 'algoliasearch/dist/algoliasearchLite.min.js')
117+
const client = algoliasearch(this.algoliaOptions.appId, this.algoliaOptions.apiKey);
118+
119+
this.algoliaIndex = client.initIndex(this.algoliaOptions.indexName);
120+
121+
this.refreshSearchResults()
122+
},
123+
124+
async initializeInfiniteScrollObserver() {
125+
await import(/* webpackChunkName: "search-page" */ 'intersection-observer/intersection-observer.js')
126+
127+
this.infiniteScrollObserver = new IntersectionObserver(([{ isIntersecting }]) => {
128+
if (!isIntersecting || this.totalResults === 0 || this.totalPages === this.lastPage + 1)
129+
return
130+
131+
this.lastPage++
132+
this.updateSearchResults()
133+
})
134+
135+
this.infiniteScrollObserver.observe(this.$refs.infiniteScrollAnchor)
136+
},
137+
138+
async updateSearchResults() {
139+
if (!this.search)
140+
return
141+
142+
const response = await this.algoliaIndex.search(this.search, { page: this.lastPage })
143+
144+
this.results.push(...response.hits.map(hit => this.parseSearchHit(hit)))
145+
this.totalResults = response.nbHits
146+
this.totalPages = response.nbPages
147+
this.queryTime = response.processingTimeMS
148+
},
149+
150+
refreshSearchResults() {
151+
this.results = []
152+
this.totalResults = 0
153+
this.totalPages = 0
154+
this.lastPage = 0
155+
this.queryTime = 0
156+
157+
this.updateSearchResults()
158+
},
159+
160+
visitFirstResult(e) {
161+
e.preventDefault()
162+
163+
if (this.results.length === 0)
164+
return;
165+
166+
window.location = this.results[0].url
167+
},
168+
169+
parseSearchHit(hit) {
170+
const hierarchy = hit._highlightResult.hierarchy
171+
const titles = []
172+
173+
let summary, levelName, level = 0
174+
while ((levelName = 'lvl' + level++) in hierarchy) {
175+
titles.push(hierarchy[levelName].value)
176+
}
177+
178+
if (hit._snippetResult && hit._snippetResult.content) {
179+
summary = hit._snippetResult.content.value + '...'
180+
}
181+
182+
return {
183+
title: titles.pop(),
184+
url: hit.url,
185+
summary: summary,
186+
breadcrumbs: titles,
187+
}
188+
}
189+
}
190+
}
191+
</script>
192+
193+
<style lang="scss">
194+
@import "@theme/styles/_settings.scss";
195+
196+
#search-page {
197+
198+
.search-box {
199+
width: 100%;
200+
display: flex;
201+
flex-direction: column;
202+
203+
.search-query {
204+
width: auto;
205+
}
206+
207+
.search-footer {
208+
display: flex;
209+
height: 35px;
210+
align-items: center;
211+
justify-content: space-between;
212+
margin-bottom: 12px;
213+
214+
p {
215+
margin: 0;
216+
padding: 0;
217+
font-size: .9rem;
218+
}
219+
220+
.algolia-docsearch-footer--logo {
221+
width: 115px;
222+
height: 16px;
223+
}
224+
225+
}
226+
227+
}
228+
229+
.search-result {
230+
margin-bottom: 15px;
231+
232+
.title {
233+
display: block;
234+
}
235+
236+
.summary {
237+
padding: 0;
238+
margin: 0;
239+
font-size: .9rem;
240+
}
241+
242+
.breadcrumb {
243+
font-size: .9rem;
244+
color: $light;
245+
246+
& + .breadcrumb::before {
247+
content: "\203A\A0";
248+
margin-left: 5px;
249+
color: $light;
250+
}
251+
252+
}
253+
254+
.algolia-docsearch-suggestion--highlight {
255+
color: darken($green, 20%);
256+
font-weight: 600;
257+
}
258+
259+
}
260+
261+
}
262+
</style>

src/search/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Search Vue.js
2+
3+
<search-index/>

yarn.lock

Lines changed: 5 additions & 0 deletions
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)