feat(archives): add search bar to archives page

This commit is contained in:
Jimmy Cai 2024-02-26 12:13:52 +01:00
parent 4d6b65f63c
commit 4a607c1bff
4 changed files with 214 additions and 6 deletions

165
assets/ts/search.ts Normal file
View File

@ -0,0 +1,165 @@
interface PageData {
title: string;
content: string;
id: string;
}
class Search {
private data: PageData[];
private form: HTMLFormElement;
private input: HTMLInputElement;
private resultTitle: HTMLHeadElement;
private resultTitleTemplate: string;
constructor({ form, input, resultTitle, resultTitleTemplate }) {
this.form = form;
this.input = input;
this.resultTitle = resultTitle;
this.resultTitleTemplate = resultTitleTemplate;
this.handleQueryString();
this.bindQueryStringChange();
this.bindSearchForm();
}
private async getIndex() {
if (!this.data) {
const jsonURL = this.form.dataset.json;
this.data = await fetch(jsonURL).then(res => res.json());
}
return this.data;
}
private async searchKeywords(keywords: string[]) {
const index = await this.getIndex();
/// Return an set of ids that match the keywords
return new Set(index.filter(item => {
return keywords.every(keyword => {
return item.title.includes(keyword) || item.content.includes(keyword);
});
}).map(item => item.id));
}
private async doSearch(keywords: string[]) {
const startTime = performance.now();
const results = await this.searchKeywords(keywords);
this.clear();
/// Hide all articles except the ones that are in the results
const archiveGroups = document.querySelectorAll('.archives-group') as NodeListOf<HTMLDivElement>;
archiveGroups.forEach(group => {
const articles = Array.from(group.querySelectorAll('article'));
articles.map(article => {
article.style.display = 'none';
article.style.removeProperty('border-bottom');
});
const matchingArticles = articles.filter(article => results.has(article.id));
const hasResults = matchingArticles.length > 0;
if (!hasResults) return group.style.display = 'none';
matchingArticles.map(article => article.style.removeProperty('display'));
matchingArticles[matchingArticles.length - 1].style.borderBottom = 'none';
});
const endTime = performance.now();
this.resultTitle.innerText = this.generateResultTitle(results.size, ((endTime - startTime) / 1000).toPrecision(1));
this.resultTitle.style.display = 'block';
}
private generateResultTitle(resultLen, time) {
return this.resultTitleTemplate.replace("#PAGES_COUNT", resultLen).replace("#TIME_SECONDS", time);
}
private bindSearchForm() {
let lastSearch = '';
const eventHandler = (e) => {
e.preventDefault();
const keywords = this.input.value.trim();
Search.updateQueryString(keywords, true);
if (keywords === '') {
lastSearch = '';
return this.clear();
}
if (lastSearch === keywords) return;
lastSearch = keywords;
this.doSearch(keywords.split(' '));
}
this.input.addEventListener('input', eventHandler);
this.input.addEventListener('compositionend', eventHandler);
}
private clear() {
this.resultTitle.style.display = 'none';
document.querySelectorAll('.archives-group, .archives-group article').forEach(el => el.removeAttribute('style'));
}
private bindQueryStringChange() {
window.addEventListener('popstate', (e) => {
this.handleQueryString()
})
}
private handleQueryString() {
const pageURL = new URL(window.location.toString());
const keywords = pageURL.searchParams.get('keyword');
this.input.value = keywords;
if (keywords) {
this.doSearch(keywords.split(' '));
}
else {
this.clear()
}
}
private static updateQueryString(keywords: string, replaceState = false) {
const pageURL = new URL(window.location.toString());
if (keywords === '') {
pageURL.searchParams.delete('keyword')
}
else {
pageURL.searchParams.set('keyword', keywords);
}
if (replaceState) {
window.history.replaceState('', '', pageURL.toString());
}
else {
window.history.pushState('', '', pageURL.toString());
}
}
}
declare global {
interface Window {
searchResultTitleTemplate: string;
}
}
window.addEventListener('load', () => {
setTimeout(function () {
const searchForm = document.getElementById('search-form') as HTMLFormElement,
searchInput = searchForm.querySelector('input') as HTMLInputElement,
searchResultTitle = document.querySelector('.search-result--title') as HTMLHeadingElement;
new Search({
form: searchForm,
input: searchInput,
resultTitle: searchResultTitle,
resultTitleTemplate: window.searchResultTitleTemplate
});
}, 0);
})
export default Search;

View File

@ -1,9 +1,27 @@
{{ define "body-class" }}template-archives{{ end }} {{ define "body-class" }}template-archives template-search{{ end }}
{{ define "head" }}
{{- with .OutputFormats.Get "json" -}}
<link rel="preload" href="{{ .RelPermalink }}" as="fetch" crossorigin="anonymous">
{{- end -}}
{{ end }}
{{ define "main" }} {{ define "main" }}
<form id="search-form" action="{{ .RelPermalink }}" class="search-form"{{ with .OutputFormats.Get "json" -}} data-json="{{ .RelPermalink }}"{{- end }}>
<p>
<label>{{ T "search.title" }}</label>
<input name="keyword" placeholder="{{ T `search.placeholder` }}" />
</p>
<button title="{{ T `search.title` }}">
{{ partial "helper/icon" "search" }}
</button>
</form>
<h3 class="search-result--title section-title">dawdwa</h3>
{{- $taxonomy := $.Site.GetPage "taxonomyTerm" "categories" -}}
{{- $terms := $taxonomy.Pages -}}
{{ if $terms }}
<header> <header>
{{- $taxonomy := $.Site.GetPage "taxonomyTerm" "categories" -}}
{{- $terms := $taxonomy.Pages -}}
{{ if $terms }}
<h2 class="section-title">{{ $taxonomy.Title }}</h2> <h2 class="section-title">{{ $taxonomy.Title }}</h2>
<div class="subsection-list"> <div class="subsection-list">
<div class="article-list--tile"> <div class="article-list--tile">
@ -12,8 +30,8 @@
{{ end }} {{ end }}
</div> </div>
</div> </div>
{{ end }}
</header> </header>
{{ end }}
{{ $pages := where .Site.RegularPages "Type" "in" .Site.Params.mainSections }} {{ $pages := where .Site.RegularPages "Type" "in" .Site.Params.mainSections }}
@ -30,4 +48,16 @@
{{ end }} {{ end }}
{{ partialCached "footer/footer" . }} {{ partialCached "footer/footer" . }}
<script>
window.searchResultTitleTemplate = "{{ T `search.resultTitle` }}"
</script>
{{- $opts := dict "minify" hugo.IsProduction -}}
{{- $searchScript := resources.Get "ts/search.ts" | js.Build $opts -}}
<script type="text/javascript" src="{{ $searchScript.RelPermalink }}" defer></script>
{{ end }}
{{ define "right-sidebar" }}
{{ partial "sidebar/right.html" (dict "Context" . "Scope" "homepage") }}
{{ end }} {{ end }}

View File

@ -0,0 +1,13 @@
{{- $pages := where .Site.RegularPages "Type" "in" .Site.Params.mainSections -}}
{{- $result := slice -}}
{{- range $pages -}}
{{- $data := dict
"title" .Title
"content" (.Plain)
"id" .File.UniqueID
-}}
{{- $result = $result | append $data -}}
{{- end -}}
{{ jsonify $result }}

View File

@ -1,4 +1,4 @@
<article> <article id="{{ .File.UniqueID }}">
<a href="{{ .RelPermalink }}"> <a href="{{ .RelPermalink }}">
<div class="article-details"> <div class="article-details">
<h2 class="article-title"> <h2 class="article-title">