From 5426636efed9b9dd10f898c5bad0c2fc50cfe955 Mon Sep 17 00:00:00 2001 From: "Mark J. Becker" Date: Mon, 27 Nov 2023 14:40:40 +0100 Subject: [PATCH] Blog post and index pages (#44) * Style blog post pages and autoblocking * Add blog author * Update section style for mobile * Use fragment for blog footer * Add styles for columns teaser * Update cards component to fetch blog posts from query * Make blog post links absolute * Handle canada blog URLs and author metadata * Update blog post author * Update author display logic * Add sharethis buttons * Add teasers to blog index page * Add blog-grid style * Implement blog search * Add tag filter for blog teaser * Add tag filtering * Use tag filters from live --- .eslintignore | 3 +- blocks/cards/cards.css | 88 ++++++++++++ blocks/cards/cards.js | 272 +++++++++++++++++++++++++++++++---- blocks/columns/columns.css | 66 ++++++++- blocks/fragment/fragment.css | 14 ++ blocks/fragment/fragment.js | 55 +++++++ blocks/sharing/sharing.css | 3 + blocks/sharing/sharing.js | 3 + icons/arrow-down.png | Bin 0 -> 281 bytes scripts/scripts.js | 35 ++++- styles/styles.css | 45 +++++- 11 files changed, 549 insertions(+), 35 deletions(-) create mode 100644 blocks/fragment/fragment.css create mode 100644 blocks/fragment/fragment.js create mode 100644 blocks/sharing/sharing.css create mode 100644 blocks/sharing/sharing.js create mode 100644 icons/arrow-down.png diff --git a/.eslintignore b/.eslintignore index 158f7d56..944d9b0a 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,4 +1,5 @@ helix-importer-ui /scripts/alloy*.js -/tools/actions/ \ No newline at end of file +/tools/actions/ +/tools/importer \ No newline at end of file diff --git a/blocks/cards/cards.css b/blocks/cards/cards.css index 07916ddb..9cdc7b83 100644 --- a/blocks/cards/cards.css +++ b/blocks/cards/cards.css @@ -1,3 +1,5 @@ +/* stylelint-disable no-descending-specificity */ + .cards { --heading-font-size-m: 1.125rem; } @@ -64,6 +66,7 @@ } .cards > ul > li img { + display: inline-block; width: 100%; aspect-ratio: 4 / 3; object-fit: cover; @@ -73,6 +76,91 @@ text-decoration: none; } +.cards-pagination ul { + list-style-type: none; + display: flex; + gap: 0.5rem; + padding: 0; + justify-content: center; +} + +.cards-pagination ul li > a { + font-family: var(--body-font-family); + font-size: .75rem; + line-height: 1.313rem; + color: var(--text-color); + text-decoration: none; + border: 2px solid var(--text-color); + border-radius: 10px; + padding: 5px 10px; +} + +.cards-pagination ul li.active > a { + background-color: var(--text-color); + color: var(--background-color); + pointer-events: none; +} + +.cards-searchbar { + padding: 0.9375rem; + box-shadow: 0 4px 3px #00000040; + border-radius: 10px; + margin-bottom: 40px; +} + +.cards-searchbar label { + display: none; +} + +.cards-searchbar input { + max-width: unset; + border: 0; + border-radius: 0; + border-bottom: 2px solid var(--text-color); +} + +.cards-filterselect { + display: flex; + gap: 0.5rem; + flex-direction: column; +} + +.cards-filterselect .total, .cards-filterselect label { + font-family: var(--heading-font-family); + font-weight: 700; + line-height: 1.625rem; +} + +.cards-filterselect select { + appearance: none; + background-color: transparent; + border: none; + padding: 1rem 1rem 1rem 0; + width: 100%; + font-size: 18px; +} + +.cards-filterselect .select-group { + display: flex; + align-items: center; + cursor: pointer; + border-bottom: 2px solid var(--text-color); + max-width: 300px; + margin: 0 0 2rem; +} + +.cards-filterselect .select-group::after { + content: ''; + justify-self: end; + width: 11px; + height: 11px; + margin-top: 13px; + background-image: url('/icons/arrow-down.png'); + background-repeat: no-repeat; + background-size: contain; + padding-bottom: 1rem; +} + @media (min-width: 768px) { .cards > ul { grid-template-columns: 1fr 1fr 1fr; diff --git a/blocks/cards/cards.js b/blocks/cards/cards.js index ec701b82..3855beb9 100644 --- a/blocks/cards/cards.js +++ b/blocks/cards/cards.js @@ -1,12 +1,86 @@ -import { createOptimizedPicture } from '../../scripts/lib-franklin.js'; +import { createOptimizedPicture, getMetadata } from '../../scripts/lib-franklin.js'; -const host = 'https://www.24petwatch.com'; -const sitesearchUrl = `https://${window.location.hostname.includes('aem-stage') ? 'aem-stage' : 'www'}.24petwatch.com/bin/24pethealth/sitesearch.json`; -const sitesearchPayload = JSON.stringify({ - context: '/content/24petwatch/us/en/blog/jcr:content/root/container/container_177885842/container_51910998/contentsearchresults', - resultsPerPage: 3, - requestedPage: 1, -}); +const isCanada = window.location.pathname.startsWith('/ca/'); + +async function getTagFilters() { + let index = new URL(`${isCanada ? '/ca' : ''}/blog/tag-filters.json`, window.location.origin); + if (!window.location.hostname.includes('24petwatch.com')) { + index = new URL(`https://main--24petwatch--hlxsites.hlx.live${isCanada ? '/ca' : ''}/blog/tag-filters.json`); + } + const response = await fetch(index); + const json = await response.json(); + return json.data; +} + +async function loadBlogPosts() { + let index = new URL(`${isCanada ? '/ca' : ''}/blog/query-index.json`, window.location.origin); + if (!window.location.hostname.includes('24petwatch.com')) { + index = new URL(`https://main--24petwatch--hlxsites.hlx.live${isCanada ? '/ca' : ''}/blog/query-index.json`); + } + const chunkSize = 100; + const loadChunk = async (offset) => { + index.searchParams.set('limit', chunkSize); + index.searchParams.set('offset', offset); + + const response = await fetch(index); + const json = await response.json(); + + // Check if more has to be loaded + if (json.total > offset + chunkSize) { + return { + data: [...json.data, ...(await loadChunk(offset + 100)).data], + total: json.total, + }; + } + return { + data: json.data, + total: json.total, + }; + }; + + if (!window.blogPosts) { + window.blogPosts = await loadChunk(0); + } + return window.blogPosts; +} + +// eslint-disable-next-line no-unused-vars +const fetchBlogPosts = async (page = 1, tags = [], searchTerm = '', pagesize = 9) => { + let { data, total } = await loadBlogPosts(); + + // Filter by tags + if (tags.length > 0) { + data = data.filter(({ tags: blogTag }) => tags.some((t) => blogTag.includes(t))); + total = data.length; + } + + // Filter by search term + if (searchTerm) { + data = data + .filter(({ title, description }) => title.toLowerCase().includes(searchTerm.toLowerCase()) + || description.toLowerCase().includes(searchTerm.toLowerCase())); + total = data.length; + } + + // Filter by page + const start = (page - 1) * pagesize; + const end = start + pagesize; + + let currentPage = page; + if (currentPage > Math.ceil(total / pagesize)) { + currentPage = Math.ceil(total / pagesize); + } + if (currentPage < 1) { + currentPage = 1; + } + + return { + items: data.slice(start, end), + pages: Math.ceil(total / pagesize), + currentPage, + total, + }; +}; function wrapInAnchor(element, href) { const anchor = document.createElement('a'); @@ -17,34 +91,165 @@ function wrapInAnchor(element, href) { } function createBlogCard(item = {}) { - const blogThumbnail = `${host}${item.url.replace('.html', '')}.thumb.319.319.png`; - const blogUrl = `${host}${item.url.substring(item.url.indexOf('/blog')).replace('.html', '')}`; - - return `
- ${item.name} -
-
-

${item.name}

-

${item.description}

-

Read more

-
`; + let { title, image, path } = item; + const { description } = item; + + if (!window.location.hostname.includes('24petwatch.com')) { + path = new URL(path, 'https://www.24petwatch.com').toString(); + } + try { + image = new URL(image, window.location); + image.hostname = window.location.hostname; + image.port = window.location.port; + image.protocol = window.location.protocol; + } catch (e) { /* ignore */ } + if (title.startsWith('24Petwatch: ')) { + title = title.replace('24Petwatch: ', ''); + } + + return document.createRange().createContextualFragment(` +
+ + ${title} + +
+
+

${title}

+

${description}

+

+ Read more +

+
+ `); +} + +function createPagination(block, pages, currentPage) { + let pageSet = new Set([1, pages, currentPage, currentPage - 1, currentPage + 1]); + pageSet = Array.from(pageSet) + .filter((a) => a > 0 && a <= pages) + .sort((a, b) => a - b); + + const onPaginate = (e) => { + const hrefPage = parseInt(new URL(e.target.href).searchParams.get('page'), 10); + const newUrl = new URL(window.location); + newUrl.searchParams.set('page', hrefPage); + window.history.pushState({}, '', newUrl.toString()); + e.preventDefault(); + // eslint-disable-next-line no-use-before-define + decorate(block); + }; + + const pagination = document.createRange().createContextualFragment(` +
+ +
`); + block.closest('.cards-wrapper').appendChild(pagination); + block.closest('.cards-wrapper').querySelectorAll('.cards-pagination a').forEach((a) => a.addEventListener('click', onPaginate)); +} + +function createSearchBox(block, searchTerm) { + const onSubmit = (e) => { + const newSearchTerm = e.target.querySelector('input').value; + const newUrl = new URL(window.location); + if (newSearchTerm !== '') { + newUrl.searchParams.set('search', newSearchTerm); + } else { + newUrl.searchParams.delete('search'); + } + newUrl.searchParams.set('page', 1); + window.history.pushState({}, '', newUrl.toString()); + e.preventDefault(); + // eslint-disable-next-line no-use-before-define + decorate(block); + }; + + const searchbar = document.createRange().createContextualFragment(` + + `); + block.closest('.cards-wrapper').prepend(searchbar); + block.closest('.cards-wrapper').querySelector('.cards-searchbar form').addEventListener('submit', onSubmit); +} + +async function createFilterSelect(block, total, currentTag) { + const tags = await getTagFilters(); + + const onChange = (e) => { + const newUrl = new URL(window.location); + if (e.target.value !== '') { + newUrl.searchParams.set('tag', e.target.value); + } else { + newUrl.searchParams.delete('tag'); + } + newUrl.searchParams.set('page', 1); + window.history.pushState({}, '', newUrl.toString()); + // eslint-disable-next-line no-use-before-define + decorate(block); + }; + + const filterselect = document.createRange().createContextualFragment(` +
+
${total} Results
+ +
+ +
+
+ `); + block.closest('.cards-wrapper').insertBefore(filterselect, block.closest('.cards-wrapper').querySelector('.block')); + block.closest('.cards-wrapper').querySelector('.cards-filterselect select').addEventListener('change', onChange); } async function populateBlogTeaser(block) { - const response = await fetch(sitesearchUrl, { - method: 'POST', - body: sitesearchPayload, - headers: { - 'Content-Type': 'application/json', - }, + const tags = getMetadata('article:tag').split(', '); + const response = await fetchBlogPosts(1, tags, '', 3); + response.items.forEach((item) => { + const card = document.createElement('div'); + card.appendChild(createBlogCard(item)); + block.appendChild(card); }); - const blogItems = await response.json(); +} - (blogItems.results || []).forEach((item) => { +async function populateBlogGrid(block) { + const searchParams = new URLSearchParams(window.location.search); + const page = parseInt(searchParams.get('page'), 10) || 1; + const searchTerm = searchParams.get('search') || ''; + const tags = (searchParams.get('tag') ? [searchParams.get('tag')] : []).map((t) => t.replace(/[^a-z0-9-]/g, '')); + const { + items, pages, currentPage, total, + } = await fetchBlogPosts(page, tags, searchTerm.replace(/[^a-zA-Z0-9 ]/g, ''), 9); + items.forEach((item) => { const card = document.createElement('div'); - card.innerHTML = createBlogCard(item); - block.append(card); + card.appendChild(createBlogCard(item)); + block.appendChild(card); }); + + if (items.length === 0) { + block.closest('.cards-wrapper').prepend(document.createRange().createContextualFragment(` +

Sorry, there are no results that match your search

+

Please check your spelling or try again using different keywords

+ `)); + } else { + createFilterSelect(block, total, tags.length > 0 ? tags[0] : null); + } + + createSearchBox(block, searchTerm); + createPagination(block, pages, currentPage); } export default async function decorate(block) { @@ -53,6 +258,13 @@ export default async function decorate(block) { await populateBlogTeaser(block); } + const isBlogGrid = block.classList.contains('blog-grid'); + if (isBlogGrid) { + block.textContent = ''; + block.closest('.cards-wrapper').querySelectorAll(':scope > *:not(.block)').forEach((e) => e.remove()); + await populateBlogGrid(block); + } + /* change to ul, li */ const ul = document.createElement('ul'); [...block.children].forEach((row) => { @@ -60,7 +272,6 @@ export default async function decorate(block) { li.innerHTML = row.innerHTML; [...li.children].forEach((div) => { const href = li.querySelector('a')?.href; - if (div.children.length === 1 && div.querySelector('picture')) { div.className = 'cards-card-image'; wrapInAnchor(div, href); @@ -74,7 +285,6 @@ export default async function decorate(block) { }); [...ul.querySelectorAll('img')] - .filter((img) => !(img.src || '').startsWith('http')) // do not optimize absolute images for now .forEach((img) => img.closest('picture').replaceWith(createOptimizedPicture(img.src, img.alt, false, [{ width: '750' }]))); block.textContent = ''; block.append(ul); diff --git a/blocks/columns/columns.css b/blocks/columns/columns.css index e4bb6a05..c4ce5c46 100644 --- a/blocks/columns/columns.css +++ b/blocks/columns/columns.css @@ -32,6 +32,63 @@ text-align: center; } +.columns.teaser { + background-color: #fff1d6; + border-radius: 0.625rem; + overflow: hidden; +} + +.columns.teaser > div, .columns.gray > div { + padding: 0; +} + +.columns.teaser p a.button.primary { + width: 100%; +} + +.columns.teaser > div > .columns-img-col { + order: 1; +} + +.columns.teaser > div > div { + font-size: 1rem; + line-height: 1.5625rem; +} + +.columns.teaser > div > div:last-child, .columns.gray > div > div:first-child { + margin-bottom: 1.25rem; + padding-left: 0.9375rem; + padding-right: 0.9375rem; +} + +.columns.teaser p.button-container, .columns.gray p.button-container { + margin: 0; +} + +.columns.teaser p.button-container a.buttonm, .columns.gray p.button-container a.button { + margin: 0.3125rem auto; +} + +.columns.gray p.button-container a.button { + display: inline-block; + border: 0.1875rem solid var(--text-color); + background: var(--background-color); + color: var(--text-color); +} + +.columns.gray p.button-container a.button:hover { + background: #d0dfe8; +} + +.columns.teaser > div > div h3 { + font-size: 1.3125rem; + line-height: 1.938rem; +} + +.columns.gray { + background-color: #e7e9ea; +} + @media (min-width: 768px) { .columns > div { align-items: center; @@ -44,7 +101,6 @@ .columns > div > div { flex: 1; order: unset; - } .columns > div > .columns-img-col { @@ -54,4 +110,12 @@ .columns h2 { text-align: left; } + + .columns.teaser > div > div:last-child, .columns.gray > div > div:first-child { + padding: 3.125rem; + } + + .columns.teaser > div > .columns-img-col { + order: unset; + } } diff --git a/blocks/fragment/fragment.css b/blocks/fragment/fragment.css new file mode 100644 index 00000000..46bab39e --- /dev/null +++ b/blocks/fragment/fragment.css @@ -0,0 +1,14 @@ +/* suppress nested section padding */ + +.fragment-wrapper > .section { + padding-left: 0; + padding-right: 0; +} + +.fragment-wrapper > .section:first-of-type { + padding-top: 0; +} + +.fragment-wrapper > .section:last-of-type { + padding-bottom: 0; +} diff --git a/blocks/fragment/fragment.js b/blocks/fragment/fragment.js new file mode 100644 index 00000000..b03b8f0d --- /dev/null +++ b/blocks/fragment/fragment.js @@ -0,0 +1,55 @@ +/* + * Fragment Block + * Include content on a page as a fragment. + * https://www.aem.live/developer/block-collection/fragment + */ + +import { + decorateMain, +} from '../../scripts/scripts.js'; + +import { + loadBlocks, +} from '../../scripts/lib-franklin.js'; + +/** + * Loads a fragment. + * @param {string} path The path to the fragment + * @returns {HTMLElement} The root element of the fragment + */ +export async function loadFragment(path) { + if (path && path.startsWith('/')) { + const resp = await fetch(`${path}.plain.html`); + if (resp.ok) { + const main = document.createElement('main'); + main.innerHTML = await resp.text(); + + // reset base path for media to fragment base + const resetAttributeBase = (tag, attr) => { + main.querySelectorAll(`${tag}[${attr}^="./media_"]`).forEach((elem) => { + elem[attr] = new URL(elem.getAttribute(attr), new URL(path, window.location)).href; + }); + }; + resetAttributeBase('img', 'src'); + resetAttributeBase('source', 'srcset'); + + decorateMain(main); + await loadBlocks(main); + return main; + } + } + return null; +} + +export default async function decorate(block) { + const link = block.querySelector('a'); + const path = link ? link.getAttribute('href') : block.textContent.trim(); + const fragment = await loadFragment(path); + if (fragment) { + const fragmentSection = fragment.querySelector(':scope .section'); + if (fragmentSection) { + block.closest('.section').classList.add(...fragmentSection.classList); + block.closest('.fragment-wrapper').replaceWith(...fragmentSection.childNodes); + } + } +} diff --git a/blocks/sharing/sharing.css b/blocks/sharing/sharing.css new file mode 100644 index 00000000..4b0dd9cb --- /dev/null +++ b/blocks/sharing/sharing.css @@ -0,0 +1,3 @@ +.sharing { + min-height: 48px; +} \ No newline at end of file diff --git a/blocks/sharing/sharing.js b/blocks/sharing/sharing.js new file mode 100644 index 00000000..d6ced3b3 --- /dev/null +++ b/blocks/sharing/sharing.js @@ -0,0 +1,3 @@ +export default async function decorate(block) { + block.append(document.createRange().createContextualFragment('
')); +} diff --git a/icons/arrow-down.png b/icons/arrow-down.png new file mode 100644 index 0000000000000000000000000000000000000000..c99f1f370b693b22ca139b9002033b5a5d0ed84b GIT binary patch literal 281 zcmeAS@N?(olHy`uVBq!ia0vp^+(695!3HFgJ}hYlQk(@Ik;Oo9VGw3ym^DWND9BhG ziLvTSm{a4vbx+*Ge_j*W(E*Y>{7bBSQO<*`iaNB)e&hHF}aMqWRY z{@Tu0Pu4Tv_Pr@%hth>@;zl9YAH6s<<#Z~03B$z|(){1zLgW1Y&X{=8sQiGFK$(<< z{Y%5MQ+HLH?U>qH#NIT)HGf4hbGlfR`@3766J}mp@jm;|uQc&`Gl>`G5BvE!<^Nyf YwtwPmQRw@)2