diff --git a/blocks/article-grid/article-grid.css b/blocks/article-grid/article-grid.css new file mode 100644 index 0000000..1181697 --- /dev/null +++ b/blocks/article-grid/article-grid.css @@ -0,0 +1,154 @@ +@import "../banner/banner.css"; + +.article-grid { + position: relative; + opacity: 0; + padding-bottom: var(--spacing-l); +} + +.article-grid .article-grid-result { + display: grid; + grid-template-columns: 1fr; + gap: var(--spacing-m); + padding: var(--spacing-l) 0; + opacity: 0; + transition: opacity 0.3s ease-in-out; + width: var(--grid-container-width); + margin: 0 auto; +} + +/* spinner styles in style.css */ +.article-grid .loading-container { + width: 100%; + height: 70vh; + position: relative; +} + +.article-grid.loading { + opacity: 1; +} + +.article-grid.ready .loading-container { + display: none; +} + +.article-grid.ready .article-grid-result { + opacity: 1; +} + +/* media */ +.article-grid .media { + color: var(--text-color); + padding: 0; +} + +.article-grid a.media .image { + border-radius: 8px; + background: var(--color-black); +} + +.article-grid .media .image img { + width: 100%; + aspect-ratio: 16 / 9; + object-fit: cover; + border-radius: 8px; + overflow: hidden; + transition: var(--transition-all-ease-in-out-1); +} + +.article-grid .media > .foreground { + width: 100%; +} + +.article-grid .media > .foreground .media-row { + display: flex; + flex-direction: column; + align-items: start; + width: 100%; + gap: var(--spacing-s); +} + +.article-grid .media .text .description { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + min-height: 3.2rem; +} + +.article-grid .con-button::first-letter { + text-transform: capitalize; +} + +.article-grid a.media:hover .con-button { + background-color: var(--color-black); + border-color: var(--color-black); + color: var(--color-white); + text-decoration: none; +} + +div.article-grid .media .foreground > div.media-row { + padding: 0; +} + +.article-grid a.media:hover { + text-decoration: none; +} + +.article-grid a.media:hover .image img { + opacity: 0.6; +} + +/* banner */ +.article-grid .banner { + max-width: none; + aspect-ratio: 1 / 1; +} + +.article-grid .banner, +.article-grid .banner .banner-contents { + width: 100%; + height: 100%; + margin: 0; +} + +.article-grid .load-more-container { + grid-column: 1 / -1; + display: flex; + justify-content: center; + align-items: center; +} + +@media (min-width: 600px) { + .article-grid .article-grid-result { + grid-template-columns: repeat(2, 1fr); + gap: var(--spacing-l) var(--spacing-m); + width: var(--grid-container-width); + margin-left: auto; + margin-right: auto; + } + + .article-grid.hide-odd-item-on-tablet .article-grid-result > a:last-of-type:nth-child(odd) { + display: none; + } + + .article-grid .media .text h2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + min-height: 2.8rem; + } +} + +@media (min-width: 900px) { + .article-grid .article-grid-result { + grid-template-columns: repeat(3, 1fr); + } + + .article-grid.hide-odd-item-on-tablet .article-grid-result > a:last-of-type:nth-child(odd) { + display: flex; + } +} diff --git a/blocks/article-grid/article-grid.js b/blocks/article-grid/article-grid.js new file mode 100644 index 0000000..8e65661 --- /dev/null +++ b/blocks/article-grid/article-grid.js @@ -0,0 +1,414 @@ +import { getLibs, buildBlock, replacePlaceholderForLocalizedText } from '../../scripts/utils.js'; +import initBanner from '../banner/banner.js'; + +const miloLibs = getLibs(); +const DEFAULT_GRID_LIMIT = 9; +const DEFAULT_FEED_DATA_LIMIT = 500; +const ROOT_MARGIN = 20; + +// get data +function getMetadataTags(doc) { + const metaTagElements = doc.head.querySelectorAll('meta[property="article:tag"]'); + const tagContents = [...metaTagElements].map(metaTag => + metaTag.getAttribute('content')).filter(Boolean); + + return JSON.stringify(tagContents); +} + +async function getArticleDetails(articleLink) { + const { getMetadata } = await import(`${miloLibs}/utils/utils.js`); + + const path = new URL(articleLink).pathname; + const resp = await fetch(path); + if (!resp?.ok) { + // eslint-disable-next-line no-console + console.log(`Could not retrieve metadata for ${path}`); + return null; + } + const html = await resp.text(); + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + + const trimEndings = ['|Adobe', '| Adobe', '| Adobe Blog', '|Adobe Blog']; + let title = getMetadata('og:title', doc).trim(); + const ending = trimEndings.find((el) => title.endsWith(el)); + [title] = title.split(ending); + + const image = doc.querySelector('img'); + + // same data format for decorating media block + return { + title, + description: getMetadata('description', doc), + image: image.src, + imageAlt: image.alt || title, + tags: getMetadataTags(doc), + path, + }; +} + +async function fetchArticleFeedData(blogIndex) { + const [{ getConfig }, { updateLinkWithLangRoot }] = await Promise.all([ + import(`${miloLibs}/utils/utils.js`), + import(`${miloLibs}/utils/helpers.js`) + ]); + const { feed, articles, limit } = blogIndex.config; + + // fetch article data based on links + if (articles) { + const fetchArticleDataPromise = articles.map((article) => getArticleDetails(article)); + const articleData = await Promise.all(fetchArticleDataPromise); + + articleData.forEach((data) => { + if (data) blogIndex.data.push(data); + }); + blogIndex.complete = true; + blogIndex.limit = DEFAULT_GRID_LIMIT; + return false; + } + + // fetch feed data + const finalLimit = limit || DEFAULT_FEED_DATA_LIMIT; + blogIndex.limit = finalLimit; + const queryParams = `?limit=${finalLimit}&offset=${blogIndex.offset}`; + const defaultPath = updateLinkWithLangRoot(`${getConfig().locale.contentRoot}/query-index.json`); + + const indexPath = feed + ? `${feed}${queryParams}` + : `${defaultPath}${queryParams}`; + + return fetch(indexPath) + .then((response) => response.json()) + .then((json) => { + const complete = (json.limit + json.offset) >= json.total; + json.data.forEach((post) => { + blogIndex.data.push(post); + blogIndex.byPath[post.path.split('.')[0]] = post; + }); + blogIndex.offsetData = json.data; + blogIndex.complete = complete; + + return blogIndex; + }); +} + +// filter data +const isInList = (list, val) => list?.map((t) => t.toLowerCase()).includes(val); + +async function filterArticleDataBasedOnConfig(blogIndex) { + const { getArticleTaxonomy } = await import(`${miloLibs}/blocks/article-feed/article-helpers.js`); + + /* filter posts by category, tag and author */ + const FILTER_NAMES = ['tags', 'topics', 'selectedProducts', 'selectedIndustries', 'author', 'category', 'exclude']; + + const filters = Object.keys(blogIndex.config).reduce((prev, key) => { + if (FILTER_NAMES.includes(key)) { + prev[key] = blogIndex.config[key].split(',').map((e) => e.toLowerCase().trim()); + } + return prev; + }, {}); + + const articleData = blogIndex.data; + const KEYWORDS = ['exclude', 'tags', 'topics']; + const SELECTED = ['selectedProducts', 'selectedIndustries']; + + const filteredArticleData = articleData.filter((article) => { + const articleTaxonomy = getArticleTaxonomy(article); + + const matchedAll = Object.keys(filters).every((key) => { + if (KEYWORDS.includes(key)) { + const matchedFilter = filters[key] + .some((val) => (isInList(articleTaxonomy?.allTopics, val))); + return key === 'exclude' ? !matchedFilter : matchedFilter; + } + + if (SELECTED.includes(key)) { + if (filters.selectedProducts && filters.selectedIndustries) { + // match product && industry + const matchProduct = filters.selectedProducts + .some((val) => (isInList(articleTaxonomy?.allTopics, val))); + const matchIndustry = filters.selectedIndustries + .some((val) => (isInList(articleTaxonomy?.allTopics, val))); + return matchProduct && matchIndustry; + } + const matchedFilter = filters[key] + .some((val) => isInList(articleTaxonomy.allTopics, val)); + return matchedFilter; + } + + const matchedFilter = filters[key].some((val) => isInList([article[key]], val)); + return matchedFilter; + }); + + return (matchedAll); + }); + + blogIndex.data = filteredArticleData; +} + +function decorateLoadingContainer() { + const container = document.createElement('div'); + container.classList.add('loading-container'); + const spinner = document.createElement('div'); + spinner.classList.add('spinner'); + + container.append(spinner); + return container; +} + +async function decorateMediaBlock(articleItem) { + const { createOptimizedPicture, getArticleTaxonomy } = await import(`${miloLibs}/blocks/article-feed/article-helpers.js`); + + const { title, description = '', image, imageAlt = title || '', path } = articleItem; + const picture = createOptimizedPicture(image, imageAlt, [{ width: '750' }]); + const pictureTag = picture.outerHTML; + const articleTax = getArticleTaxonomy(articleItem); + + const readMore = await replacePlaceholderForLocalizedText('read-more'); + + const mediaBlock = buildBlock('media', [ + [ + pictureTag, + { + elems: [ + `

