diff --git a/assets/scss/partials/article.scss b/assets/scss/partials/article.scss index 4f2b940..80d7d9d 100644 --- a/assets/scss/partials/article.scss +++ b/assets/scss/partials/article.scss @@ -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; + } } } diff --git a/assets/scss/partials/layout/search.scss b/assets/scss/partials/layout/search.scss new file mode 100644 index 0000000..ad6a8a2 --- /dev/null +++ b/assets/scss/partials/layout/search.scss @@ -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); +} diff --git a/assets/scss/style.scss b/assets/scss/style.scss index 5e07b9c..dc7000d 100644 --- a/assets/scss/style.scss +++ b/assets/scss/style.scss @@ -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; diff --git a/assets/ts/search.tsx b/assets/ts/search.tsx new file mode 100644 index 0000000..6fdd426 --- /dev/null +++ b/assets/ts/search.tsx @@ -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 '' + match + ''; +} + +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
+ +
+

+ +
+ {item.image && +
+ +
+ } +
+
; +} + +window.addEventListener('load', () => { + handleQueryString(); + bindQueryStringChange(); + bindSearchForm(); +}) \ No newline at end of file diff --git a/layouts/page/search.html b/layouts/page/search.html new file mode 100644 index 0000000..6c98cc5 --- /dev/null +++ b/layouts/page/search.html @@ -0,0 +1,22 @@ +{{ define "body-class" }}template-search{{ end }} +{{ define "main" }} +
+

+ + +

+ + +
+ +

+
+ +{{- $opts := dict "minify" hugo.IsProduction "JSXFactory" "createElement" -}} +{{- $searchScript := resources.Get "ts/search.tsx" | js.Build $opts -}} + + +{{ partialCached "footer/footer" . }} +{{ end }} \ No newline at end of file diff --git a/layouts/page/search.json b/layouts/page/search.json new file mode 100644 index 0000000..4c98536 --- /dev/null +++ b/layouts/page/search.json @@ -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 }} \ No newline at end of file