mirror of
https://github.com/CaiJimmy/hugo-theme-stack.git
synced 2024-11-23 10:21:46 +01:00
feat: add search template
This commit is contained in:
parent
12578a5769
commit
e5f96c8762
6 changed files with 362 additions and 0 deletions
|
@ -207,6 +207,13 @@
|
|||
.article-time {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.article-preview{
|
||||
font-size: 1.4rem;
|
||||
color: var(--card-text-color-tertiary);
|
||||
margin-top: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
90
assets/scss/partials/layout/search.scss
Normal file
90
assets/scss/partials/layout/search.scss
Normal file
|
@ -0,0 +1,90 @@
|
|||
.search-form {
|
||||
margin-bottom: var(--section-separation);
|
||||
position: relative;
|
||||
--button-size: 80px;
|
||||
|
||||
&.widget {
|
||||
--button-size: 60px;
|
||||
|
||||
label {
|
||||
font-size: 1.3rem;
|
||||
top: 10px;
|
||||
}
|
||||
|
||||
input {
|
||||
font-size: 1.5rem;
|
||||
padding: 30px 20px 15px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
left: 20px;
|
||||
font-size: 1.4rem;
|
||||
color: var(--card-text-color-tertiary);
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 40px 20px 20px;
|
||||
border-radius: var(--card-border-radius);
|
||||
background-color: var(--card-background);
|
||||
box-shadow: var(--shadow-l1);
|
||||
color: var(--card-text-color-main);
|
||||
width: 100%;
|
||||
border: 0;
|
||||
-webkit-appearance: none;
|
||||
|
||||
transition: box-shadow 0.3s ease;
|
||||
|
||||
font-size: 1.8rem;
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
box-shadow: var(--shadow-l2);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: var(--button-size);
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
|
||||
padding: 0 10px;
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
|
||||
svg {
|
||||
stroke-width: 2;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--card-text-color-secondary);
|
||||
stroke-width: 1.33;
|
||||
transition: all 0.3s ease;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-result--title {
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 10px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--body-text-color);
|
||||
}
|
|
@ -23,6 +23,7 @@
|
|||
@import "partials/layout/article.scss";
|
||||
@import "partials/layout/taxonomy.scss";
|
||||
@import "partials/layout/404.scss";
|
||||
@import "partials/layout/search.scss";
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
|
|
222
assets/ts/search.tsx
Normal file
222
assets/ts/search.tsx
Normal file
|
@ -0,0 +1,222 @@
|
|||
interface pageData {
|
||||
title: string,
|
||||
date: string,
|
||||
permalink: string,
|
||||
content: string,
|
||||
image?: string,
|
||||
preview: string,
|
||||
matchCount: number
|
||||
}
|
||||
|
||||
const searchForm = document.querySelector('.search-form') as HTMLFormElement;
|
||||
const searchInput = searchForm.querySelector('input') as HTMLInputElement;
|
||||
const searchResultList = document.querySelector('.search-result--list') as HTMLDivElement;
|
||||
const searchResultTitle = document.querySelector('.search-result--title') as HTMLHeadingElement;
|
||||
|
||||
let data: pageData[];
|
||||
|
||||
function createElement(tag, attrs, children) {
|
||||
var element = document.createElement(tag);
|
||||
|
||||
for (let name in attrs) {
|
||||
if (name && attrs.hasOwnProperty(name)) {
|
||||
let value = attrs[name];
|
||||
|
||||
if (name == "dangerouslySetInnerHTML") {
|
||||
element.innerHTML = value.__html;
|
||||
}
|
||||
else if (value === true) {
|
||||
element.setAttribute(name, name);
|
||||
} else if (value !== false && value != null) {
|
||||
element.setAttribute(name, value.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 2; i < arguments.length; i++) {
|
||||
let child = arguments[i];
|
||||
if (child) {
|
||||
element.appendChild(
|
||||
child.nodeType == null ?
|
||||
document.createTextNode(child.toString()) : child);
|
||||
}
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
window.createElement = createElement;
|
||||
|
||||
function escapeRegExp(string) {
|
||||
return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
async function getData() {
|
||||
if (!data) {
|
||||
/// Not fetched yet
|
||||
const jsonURL = searchForm.dataset.json;
|
||||
data = await fetch(jsonURL).then(res => res.json());
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function updateQueryString(keywords: string) {
|
||||
const pageURL = new URL(window.location.toString());
|
||||
|
||||
if (keywords === '') {
|
||||
pageURL.searchParams.delete('keyword')
|
||||
}
|
||||
else {
|
||||
pageURL.searchParams.set('keyword', keywords);
|
||||
}
|
||||
|
||||
window.history.pushState('', '', pageURL.toString());
|
||||
}
|
||||
|
||||
function bindQueryStringChange() {
|
||||
window.addEventListener('popstate', (e) => {
|
||||
handleQueryString()
|
||||
})
|
||||
}
|
||||
|
||||
function handleQueryString() {
|
||||
const pageURL = new URL(window.location.toString());
|
||||
const keywords = pageURL.searchParams.get('keyword');
|
||||
searchInput.value = keywords;
|
||||
|
||||
if (keywords) {
|
||||
doSearch(keywords.split(' '));
|
||||
}
|
||||
else {
|
||||
clear()
|
||||
}
|
||||
}
|
||||
|
||||
function bindSearchForm() {
|
||||
let lastSearch = '';
|
||||
searchForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const keywords = searchInput.value;
|
||||
|
||||
updateQueryString(keywords);
|
||||
|
||||
if (keywords === '') {
|
||||
return clear();
|
||||
}
|
||||
|
||||
if (lastSearch === keywords) return;
|
||||
lastSearch = keywords;
|
||||
|
||||
doSearch(keywords.split(' '));
|
||||
})
|
||||
}
|
||||
|
||||
function clear() {
|
||||
searchResultList.innerHTML = '';
|
||||
searchResultTitle.innerText = '';
|
||||
}
|
||||
|
||||
async function doSearch(keywords: string[]) {
|
||||
const startTime = performance.now();
|
||||
|
||||
const results = await searchKeyword(keywords);
|
||||
clear();
|
||||
|
||||
for (const item of results) {
|
||||
searchResultList.append(render(item));
|
||||
}
|
||||
|
||||
const endTime = performance.now();
|
||||
|
||||
searchResultTitle.innerText = `${results.length} pages (${((endTime - startTime) / 1000).toPrecision(1)} seconds)`;
|
||||
}
|
||||
|
||||
function marker(match, p1, p2, p3, offset, string) {
|
||||
return '<mark>' + match + '</mark>';
|
||||
}
|
||||
|
||||
async function searchKeyword(keywords: string[]) {
|
||||
const rawData = await getData();
|
||||
let results: pageData[] = [];
|
||||
|
||||
keywords.sort((a, b) => {
|
||||
return b.length - a.length
|
||||
});
|
||||
|
||||
for (const item of rawData) {
|
||||
let result = {
|
||||
...item,
|
||||
preview: '',
|
||||
matchCount: 0
|
||||
}
|
||||
|
||||
let matched = false;
|
||||
|
||||
for (const keyword of keywords) {
|
||||
const regex = new RegExp(escapeRegExp(keyword), 'gi');
|
||||
|
||||
const contentMatch = regex.exec(item.content);
|
||||
regex.lastIndex = 0; /// Reset regex
|
||||
const titleMatch = regex.exec(item.title);
|
||||
regex.lastIndex = 0; /// Reset regex
|
||||
|
||||
if (titleMatch) {
|
||||
result.title = item.title.replace(regex, marker);
|
||||
}
|
||||
|
||||
if (titleMatch || contentMatch) {
|
||||
matched = true;
|
||||
++result.matchCount;
|
||||
|
||||
let start = 0,
|
||||
end = 100;
|
||||
|
||||
if (contentMatch) {
|
||||
start = contentMatch.index - 20;
|
||||
end = contentMatch.index + 80
|
||||
|
||||
if (start < 0) start = 0;
|
||||
}
|
||||
|
||||
if (result.preview.indexOf(keyword) !== -1) {
|
||||
result.preview = result.preview.replace(regex, marker);
|
||||
}
|
||||
else {
|
||||
if (start !== 0) result.preview += `[...] `;
|
||||
result.preview += `${result.content.slice(start, end).replace(regex, marker)} `;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matched) {
|
||||
result.preview += '[...]';
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
/** Result with more matches appears first */
|
||||
return results.sort((a, b) => {
|
||||
return b.matchCount - a.matchCount;
|
||||
});
|
||||
}
|
||||
|
||||
const render = (item: pageData) => {
|
||||
return <article>
|
||||
<a href={item.permalink}>
|
||||
<div class="article-details">
|
||||
<h2 class="article-title" dangerouslySetInnerHTML={{ __html: item.title }}></h2>
|
||||
<secion class="article-preview" dangerouslySetInnerHTML={{ __html: item.preview }}></secion>
|
||||
</div>
|
||||
{item.image &&
|
||||
<div class="article-image">
|
||||
<img src={item.image} loading="lazy" />
|
||||
</div>
|
||||
}
|
||||
</a>
|
||||
</article>;
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
handleQueryString();
|
||||
bindQueryStringChange();
|
||||
bindSearchForm();
|
||||
})
|
22
layouts/page/search.html
Normal file
22
layouts/page/search.html
Normal file
|
@ -0,0 +1,22 @@
|
|||
{{ define "body-class" }}template-search{{ end }}
|
||||
{{ define "main" }}
|
||||
<form action="{{ .Permalink }}" class="search-form"{{ with .OutputFormats.Get "json" -}} data-json="{{ .Permalink }}"{{- end }}>
|
||||
<p>
|
||||
<label>Keyword</label>
|
||||
<input name="keyword" placeholder="Type something..." />
|
||||
</p>
|
||||
|
||||
<button title="Search">
|
||||
{{ (resources.Get "icons/search.svg").Content | safeHTML }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<h3 class="search-result--title"></h3>
|
||||
<div class="search-result--list article-list--compact"></div>
|
||||
|
||||
{{- $opts := dict "minify" hugo.IsProduction "JSXFactory" "createElement" -}}
|
||||
{{- $searchScript := resources.Get "ts/search.tsx" | js.Build $opts -}}
|
||||
<script type="text/javascript" src="{{ $searchScript.RelPermalink }}" defer></script>
|
||||
|
||||
{{ partialCached "footer/footer" . }}
|
||||
{{ end }}
|
20
layouts/page/search.json
Normal file
20
layouts/page/search.json
Normal file
|
@ -0,0 +1,20 @@
|
|||
{{- $pages := where .Site.RegularPages "Type" "in" .Site.Params.mainSections -}}
|
||||
{{- $notHidden := where .Site.RegularPages "Params.hidden" "!=" true -}}
|
||||
{{- $filtered := ($pages | intersect $notHidden) -}}
|
||||
|
||||
{{- $result := slice -}}
|
||||
|
||||
{{- range $filtered -}}
|
||||
{{- $data := dict "title" .Title "date" .Date "permalink" .Permalink "content" (htmlUnescape .Plain) -}}
|
||||
|
||||
{{- $image := partialCached "helper/image" (dict "Context" . "Type" "articleList") .RelPermalink "articleList" -}}
|
||||
{{- if and $image.exists $image.resource -}}
|
||||
{{- $thumbnail := $image.resource.Fill "120x120" -}}
|
||||
{{- $image := dict "image" (absURL $thumbnail.Permalink) -}}
|
||||
{{- $data = merge $data $image -}}
|
||||
{{ end }}
|
||||
|
||||
{{- $result = $result | append $data -}}
|
||||
{{- end -}}
|
||||
|
||||
{{ jsonify $result }}
|
Loading…
Reference in a new issue