${articleTax.category}

`, + `

${title}

`, + `

${description}

`, + `

${readMore}

`, + ], + }, + ], + ]); + + mediaBlock.classList.add('small'); + window.initMediaBlock(mediaBlock); + + return mediaBlock; +} + +function getOrCreateResultContainer(block) { + let resultContainer = block.querySelector('.article-grid-result'); + if (!resultContainer) { + resultContainer = document.createElement('div'); + resultContainer.classList.add('article-grid-result'); + } + return resultContainer; +} + +function initLoadingState(block, resultContainer) { + block.innerHTML = ''; + const loadingContainer = decorateLoadingContainer(); + block.prepend(loadingContainer); + block.append(resultContainer); + block.classList.add('loading'); +} + +function calculateDisplayLimit(offset, totalArticles, totalBanners) { + const limit = offset + DEFAULT_GRID_LIMIT; + return limit > totalArticles ? (totalArticles + totalBanners) : limit; +} + +function getBannerStyle(url) { + const urlFragment = url?.split('#')[1]; + if (!urlFragment) return false; + + const result = []; + const params = urlFragment.split('&'); + + params.forEach((param) => { + const [key, ...valueParts] = param.split('-'); + if (key === 'style') result.push(valueParts.join('-')); + }); + return result; +} + +async function loadBannerIfAvailable(bannerPos, bannerData, index, resultContainer) { + const existingBannerIndex = bannerPos.indexOf(`${index + 1}`); + + if (existingBannerIndex !== -1 && bannerData[existingBannerIndex]) { + const bannerBlock = buildBlock('banner', [ + [`

