diff --git a/cigaradvisor/blocks/article-list/article-list.js b/cigaradvisor/blocks/article-list/article-list.js index 972f9cc..0d2eca3 100644 --- a/cigaradvisor/blocks/article-list/article-list.js +++ b/cigaradvisor/blocks/article-list/article-list.js @@ -7,7 +7,17 @@ import { import { buildArticleTeaser } from '../article-teaser/article-teaser.js'; import { generatePagination, getCategory } from '../../scripts/util.js'; -export async function renderPage(wrapper, articles, limit) { +/** + * Renders the page with the given wrapper element, articles, limit, and articlesCount. + * + * @param {HTMLElement} wrapper - The wrapper element to render the page into. + * @param {Array} articles - The array of articles to render. + * @param {number} limit - The limit of articles per page. + * @param {number} articlesCount - The total count of articles. This is passed for pagination + * when full list of articles are not passed. + * @returns {Promise} - A promise that resolves when the page is rendered. + */ +export async function renderPage(wrapper, articles, limit, articlesCount) { let pageSize = 10; if (!articles || articles.length === 0) { return; @@ -23,14 +33,25 @@ export async function renderPage(wrapper, articles, limit) { if (match) { currentPage = Number.isNaN(parseInt(match[1], 10)) ? currentPage : parseInt(match[1], 10); } - const totalPages = Math.ceil(articles.length / pageSize); + let totalPages; + let articleList; + /* articlesCount is passed when full list of articles are not passed. + * This is needed for pagination. + */ + if (articlesCount) { + totalPages = Math.ceil(articlesCount / pageSize); + articleList = [...articles]; + } else { + totalPages = Math.ceil(articles.length / pageSize); + articleList = articles.slice((currentPage - 1) * pageSize, currentPage * pageSize); + } // populating authors and categories info cache await Promise.all([getAllAuthors(), fetchAllCategories()]).then(); // eslint-disable-next-line max-len // eslint-disable-next-line max-len - const articlePromises = articles.slice((currentPage - 1) * pageSize, currentPage * pageSize).map(async (article) => { + const articlePromises = articleList.map(async (article) => { const articleTeaser = document.createElement('div'); articleTeaser.classList.add('article-teaser'); articleTeaser.classList.add('block'); diff --git a/cigaradvisor/blocks/search-results/search-results.css b/cigaradvisor/blocks/search-results/search-results.css index 1f79bdd..873af61 100644 --- a/cigaradvisor/blocks/search-results/search-results.css +++ b/cigaradvisor/blocks/search-results/search-results.css @@ -1,3 +1,38 @@ .search-results.block p { text-align: center; -} \ No newline at end of file +} + +.loading-image-container { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} + +.search-results.block .loading-image { + display: block; + margin: 0 auto; + width: 100vw; + height: 300px; +} + +.search-results.block .loader { + width: 48px; + height: 48px; + border: 5px solid var(--medium-grey); + border-bottom-color: transparent; + border-radius: 50%; + display: inline-block; + box-sizing: border-box; + animation: rotation 1s linear infinite; + } + + @keyframes rotation { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } + } \ No newline at end of file diff --git a/cigaradvisor/blocks/search-results/search-results.js b/cigaradvisor/blocks/search-results/search-results.js index ce4de3f..d90e61d 100644 --- a/cigaradvisor/blocks/search-results/search-results.js +++ b/cigaradvisor/blocks/search-results/search-results.js @@ -5,75 +5,173 @@ import { renderPage } from '../article-list/article-list.js'; const searchParams = new URLSearchParams(window.location.search); -function compareFound(hit1, hit2) { - return hit1.minIdx - hit2.minIdx; -} - -function filterData(searchTerms, data) { - const foundInHeader = []; - const foundInText = []; +const IGNORED_TERMS = []; +const doMatch = (property, term) => { + const regex = new RegExp(term, 'gi'); + if (property) { + return property.match(regex); + } + return false; +}; + +function filterData(fullTerm, data) { + const searchTokens = []; + searchTokens.push(fullTerm); + + searchTokens.push(...fullTerm.toLowerCase().split(/\s+/).filter((term) => term && term.length > 2 && term !== fullTerm.toLowerCase())); + + // Object + // { + // priority: Number + // article: article + // count: Number + // } + const results = []; data.forEach((result) => { - let minIdx = -1; - - searchTerms.forEach((term) => { - // eslint-disable-next-line max-len - const idx = (result.heading || result.title || result.description).toLowerCase().indexOf(term); - if (idx < 0) return; - if (minIdx < idx) minIdx = idx; + const found = { + article: result, + count: 0, + }; + + searchTokens.forEach((token) => { + if (IGNORED_TERMS.includes(token.toLowerCase().trim())) return; + // eslint-disable-next-line no-param-reassign + if (token.endsWith('s')) token = token.substring(0, token.length - 1); // Handle potential pluralization of token. + + if (doMatch(result.title, token)) { + found.rank ||= 1; + found.count += 1; + } + if (doMatch(result.heading, token)) { + found.rank ||= 2; + found.count += 1; + } + if (doMatch(result.description, token)) { + found.rank ||= 3; + found.count += 1; + } + if (doMatch(result.blurb, token)) { + found.rank ||= 4; + found.count += 1; + } + if (doMatch(result.text, token)) { + found.rank ||= 5; + found.count += 1; + } }); - if (minIdx >= 0) { - foundInHeader.push({ minIdx, result }); - return; + if (found.count > 0) { + results.push(found); } + }); - const fullText = result.text ? result.text.toLowerCase() : ''; - searchTerms.forEach((term) => { - const idx = fullText.indexOf(term); - if (idx < 0) return; - if (minIdx < idx) minIdx = idx; - }); - - if (minIdx >= 0) { - foundInText.push({ minIdx, result }); + return results.sort((l, r) => { + if (l.rank < r.rank) { + return -1; } - }); + if (l.rank === r.rank) { + if (l.count > r.count) { + return -1; + } + if (l.count < r.count) { + return 1; + } + return 0; + } + return 1; // Left rank is greater than right rank - move it down the list. + }).map((r) => r.article); +} - return [ - ...foundInHeader.sort(compareFound), - ...foundInText.sort(compareFound), - ].map((item) => item.result); +/** + * Get details of each search result from the article-index. + * + * @param {Array} results - The search results. + * @param {Array} allArticles - All the articles. + * @param {HTMLElement} wrapper - The wrapper element to append the results. + * @param {number} limit - The maximum number of articles to display. + * @param {number} articlesCount - The total count of articles. This is needed for pagination. + * @returns {Promise} - A promise that resolves when the page is rendered. + */ +async function processSearchResults(results, allArticles, wrapper, limit, articlesCount) { + const articles = []; + results.forEach((post) => { + const filteredArticles = allArticles.filter((obj) => obj.path === getRelativePath(post.path)); + articles.push(filteredArticles[0]); + }); + await renderPage(wrapper, articles, limit, articlesCount); + const loadingImageContainer = document.querySelector('.loading-image-container'); + const articleListWrapper = document.querySelector('.article-list-wrapper'); + articleListWrapper.style.transition = 'opacity 2s'; + articleListWrapper.style.opacity = 1; + articleListWrapper.style.display = 'block'; + loadingImageContainer.style.transition = 'opacity 2s'; + loadingImageContainer.style.opacity = 0; + loadingImageContainer.style.display = 'none'; } -async function handleSearch(searchValue, wrapper, limit) { +async function handleSearch(searchValue, block, limit) { + const wrapper = block.querySelector('.article-teaser-wrapper'); const searchSummary = document.createElement('p'); searchSummary.classList.add('search-summary'); if (searchValue.length < 3 || !searchValue.match(/[a-z]/i)) { searchSummary.innerHTML = 'Please enter at least three (3) characters to search.'; - wrapper.prepend(searchSummary); + wrapper.replaceChildren(searchSummary); return; } - const searchTerms = searchValue.toLowerCase().split(/\s+/).filter((term) => !!term); + // show loading spinner + const loadingImageContainer = document.createElement('div'); + loadingImageContainer.classList.add('loading-image-container'); + const loadingImage = document.createElement('img'); + loadingImage.classList.add('loading-image'); + loadingImage.src = '/cigaradvisor/images/search/ca-search-results-loading.svg'; + loadingImageContainer.append(loadingImage); + const spinner = document.createElement('span'); + spinner.classList.add('loader'); + loadingImageContainer.append(spinner); + block.prepend(loadingImageContainer); + const data = await getSearchIndexData(); - const filteredData = filterData(searchTerms, data); + const filteredData = filterData(searchValue, data); + const articlesCount = filteredData.length; - const articles = []; const allArticles = await loadPosts(); - filteredData.forEach((post) => { - const filteredArticles = allArticles.filter((obj) => obj.path === getRelativePath(post.path)); - articles.push(filteredArticles[0]); - }); - searchSummary.textContent = `Your search for "${searchValue}" resulted in ${articles.length} articles`; - if (articles.length === 0) { + + searchSummary.textContent = `Your search for "${searchValue}" resulted in ${filteredData.length} articles`; + if (filteredData.length === 0) { const noResults = document.createElement('p'); noResults.classList.add('no-results'); noResults.textContent = 'Sorry, we couldn\'t find the information you requested!'; + wrapper.replaceChildren(searchSummary); wrapper.append(noResults); - } else { - await renderPage(wrapper, articles, limit); + loadingImageContainer.style.display = 'none'; + return; } + let filteredDataCopy = [...filteredData]; + + // load the first page of results + let resultsToShow = filteredDataCopy.slice(0, limit); + // eslint-disable-next-line max-len + await processSearchResults(resultsToShow, allArticles, wrapper, limit, articlesCount); + wrapper.prepend(searchSummary); + + // handle pagination. Render each page of results when the hash changes + window.addEventListener('hashchange', async () => { + const heroSearch = document.querySelector('.hero-search'); + if (heroSearch) { + heroSearch.querySelector('input').value = searchValue; + } + const url = new URL(window.location.href); + const hashParams = new URLSearchParams(url.hash.substring(1)); + const page = hashParams.get('page'); + const start = (page - 1) * limit; + const end = start + limit; + filteredDataCopy = [...filteredData]; + resultsToShow = filteredDataCopy.slice(start, end); + await processSearchResults(resultsToShow, allArticles, wrapper, limit, articlesCount); + wrapper.prepend(searchSummary); + }); } export default async function decorate(block) { @@ -92,29 +190,18 @@ export default async function decorate(block) { const articleTeaserWrapper = document.createElement('div'); articleTeaserWrapper.classList.add('article-teaser-wrapper'); - if (searchParams.get('s')) { - const searchValue = searchParams.get('s'); - const heroSearch = document.querySelector('.hero-search'); - if (heroSearch) { - heroSearch.querySelector('input').value = searchValue; - } - await handleSearch(searchValue, articleTeaserWrapper, limit); - } - articleList.append(articleTeaserWrapper); articleListWrapper.append(articleList); block.replaceChildren(articleListWrapper); - window.addEventListener('hashchange', async () => { - if (searchParams.get('s')) { - const searchValue = searchParams.get('s'); - const heroSearch = document.querySelector('.hero-search'); - if (heroSearch) { - heroSearch.querySelector('input').value = searchValue; - } - await handleSearch(searchValue, articleTeaserWrapper, limit); + if (searchParams.get('s')) { + const searchValue = searchParams.get('s').trim(); + const heroSearch = document.querySelector('.hero-search'); + if (heroSearch) { + heroSearch.querySelector('input').value = searchValue; } - }); + handleSearch(searchValue, block, limit); + } } diff --git a/helix-query.yaml b/helix-query.yaml index 133f9a5..f541894 100644 --- a/helix-query.yaml +++ b/helix-query.yaml @@ -195,6 +195,9 @@ indices: description: select: head > meta[property="og:description"] value: attribute(el, "content") + blurb: + select: head > meta[property="articleblurb"] + value: attribute(el, "content") heading: select: main h1:first-of-type value: textContent(el)