diff --git a/blocks/helpers.js b/blocks/helpers.js index a7c072a..2b02ef9 100644 --- a/blocks/helpers.js +++ b/blocks/helpers.js @@ -74,3 +74,118 @@ export function createTag(tag, attributes = {}) { }); return element; } + +async function fetchIndex(indexFile, pageSize = 500) { + const handleIndex = async (offset) => { + const resp = await fetch(`/${indexFile}.json?limit=${pageSize}&offset=${offset}`); + const json = await resp.json(); + + const newIndex = { + complete: (json.limit + json.offset) === json.total, + offset: json.offset + pageSize, + promise: null, + data: [...window.index[indexFile].data, ...json.data], + }; + + return newIndex; + }; + + window.index = window.index || {}; + window.index[indexFile] = window.index[indexFile] || { + data: [], + offset: 0, + complete: false, + promise: null, + }; + + // Return index if already loaded + if (window.index[indexFile].complete) { + return window.index[indexFile]; + } + + // Return promise if index is currently loading + if (window.index[indexFile].promise) { + return window.index[indexFile].promise; + } + + window.index[indexFile].promise = handleIndex(window.index[indexFile].offset); + const newIndex = await (window.index[indexFile].promise); + window.index[indexFile] = newIndex; + + return newIndex; +} + +/** + * Queries an entire query index. + * @param {string} indexFile The index file path name (e.g. "us/en/query-index"). + * NOTE: without leading "/" and without trailing ".json". + * @param {*} pageSize The page size of the {@link fetchIndex} calls. + * @returns {Promise} The entire query index. + */ +export async function queryEntireIndex(indexFile, pageSize = 500) { + window.queryIndex = window.queryIndex || {}; + if (!window.queryIndex[indexFile]) { + window.queryIndex[indexFile] = { + data: [], + offset: 0, + complete: false, + promise: null, + }; + } + + // Return immediately if already complete + if (window.queryIndex[indexFile].complete) { + return window.queryIndex[indexFile]; + } + + // Wait for in-progress fetches + if (window.queryIndex[indexFile].promise) { + return window.queryIndex[indexFile].promise; + } + + // Fetch all pages in sequence and accumulate data + window.queryIndex[indexFile].promise = (async () => { + let { offset } = window.queryIndex[indexFile]; + let complete = false; + + while (!complete) { + const { + data, + offset: nextOffset, + complete: isComplete, + // eslint-disable-next-line no-await-in-loop + } = await fetchIndex(indexFile, pageSize); + + window.queryIndex[indexFile].data.push(...data); + offset = nextOffset; + complete = isComplete; + } + + window.queryIndex[indexFile].offset = offset; + window.queryIndex[indexFile].complete = true; + window.queryIndex[indexFile].promise = null; + return window.queryIndex[indexFile]; + })(); + + return window.queryIndex[indexFile].promise; +} + +/** + * Fetch query-index.json preferring localized path (/-/query-index.json) + * with a fallback to the root (/query-index.json). The result is cached in + * the module-scoped queryIndexPromise. + * @returns {Promise} Parsed JSON of the query index + */ +export function getQueryIndex() { + let queryIndexPromise = null; + if (queryIndexPromise === null) { + const [currentCountry, currentLanguage] = getCurrentCountryLanguage(); + const localizedUrl = `/${currentCountry}-${currentLanguage}/query-index.json`; + const fallbackUrl = '/query-index.json'; + queryIndexPromise = fetchIndex(localizedUrl) + .then((res) => (res.ok ? res : Promise.reject(new Error('Localized query-index not found')))) + .then((res) => res.json()) + .catch(() => fetch(fallbackUrl).then((res) => res.json())); + } + return queryIndexPromise; +} diff --git a/blocks/teaser-list/_teaser-list.json b/blocks/teaser-list/_teaser-list.json new file mode 100644 index 0000000..c157662 --- /dev/null +++ b/blocks/teaser-list/_teaser-list.json @@ -0,0 +1,79 @@ +{ + "definitions": [ + { + "title": "Teaser List", + "id": "teaser-list", + "plugins": { + "xwalk": { + "page": { + "resourceType": "core/franklin/components/block/v1/block", + "template": { + "name": "Teaser List", + "model": "teaser-list" + } + } + } + } + } + ], + "models": [ + { + "id": "teaser-list", + "fields": [ + { + "component": "select", + "name": "teaser-list-type", + "label": "Get Pages By", + "valueType": "string", + "options": [ + { + "name": "Parent Page", + "value": "parent_page" + }, + { + "name": "Individual Pages", + "value": "individual_pages" + }, + { + "name": "Tag", + "value": "tag" + } + ] + }, + { + "component": "aem-content", + "name": "parent-page-link", + "label": "Parent page", + "value": "", + "valueType": "string", + "validation": { + "rootPath": "/content/qnx-xwalk" + }, + "condition": { "==": [{ "var": "teaser-list-type" }, "parent_page"] } + }, + { + "component": "aem-content", + "valueType": "string", + "name": "individual-pages-link", + "label": "Individual pages link", + "description": "Add the paths of the individual pages.", + "multi": true, + "condition": { "==": [{ "var": "teaser-list-type" }, "individual_pages"] } + }, + { + "component": "aem-tag", + "name": "tag", + "label": "tags", + "value": "", + "valueType": "string", + "validation": { + "rootPath": "/content/qnx-xwalk" + }, + "condition": { "==": [{ "var": "teaser-list-type" }, "tag"] } + } + ] + } + ], + "filters": [] + } + \ No newline at end of file diff --git a/blocks/teaser-list/teaser-list.css b/blocks/teaser-list/teaser-list.css new file mode 100644 index 0000000..e79f233 --- /dev/null +++ b/blocks/teaser-list/teaser-list.css @@ -0,0 +1,94 @@ +.teaser-list { + --teaser-list-gap: 40px; + --teaser-list-columns: repeat(auto-fit, minmax(404px, 1fr)); + --teaser-list-padding: 0; + --teaser-list-width: 404px; + --teaser-list-height: 228px; + --teaser-list-aspect-ratio: 16/9; + --teaser-list-padding-bottom: 16px; + --teaser-list-gap: 32px; + --teaser-list-text-gap: 24px; + --teaser-text-button-gap: 40px; + + display: grid; + grid-template-columns: var(--teaser-list-columns); + gap: var(--teaser-list-gap); + padding: 0; + + .teaser { + display: flex; + flex-direction: column; + gap: var(--teaser-list-gap); + padding-bottom: var(--teaser-list-padding-bottom); + width: var(--teaser-list-width); + } + + .teaser-image { + position: relative; + width: 100%; + height: var(--teaser-list-height); + aspect-ratio: var(--teaser-list-aspect-ratio); + } + + .teaser-image img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + + .teaser-content { + display: flex; + flex-direction: column; + gap: var(--teaser-list-gap); + flex: 1; + } + + .teaser-text { + display: flex; + flex-direction: column; + gap: var(--teaser-list-text-gap); + } + + .teaser-text > div { + display: flex; + flex-direction: column; + gap: var(--teaser-list-gap); + } + + .teaser-headline { + font-size: var(--teaser-list-headline-font-size); + margin: 0; + } + + .teaser-description { + font-size: var(--teaser-list-description-font-size); + margin: 0; + } + + .teaser-button-container { + display: flex; + justify-content: flex-end; + align-items: center; + gap: var(--teaser-list-gap); + + .button { + color: var(--clr-qnx-coral); + } + } + + @media (max-width: 768px) { + .teaser { + width: 100%; + } + } + + @media (max-width: 1024px) { + .teaser { + width: 45%; + margin: 0 auto; + } + } + + +} diff --git a/blocks/teaser-list/teaser-list.js b/blocks/teaser-list/teaser-list.js new file mode 100644 index 0000000..6c2539a --- /dev/null +++ b/blocks/teaser-list/teaser-list.js @@ -0,0 +1,95 @@ +import useBlockConfig from '../../scripts/global/useBlockConfig.js'; +import { getQueryIndex } from '../helpers.js'; + +const BLOCK_CONFIG = Object.freeze({ + empty: false, + FIELDS: { + TEASER_LIST_TYPE: { + index: 0, + removeRow: true, + }, + TEASER_PARENT_PAGE_LINK: { + index: 1, + removeRow: true, + }, + TEASER_INDIVIDUAL_PAGES_LINK: { + index: 2, + removeRow: true, + }, + TEASER_TAG: { + index: 3, + removeRow: true, + }, + }, +}); + +export default async function decorate(block) { + const { + TEASER_LIST_TYPE, + TEASER_PARENT_PAGE_LINK, + TEASER_INDIVIDUAL_PAGES_LINK, + TEASER_TAG, + } = useBlockConfig(block, BLOCK_CONFIG); + + let pagesData = []; + + if (TEASER_LIST_TYPE.text === 'parent_page') { + const { data } = await getQueryIndex(); + const teaserParentPath = TEASER_PARENT_PAGE_LINK.text; + const teaserParentLink = teaserParentPath.replace('/content/qnx-xwalk', ''); + pagesData = data.filter( + (page) => page.path + && page.path.startsWith(teaserParentLink) + && page.path !== teaserParentLink, + ); + } else if (TEASER_LIST_TYPE.text === 'individual_pages') { + const individualPagesLinks = TEASER_INDIVIDUAL_PAGES_LINK.node?.innerText; + if (individualPagesLinks) { + const { data } = await getQueryIndex(); + + const individualPaths = individualPagesLinks + .split(',') + .map((link) => link.trim()) + .filter((link) => link.length > 0) + .map((link) => { + const cleanPath = link.replace(/^\/?(content\/qnx-xwalk\/?)/, ''); + return cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`; + }); + + pagesData = data.filter((page) => page.path && individualPaths.includes(page.path)); + pagesData.sort((a, b) => { + const aIndex = individualPaths.indexOf(a.path); + const bIndex = individualPaths.indexOf(b.path); + return aIndex - bIndex; + }); + } + } else if (TEASER_LIST_TYPE.text === 'tag') { + // TODO: Implement tag + const { data } = await getQueryIndex(); + pagesData = data.filter((page) => page.tags && page.tags.includes(TEASER_TAG.text)); + } + + pagesData.forEach((page) => { + const teaser = document.createElement('div'); + teaser.classList.add('teaser'); + if (page) { + teaser.innerHTML = ` +
+ ${page.title} +
+
+
+

${page.teasertitle}

+

${page.teaserdescription}

+
+ +
+ `; + } + block.appendChild(teaser); + }); +} diff --git a/component-definition.json b/component-definition.json index ef09508..9020523 100644 --- a/component-definition.json +++ b/component-definition.json @@ -400,6 +400,21 @@ } } }, + { + "title": "Teaser List", + "id": "teaser-list", + "plugins": { + "xwalk": { + "page": { + "resourceType": "core/franklin/components/block/v1/block", + "template": { + "name": "Teaser List", + "model": "teaser-list" + } + } + } + } + }, { "title": "Teaser", "id": "teaser", diff --git a/component-filters.json b/component-filters.json index ff802ed..47cf1dc 100644 --- a/component-filters.json +++ b/component-filters.json @@ -26,7 +26,8 @@ "video", "gallery", "event-teaser", - "framed-grid" + "framed-grid", + "teaser-list" ] }, { diff --git a/component-models.json b/component-models.json index 3ccd0ba..4b103dc 100644 --- a/component-models.json +++ b/component-models.json @@ -1231,6 +1231,83 @@ } ] }, + { + "id": "teaser-list", + "fields": [ + { + "component": "select", + "name": "teaser-list-type", + "label": "Get Pages By", + "valueType": "string", + "options": [ + { + "name": "Parent Page", + "value": "parent_page" + }, + { + "name": "Individual Pages", + "value": "individual_pages" + }, + { + "name": "Tag", + "value": "tag" + } + ] + }, + { + "component": "aem-content", + "name": "parent-page-link", + "label": "Parent page", + "value": "", + "valueType": "string", + "validation": { + "rootPath": "/content/qnx-xwalk" + }, + "condition": { + "==": [ + { + "var": "teaser-list-type" + }, + "parent_page" + ] + } + }, + { + "component": "aem-content", + "valueType": "string", + "name": "individual-pages-link", + "label": "Individual pages link", + "description": "Add the paths of the individual pages.", + "multi": true, + "condition": { + "==": [ + { + "var": "teaser-list-type" + }, + "individual_pages" + ] + } + }, + { + "component": "aem-tag", + "name": "tag", + "label": "tags", + "value": "", + "valueType": "string", + "validation": { + "rootPath": "/content/qnx-xwalk" + }, + "condition": { + "==": [ + { + "var": "teaser-list-type" + }, + "tag" + ] + } + } + ] + }, { "id": "teaser", "fields": [ diff --git a/models/_section.json b/models/_section.json index 94c06a5..9d8b09b 100644 --- a/models/_section.json +++ b/models/_section.json @@ -68,7 +68,8 @@ "video", "gallery", "event-teaser", - "framed-grid" + "framed-grid", + "teaser-list" ] } ] diff --git a/scripts/global/useBlockConfig.js b/scripts/global/useBlockConfig.js new file mode 100644 index 0000000..1034db8 --- /dev/null +++ b/scripts/global/useBlockConfig.js @@ -0,0 +1,56 @@ +/** + * Extracts fields from block rows and removes rows as per config, + * allowing custom extraction logic per field. + * + * @param {Element} block The block element + * @param {import("./types").BlockConfig} BLOCK_CONFIG The block config object + * @param {{ + * [k: string]: import("./types").FieldExtractor, + * }} [fieldExtractors] Field extractors: `{ FIELD_NAME: (row, index, rows) => { ... } }` + * @returns {{ + * [k: keyof BlockConfig['FIELDS']]: { + * text: string, + * node: Element | null, + * picture: HTMLPictureElement | null, + * [j: string]: any, + * } + * }} The structured fields + */ +export default function useBlockConfig(block, BLOCK_CONFIG, fieldExtractors = {}) { + const rows = Array.from(block.children); + + const fields = {}; + Object.entries(BLOCK_CONFIG.FIELDS).forEach(([key, { index }]) => { + const row = rows[index]; + let value; + + // If a custom extractor is provided, use it + if (typeof fieldExtractors[key] === 'function') { + value = fieldExtractors[key](row, index, rows); + } else if (row) { + // Default extraction: text, node, picture + value = { + text: row.textContent?.trim() || '', + node: row, + picture: row.querySelector?.('picture') || null, + }; + } else { + value = { text: '', node: null, picture: null }; + } + + fields[key] = value; + }); + + // Decorate block + if (fields.BLOCK_ID) block.id = fields.BLOCK_ID.text; + if (fields.BLOCK_LABEL) block.setAttribute('data-block-label', fields.BLOCK_LABEL.text); + + // Remove configured rows + Object.values(BLOCK_CONFIG.FIELDS) + .filter(({ removeRow }) => removeRow) + .forEach(({ index }) => rows[index]?.remove()); + + if (BLOCK_CONFIG.empty) block.textContent = ''; + + return fields; +}