`], + ]); + + const bannerClasses = getBannerStyle(bannerData[existingBannerIndex]); + if (bannerClasses?.length > 0) { + bannerClasses.forEach((className) => { + bannerBlock.classList.add(className); + }); + } + + await initBanner(bannerBlock); + bannerBlock.classList.add('article-grid-item'); + resultContainer.append(bannerBlock); + bannerData[existingBannerIndex] = ''; + return true; + } + + return false; +} + +async function loadArticleItem(articleItem, resultContainer, createTag) { + let mediaBlock = await decorateMediaBlock(articleItem); + + const linkEl = mediaBlock.querySelector('a'); + if (linkEl.href) { + const mediaBlockContent = mediaBlock.innerHTML; + mediaBlock = createTag( + 'a', + { + class: mediaBlock.classList, + href: linkEl.href, + target: linkEl.target, + }, + mediaBlockContent, + ); + } + + mediaBlock.classList.add('article-grid-item'); + resultContainer.append(mediaBlock); +} + +async function loadAndExposeMediaBlock() { + try { + const [{ default: initMediaBlock }, { loadStyle }] = await Promise.all([ + import(`${miloLibs}/blocks/media/media.js`), + import(`${miloLibs}/utils/utils.js`) + ]); + + window.initMediaBlock = initMediaBlock; + loadStyle(`${miloLibs}/blocks/media/media.css`); + } catch (error) { + console.error('Error loading the module:', error); + } +} + +function getBannerPosition(bannerData) { + const bannerPos = []; + if (!bannerData?.length) return bannerPos; + + bannerData.forEach((banner) => { + if (!banner?.length) return; + + const url = new URL(banner); + const position = url.hash?.match(/position-(\d+)/)?.[1]; + + if (position) bannerPos.push(position); + }); + + return bannerPos; +} + +async function decorateArticleGrid(block, blogIndex) { + const { createTag } = await import(`${miloLibs}/utils/utils.js`); + const articleData = blogIndex.data; + const { offset, config } = blogIndex; + const { banner } = config; + + const bannerData = banner ? (Array.isArray(banner) ? banner : [banner]) : []; + const bannerPos = getBannerPosition(bannerData); + const resultContainer = getOrCreateResultContainer(block); + + if (offset === 0) { + initLoadingState(block, resultContainer); + } + + let articleIndex = offset; + const displayLimit = calculateDisplayLimit(offset, articleData.length, bannerPos.length); + + // Load articles and banners + for (let i = offset; i < displayLimit; i += 1) { + const bannerLoaded = await loadBannerIfAvailable(bannerPos, bannerData, i, resultContainer); + + if (!bannerLoaded) { + await loadArticleItem(articleData[articleIndex], resultContainer, createTag); + articleIndex += 1; + } + } + + blogIndex.offset = articleIndex; + + // If more articles are available, show load more button + if (articleData.length > blogIndex.offset) { + await setupLoadMoreButton(block, blogIndex, resultContainer); + } +} + +async function decorateLoadMoreButton() { + const loadMore = document.createElement('a'); + loadMore.className = 'load-more con-button outline'; + loadMore.href = '#'; + loadMore.textContent = await replacePlaceholderForLocalizedText('load-more'); + + return loadMore; +} + +async function setupLoadMoreButton(block, blogIndex, resultContainer) { + const loadMoreButton = await decorateLoadMoreButton(); + const loadMoreContainer = document.createElement('p'); + loadMoreContainer.classList.add('load-more-container'); + loadMoreContainer.append(loadMoreButton); + resultContainer.append(loadMoreContainer); + + loadMoreButton.addEventListener('click', async (e) => { + e.preventDefault(); + loadMoreContainer.remove(); + await decorateArticleGrid(block, blogIndex); + }); +} + +function hideOddButtonOnTabletIfIsFeedData(block, blogIndex) { + const { feed } = blogIndex.config; + if (feed) block.classList.add('hide-odd-item-on-tablet'); +} + +// block for loading article + banner +// column key: articles -> fetch data based on article links +// column key: feed -> fetch data based on feed single link (has load more button) +export default async function init(block) { + const { readBlockConfig } = await import(`${miloLibs}/blocks/article-feed/article-feed.js`); + + const blogIndex = { + data: [], + byPath: {}, + offset: 0, // number of total items (article + banner) + complete: false, + config: {}, + offsetData: [], + }; + blogIndex.config = readBlockConfig(block); + + let isLoading = false; + + const initArticleGrid = async () => { + if (isLoading) return; + isLoading = true; + + await Promise.all([ + loadAndExposeMediaBlock(), // for reusing media block init + fetchArticleFeedData(blogIndex) + ]); + + await filterArticleDataBasedOnConfig(blogIndex); + await decorateArticleGrid(block, blogIndex); + hideOddButtonOnTabletIfIsFeedData(block, blogIndex); + + block.classList.add('ready'); + isLoading = false; + }; + + async function handleIntersection(entries, observer) { + entries.forEach(async (entry) => { + if (entry.isIntersecting) { + await initArticleGrid(); + observer.unobserve(entry.target); + } + }); + } + + // reduce inital load time + const observer = new IntersectionObserver(handleIntersection, { + root: null, // Use the viewport as the root + rootMargin: `${ROOT_MARGIN}px`, + }); + observer.observe(block); +} diff --git a/blocks/banner/banner.css b/blocks/banner/banner.css index 23995c6..79aa60d 100644 --- a/blocks/banner/banner.css +++ b/blocks/banner/banner.css @@ -1,8 +1,15 @@ main .banner { + display: block; opacity: 0; min-height: 152px; + width: 100%; max-width: var(--grid-container-width); - margin: 0 auto; + margin: 32px auto; + border-radius: 8px; + overflow: hidden; + + /* fallback color without image */ + background: var(--color-banner-red-gradient); } main .banner.is-loaded { @@ -10,23 +17,43 @@ main .banner.is-loaded { min-height: 0; } -main .banner .content-wrapper { - max-width: 600px; +main .banner .banner-contents { + box-sizing: border-box; + position: relative; display: flex; - padding: 16px 0; - border-top: 2px solid var(--color-gray-300); - border-bottom: 2px solid var(--color-gray-300); - align-items: center; - margin: 64px auto; + flex-direction: column; + justify-content: center; + width: 100%; + height: 100%; + min-height: 300px; + padding: 24px 20px; + overflow: hidden; + flex-grow: 1; } -main .banner-text { - margin-left: 40px; +main .banner .banner-contents::before { + content: ''; + position: absolute; + inset: 0; + width: 100%; + height: 100%; + background: var(--color-black); + opacity: 0.5; + z-index: 1; } main .banner img { - width: 100px; - max-width: unset; + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + transition: var(--transition-all-ease-in-out-1); +} + +main .banner .banner-text { + width: 100%; + z-index: 2; } main .banner .banner-text h3, @@ -36,14 +63,74 @@ main .banner .banner-text p { } main .banner .banner-text h3 { - font-size: var(--body-font-size-xs); - line-height: var(--body-line-height); - letter-spacing: .1em; text-transform: uppercase; - margin: 16px 0; + margin-bottom: 24px; } -main .banner .banner-text p { - font-size: var(--body-font-size-s); - margin: 16px 0; +main .banner .banner-text .banner-description { + font-weight: 700; + margin-bottom: var(--spacing-s); +} + +/* varaint: dark, light, no-overlay, gradient */ +main .banner.dark .banner-text { + color: var(--color-white); +} + +main .banner.light .banner-text { + color: var(--text-color); +} + +main a.banner.light .con-button { + background: rgba(255 255 255 / 90%); +} + +main .banner.no-overlay .banner-contents::before { + display: none; +} + +main .banner.bg-gradient-red { + background: var(--color-banner-red-gradient); +} + +main .banner.bg-gradient-red img, +main .banner.bg-gradient-red .banner-contents::before { + display: none; +} + +/* hover animation */ +main a.banner:hover { + text-decoration: none; +} + +main a.banner:hover img { + transform: scale(1.07); +} + +main a.banner:hover .con-button { + text-decoration: none; +} + +main a.banner.dark:hover .con-button { + background-color: var(--color-white); + color: var(--color-black); +} + +main a.banner.light:hover .con-button { + background-color: var(--color-black); + border-color: var(--color-black); + color: var(--color-white); +} + +@media (min-width: 600px) { + main .banner { + max-width: 480px; + margin: var(--spacing-l) auto; + } +} + +@media (min-width: 900px) { + main .banner .banner-contents { + padding: 24px 20px 48px; + } } diff --git a/blocks/banner/banner.js b/blocks/banner/banner.js index f0af796..d19ea41 100644 --- a/blocks/banner/banner.js +++ b/blocks/banner/banner.js @@ -1,16 +1,11 @@ -import { - createOptimizedPicture, - normalizeHeadings, -} from '../../scripts/utils.js'; +import { createOptimizedPicture, normalizeHeadings, changeHTMLTag, hasDarkOrLightClass } from '../../scripts/utils.js'; export default async function init(block) { const bannerContents = document.createElement('div'); bannerContents.classList.add('banner-contents'); + block.querySelectorAll('a').forEach(async (a) => { if (!a?.href) return; - // content wrapper - const bannerContent = document.createElement('div'); - bannerContent.classList.add('content-wrapper'); try { // get response from the URL @@ -18,6 +13,7 @@ export default async function init(block) { const path = pathname?.replace(/\.html$/, ''); if (path) { const response = await fetch(`${path}.plain.html`); + if (response.ok) { const responseEl = document.createElement('div'); responseEl.innerHTML = await response.text(); @@ -29,26 +25,55 @@ export default async function init(block) { bannerImage.classList.add('banner-image'); bannerText.classList.add('banner-text'); + if (!hasDarkOrLightClass(block)) { + block.classList.add('dark'); // add default class + } + // banner image content const img = responseEl.querySelector('img'); - const picture = img.closest('picture'); - const newPicture = createOptimizedPicture(img.src, img.alt); - if (picture) { + if (img) { + const picture = img.closest('picture'); + const newPicture = createOptimizedPicture(img.src, img.alt); picture.parentElement.replaceChild(newPicture, picture); bannerImage.append(newPicture); + } else { + block.classList.add('bg-gradient-red'); } // banner text content normalizeHeadings(responseEl, ['h3']); + const heading = responseEl.querySelector('h3'); + if (heading) heading.classList.add('detail-m'); + const descriptions = responseEl.querySelectorAll('p'); + descriptions?.forEach((desc) => { + if (!desc.querySelector('a') && desc.textContent.trim().length > 0) { + desc.classList.add('heading-m', 'banner-description'); + } + }); + const link = responseEl.querySelector('a'); - link.classList.add('cta-link'); + if (link) link.classList.add('con-button'); + bannerText.append(responseEl); // appending DOM objects - bannerContent.append(bannerImage, bannerText); - bannerContents.append(bannerContent); + bannerContents.append(bannerImage, bannerText); block.innerHTML = ''; block.append(bannerContents); + + if (link?.href) { + // switch whole banner to + const linkedBannerProperties = { + class: block.classList, + href: link.href, + target: link.target, + }; + const linkedBanner = changeHTMLTag(block, 'a', linkedBannerProperties); + + // switch inner link back to + const linkedBannerLink = linkedBanner.querySelector('a'); + changeHTMLTag(linkedBannerLink, 'span', { class: linkedBannerLink.classList }); + } } else { block.remove(); } diff --git a/scripts/scripts.js b/scripts/scripts.js index 2c99f0d..09e1e1c 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import { decorateContent, setLibs, buildAutoBlocks } from './utils.js'; +import { decorateContent, setLibs, buildAutoBlocks, decorateMasonry } from './utils.js'; // Add project-wide styles here. const STYLES = ['/styles/styles.css', '/styles/articles.css']; @@ -237,5 +237,6 @@ const { loadArea, setConfig, getMetadata } = await import(`${miloLibs}/utils/uti await buildAutoBlocks(); overrideMiloBlocks(); await loadArea(); + decorateMasonry(); initSidekick(); }()); diff --git a/scripts/utils.js b/scripts/utils.js index 69a2f9e..ca3de88 100644 --- a/scripts/utils.js +++ b/scripts/utils.js @@ -177,12 +177,29 @@ export async function decorateContent() { }); } +/** + * * @param {HTMLElement} element + * * @param {string} targetTag, like 'ul' or 'div' + * result: return the new element with inner content of the element, desired tag and css class + */ +export function changeHTMLTag(el, targetTag, properties = {}) { + const newEl = document.createElement(targetTag); + Object.keys(properties).forEach((key) => { + newEl.setAttribute(key, properties[key]); + }); + + newEl.innerHTML = el.innerHTML; + el.replaceWith(newEl); + + return newEl; +} + /** * Builds a block DOM Element from a two dimensional array * @param {string} blockName name of the block * @param {any} content two dimensional array or string or object of content */ -function buildBlock(blockName, content) { +export function buildBlock(blockName, content) { const table = Array.isArray(content) ? content : [[content]]; const blockEl = document.createElement('div'); // build image block nested div structure @@ -208,6 +225,44 @@ function buildBlock(blockName, content) { return (blockEl); } +/** + * * @param {string} key key of the localized text + * return localized text based on inputted key + */ +export async function replacePlaceholderForLocalizedText(key) { + const miloLibs = getLibs(); + const [{ replaceKey }, { getConfig }] = await Promise.all([ + await import(`${miloLibs}/features/placeholders.js`), + await import(`${miloLibs}/utils/utils.js`) + ]) + + const result = await replaceKey(key, getConfig()); + + return result; +} + +/** + * * @param {string} hexcode hexcode of color + * return rgb color for reusing in rgba() + */ +export function hexToRgb(hex) { + if (!/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(hex)) { + console.warn(`Invalid hex color: ${hex}`); + return false; + } + + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + + return `${r} ${g} ${b}`; +} + +export function hasDarkOrLightClass(element) { + const darkLightClass = ['dark', 'light']; + return darkLightClass.some(cls => element.classList.contains(cls)); +} + function buildAuthorHeader(mainEl) { const div = mainEl.querySelector('div'); const heading = div.querySelector('h1, h2'); @@ -292,6 +347,40 @@ function buildTagsBlock() { } } +function getCircleGradientValue(infoWrapper) { + return Array.from(infoWrapper.querySelectorAll('div')) + .find(div => div.innerText === 'circle-gradient') + ?.nextElementSibling?.innerText.trim() || null +} + +export function decorateMasonry() { + const masonryBlocks = document.querySelectorAll('.masonry-layout'); + if (!masonryBlocks) return; + + // add circle gradient bg based on hexcode provided (require 2 hexcode to work) + masonryBlocks.forEach((masonry) => { + const sectionMetadata = masonry.querySelector('.section-metadata'); + if (!sectionMetadata) return; + + const circleGradientValue = getCircleGradientValue(sectionMetadata); + if (!circleGradientValue) return; + + masonry.classList.add('background-circle-gradient'); + + const circleGradients = circleGradientValue.split(',').map(gradient => gradient.trim()); + if (!circleGradients?.length == 2) return; + + const gradient1 = hexToRgb(circleGradients[0]); + const gradient2 = hexToRgb(circleGradients[1]); + + if (!gradient1 || !gradient2) return; + + // only set `rgb` values, as `a` value will be set by css + masonry.style.setProperty('--bg-circle-gradient-1', gradient1); + masonry.style.setProperty('--bg-circle-gradient-2', gradient2); + }); +} + export async function buildAutoBlocks() { const miloLibs = getLibs(); const { getMetadata } = await import(`${miloLibs}/utils/utils.js`); diff --git a/styles/styles.css b/styles/styles.css index db37ce2..239fc2c 100644 --- a/styles/styles.css +++ b/styles/styles.css @@ -9,6 +9,12 @@ :root { --color-quote: #367ea8; --page-top-spacing: 128px; + + /* banner */ + --color-banner-red-gradient: radial-gradient(110.12% 95.52% at 25.93% 25.98%, #EB1000 -5%, #E9740A 60.34%, #FF61F6 110%); + + /* animation */ + --transition-all-ease-in-out-1: all 0.3s ease-in-out; } body header .gnav-wrapper a.gnav-brand { @@ -33,6 +39,10 @@ figcaption { text-align: center; } +.con-button { + transition: var(--transition-all-ease-in-out-1); +} + main .section:first-of-type:not([class*='-spacing']) > .content:first-of-type { margin-top: var(--page-top-spacing); } @@ -253,3 +263,242 @@ main + footer .feds-regionPicker-wrapper > .fragment p strong > a::after { padding-right: inherit; } } + +/* + * Editorial Card +*/ +main .editorial-card.click a { + color: var(--text-color); +} + +main .editorial-card .foreground { + padding-bottom: 20px; +} + +main .section:has(.editorial-card) { + padding-bottom: var(--spacing-xxl); +} + +main .editorial-card .media-area > div { + background: var(--color-black); +} + +main .editorial-card .media-area img { + transition: var(--transition-all-ease-in-out-1); +} + +main .editorial-card:hover .media-area img { + opacity: 0.6; +} + +/* + * spinner for loading animation +*/ +.spinner { + content: ''; + position: absolute; + top: 40%; + left: calc(50% - 1.6rem); + border: 2px dotted var(--color-gray-100); + border-bottom: 2px dotted var(--color-gray-200); + border-left: 2px dotted var(--color-gray-400); + border-top: 2px dotted var(--color-gray-800); + border-radius: 50%; + width: 1.6rem; + height: 1.6rem; + -webkit-animation: spin 2s linear infinite; + animation: spin 2s linear infinite; +} + +@-webkit-keyframes spin { + 0% { + -webkit-transform: rotate(0deg); + } + + 100% { + -webkit-transform: rotate(360deg); + } +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +/* + * Masnory +*/ +main .masonry-layout .brick { + overflow: hidden; +} + +/* Blog Specific hover styles based on creative feedback */ +main .masonry-layout .brick .brick-media { + background: var(--color-black); + overflow: hidden; +} + +main .masonry-layout .brick .brick-media img { + transition: var(--transition-all-ease-in-out-1); +} + +main .masonry-layout .brick a:hover .brick-media img { + opacity: 0.7; +} + +/* Blog Specific Variant: Hero Mansory */ +main .masonry-layout.hero-masonry .brick .foreground { + flex-direction: column-reverse; + justify-content: flex-end; + gap: var(--spacing-xs); +} + +main .masonry-layout.hero-masonry .brick .foreground p.action-area { + padding-top: var(--spacing-s); + gap: var(--spacing-xs); + margin-top: auto; +} + +main .masonry-layout.hero-masonry .brick .brick-media { + aspect-ratio: 16 / 9; + border-radius: 8px; + overflow: hidden; +} + +main .masonry-layout.hero-masonry .brick .foreground .brick-media { + display: block; +} + +main .masonry-layout.hero-masonry .brick .brick-media img { + width: 100%; + height: 100%; + object-fit: cover; +} + +main .masonry-layout.hero-masonry .brick .brick-media video { + margin-top: 0; +} + +main .masonry-layout.hero-masonry .brick .foreground p.body-m { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; +} + +@media (max-width: 600px) { + main .masonry-layout.hero-masonry > .text { + text-align: left; + } + + main .masonry-layout.hero-masonry .brick .foreground { + padding: var(--spacing-s); + } +} + +@media (min-width: 600px) { + main .masonry-layout.hero-masonry .brick .brick-media { + border-radius: 16px; + } + + main .masonry-layout.hero-masonry .brick.stack .foreground .brick-text { + display: flex; + flex-direction: column; + flex-grow: 1; + } +} + +@media screen and (min-width: 600px) and (max-width: 1200px) { + main .masonry-layout.hero-masonry .grid-full-width { + grid-column: 1 / -1; + } +} + +@media (min-width: 1200px) { + main .masonry-layout.hero-masonry .text-block .foreground { + max-width: 800px; + margin: 0 auto; + } + + main .masonry-layout.hero-masonry .brick .foreground .brick-media video { + object-fit: cover; + } + + main .masonry-layout.hero-masonry .brick.grid-full-width .brick-media { + aspect-ratio: auto; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + main .masonry-layout.hero-masonry .brick.stack .foreground { + flex-direction: column; + justify-content: space-between; + gap: var(--spacing-m); + padding: var(--spacing-l) var(--spacing-s); + } +} + +/* Blog Specific Variant: Gradient */ +.background-circle-gradient { + /* rbg value to be fit into rgba() for dynamic rendering of circle gradients */ + --bg-circle-gradient-1: 246 192 240; /* #f6c0f0; */ + --bg-circle-gradient-2: 243 180 158; /* #f3b49e */ + + position: relative; +} + +.background-circle-gradient > div { + z-index: 2; +} + +.background-circle-gradient::before, +.background-circle-gradient::after { + content: ''; + position: absolute; + width: 100%; + background-size: contain; + background-repeat: no-repeat; +} + +.background-circle-gradient::before { + background-image: linear-gradient(160deg, rgba(var(--bg-circle-gradient-2) / 70%) 0%, rgba(var(--bg-circle-gradient-2) / 40%) 30%, rgba(255 255 255 / 0%) 70%, rgba(255 255 255 / 0%) 100%); + top: 0; + right: 0; + height: 40%; +} + +.background-circle-gradient::after { + background-image: linear-gradient(30deg, rgba(var(--bg-circle-gradient-1) / 80%) 0%, rgba(var(--bg-circle-gradient-1) / 80%) 30%, rgba(255 255 255 / 0%) 75%, rgba(255 255 255 / 0%) 100%); + bottom: 0; + left: 0; + height: 55%; + z-index: 1; +} + +@media (min-width: 900px) { + .background-circle-gradient::before { + background-image: radial-gradient(circle 2200px at 90% -5%, rgba(var(--bg-circle-gradient-1) / 80%) 0%, transparent 30%, transparent 100%), radial-gradient(circle 2000px at 95% 25%, rgba(var(--bg-circle-gradient-2) / 100%) 0%, transparent 35%, transparent 100%); + background-position: top right; + width: 80%; + height: 80%; + } + + .background-circle-gradient::after { + background-image: radial-gradient(circle 1600px at 0% 45%, rgba(var(--bg-circle-gradient-1) / 70%) 0%, transparent 30%, transparent 100%), radial-gradient(circle 1500px at 0% 60%, rgba(var(--bg-circle-gradient-2) / 100%) 0%, transparent 30%, transparent 100%); + background-position: bottom left; + width: 90%; + height: 90%; + } + + .background-circle-gradient::before, + .background-circle-gradient::after { + background-size: contain; + background-repeat: no-repeat; + } +}