diff --git a/css/ucb-related-articles.css b/css/ucb-related-articles.css index aa829d69..bc3dc53f 100644 --- a/css/ucb-related-articles.css +++ b/css/ucb-related-articles.css @@ -1,22 +1,24 @@ .ucb-related-articles-block { - display: none; + display: none; margin-top: 20px; + --bs-gutter-x: 0 !important; + --bs-gutter-y: 0 !important; } .ucb-article-card-img { - max-width: 100%; - height: auto; + max-width: 100%; + height: auto; } .ucb-article-card-data { - display: flex; - flex-direction: column; + display: flex; + flex-direction: column; } .ucb-article-card { - margin-bottom: 2em; + margin-bottom: 2em; } .ucb-related-article-card-body{ - font-size: 85%; + font-size: 85%; } diff --git a/js/ucb-related-articles.js b/js/ucb-related-articles.js index 226618d0..3e515e4d 100644 --- a/js/ucb-related-articles.js +++ b/js/ucb-related-articles.js @@ -1,538 +1,286 @@ -const relatedArticlesBlock = document.querySelector(".ucb-related-articles-block"); -const baseURL = relatedArticlesBlock ? relatedArticlesBlock.getAttribute('data-baseurl') : ''; - -(function(relatedArticlesBlock) { - if (!relatedArticlesBlock) return; - const loggedIn = relatedArticlesBlock.getAttribute('data-loggedin') == 'true' ? true : false; - let childCount = 0; - const baseURL = relatedArticlesBlock.getAttribute('data-baseurl'); - - const excludeCatArr = JSON.parse(relatedArticlesBlock.getAttribute('data-catexclude')) - const excludeTagArr = JSON.parse(relatedArticlesBlock.getAttribute('data-tagexclude')) - - // Global variable to store articles that are good matches. Danger! - let articleArrayWithScores = [] - - // Related Shown? - const relatedShown = relatedArticlesBlock.getAttribute('data-relatedshown') != "Off" ? true : false; - - // This function returns a total of matched categories or tags - function checkMatches(data, ids, privateIds = []){ - let count = 0; - let numberArr = ids.map(Number) - // TO DO -- add in private taxonomy, dont include on counts - data.forEach((article)=>{ - if(numberArr.includes(article.meta.drupal_internal__target_id) && !privateIds.includes(article.meta.drupal_internal__target_id)){ - count++ - } - }) - return count - } - // This function takes in the tag endpoint and current array of related articles, returns the array of related articles once it has a count of 3. - async function getArticlesWithTags(url, array, articleTags ,numLeft, privateTags){ - fetch(url) - .then(response => response.json()) - .then(data=>{ - let relatedArticlesDiv = document.querySelector('.related-articles-section') - let returnedArticles = data.data - let existingIds = []; - // create an array of existing ids - array.map(article=>{ - existingIds.push(article.id) - }) - - // remove any articles already chosen, excluded categories, and the current article - let filterData = [] - returnedArticles.map((article)=>{ - - - - let thisArticleCats = article.relationships.field_ucb_article_categories.data - let thisArticleTags = article.relationships.field_ucb_article_tags.data - let urlCheck = article.attributes.path.alias ? article.attributes.path.alias : `/node/${article.attributes.drupal_internal__nid}`; - let toInclude = true; - //remove excluded category & tagss - if(thisArticleTags.length){ // if there are categories - thisArticleTags.forEach((tag)=>{ - let id = tag.meta.drupal_internal__target_id; - if(excludeTagArr.includes(id)){ - toInclude = false; - return - } - }) - } - - if(article.attributes.field_ucb_article_external_url){ - toInclude = false; - return - } - - if(thisArticleCats.length){ // if there are categories - thisArticleCats.forEach((category)=>{ // check each category - let id = category.meta.drupal_internal__target_id; - if(excludeCatArr.includes(id)){ // if excluded, do not proceed - toInclude = false; - return - } - if( urlCheck == window.location.pathname) { // if same article, do not proceed - toInclude = false - return; - } // proceed - // create an object out of - // add to running array of possible matches - - }) - } - - if(existingIds.includes(article.id) || urlCheck == window.location.pathname ){ - toInclude = false - // filter on categories - } - if(toInclude){ - let articleObj ={} - articleObj.id = article.id - articleObj.catMatches = checkMatches(article.relationships.field_ucb_article_tags.data, articleTags, privateTags) // count the number of matches - articleObj.article = article - filterData.push(articleObj) - } - else{ - return - } - }) - - let urlObj = {}; - let idObj = {}; - - if (data.included) { - let filteredData = data.included.filter((url) => { - return url.attributes.uri !== undefined; - }) - // creates the urlObj, key: data id, value: url - filteredData.map((pair) => { - urlObj[pair.id] = pair.links.focal_image_square.href; - }) - - // removes all other included data besides images in our included media - let idFilterData = data.included.filter((item) => { - return item.type == "media--image"; - }) - // using the image-only data, creates the idObj => key: thumbnail id, value : data id - idFilterData.map((pair) => { - idObj[pair.id] = pair.relationships.thumbnail.data.id; - }) - } - - // Rank based on matches (cats) - filterData.sort((a, b) => a.catMatches - b.catMatches).reverse(); - filterData.length = numLeft // sets to fill in however many articles are left - - filterData.map((article)=>{ - let articleCard = document.createElement('div') - articleCard.classList = "ucb-article-card col-sm-12 col-md-6 col-lg-4" - let title = article.article.attributes.title; - let link = article.article.attributes.path.alias ? baseURL + article.article.attributes.path.alias : `${baseURL}/node/${article.attributes.drupal_internal__nid}`; - - // if no thumbnail, show no image - if (!article.article.relationships.field_ucb_article_thumbnail.data) { - image = ""; - } else { - //Use the idObj as a memo to add the corresponding image url - let thumbId = article.article.relationships.field_ucb_article_thumbnail.data.id; - image = urlObj[idObj[thumbId]]; - } - let body = "" - // if summary, use that - if( article.article.attributes.field_ucb_article_summary != null){ - body = article.article.attributes.field_ucb_article_summary; - } - - // if image, use it - if (!article.article.relationships.field_ucb_article_thumbnail.data) { - imageSrc = ""; - } else { - //Use the idObj as a memo to add the corresponding image url - let thumbId = article.article.relationships.field_ucb_article_thumbnail.data.id; - imageSrc = urlObj[idObj[thumbId]]; - } - - var artcardImgContainer = document.createElement('div'); - artcardImgContainer.classList = 'ucb-article-card-img'; - - var artCardImgLink = document.createElement('a'); - artCardImgLink.href = link; - - var artCardImg = document.createElement('img'); - artCardImg.src = imageSrc; - - var artCardDataContainer = document.createElement('div') - artCardDataContainer.classList = 'ucb-article-card-data' - - var artCardDataTitle = document.createElement('span') - artCardDataTitle.classList= 'ucb-article-card-title' - - var artCardTitleLink = document.createElement('a') - artCardTitleLink.href = link; - artCardTitleLink.innerText = title; - - var artCardDataBody = document.createElement('span') - artCardDataBody.classList = 'ucb-related-article-card-body' - artCardDataBody.innerText = body; - - - if(link && imageSrc) { - // image = ``; - - artCardImgLink.appendChild(artCardImg) - artcardImgContainer.appendChild(artCardImgLink) - - } - - artCardDataTitle.appendChild(artCardTitleLink) - artCardDataContainer.appendChild(artCardDataTitle) - artCardDataContainer.appendChild(artCardDataBody) - - articleCard.appendChild(artcardImgContainer) - articleCard.appendChild(artCardDataContainer) - relatedArticlesDiv.appendChild(articleCard) - }) - - // Check to see what was rendered - // sets global counter of children - childCount = relatedArticlesDiv.childElementCount - // If no matches and logged in, render error message for admin - if(childCount == 0 && loggedIn == true){ - let message = document.createElement('h3') - message.innerText = 'There are no returned article matches - check exclusion filters and try again' - relatedArticlesDiv.appendChild(message) - // If no matches and not logged in, hide section - } else if (relatedArticlesDiv.childElementCount == 0 && loggedIn == false){ - let header = relatedArticlesBlock.children[0] - header.innerText = '' - - } else if(childCount > 1 && loggedIn==true){ - var message = document.getElementById('admin-notif-message') - if(message){ - message.remove() - } - }else { - // last check for error message - } - - }) - } - - // If related articles is toggled on create section, run the fetch - if(relatedShown){ - // Iterate through the json data of the articles tags and categories, store the values - let x=0 - let n=0 - // Tag array - iterate through and store taxonomy ID's for fetch - var tagJSON = JSON.parse(relatedArticlesBlock.getAttribute('data-tagsjson')); - var myTagIDs = [] - - while(tagJSON[n]!=undefined){ - myTagIDs.push(tagJSON[n]["#cache"].tags[0]) - n++; - } - var myTags = myTagIDs.map((id)=> id.replace(/\D/g,'')) // remove blanks, get only the tag ID#s - - // Cat array - iterate through and store taxonomy ID's for fetch - var catsJSON = JSON.parse(relatedArticlesBlock.getAttribute('data-catsjson')); - var myCatsID= []; - while(catsJSON[x]!=undefined){ - myCatsID.push(catsJSON[x]["#cache"].tags[0]) - x++; - } - - var myCats = myCatsID.map((id)=> id.replace(/\D/g,''))// remove blanks, get only the cat ID#s - - // Using tags and categories, construct an API call - var rootURL = `${baseURL}/jsonapi/node/ucb_article?include[node--ucb_article]=uid,title,ucb_article_content,created,field_ucb_article_summary,field_ucb_article_categories,field_ucb_article_tags,field_ucb_article_thumbnail&include=field_ucb_article_thumbnail.field_media_image&fields[file--file]=uri,url%20&filter[published][group][conjunction]=AND&filter[publish-check][condition][path]=status&filter[publish-check][condition][value]=1&filter[publish-check][condition][memberOf]=published`; - - var tagQuery = buildTagFilter(myTags) - var catQuery = buildCatFilter(myCats) - - // varructs the tag portion of the API filter - function buildTagFilter(array){ - let string = `${rootURL}` - - array.forEach(value => { - let tagFilterString = `&filter[filter-tag${value}][condition][path]=field_ucb_article_tags.meta.drupal_internal__target_id&filter[filter-tag${value}][condition][value]=${value}&filter[filter-tag${value}][condition][memberOf]=tag-include`; - string += tagFilterString - }); - return string - // let tagFilterString = `` - } - // Constructs the category portion of the API filter - function buildCatFilter(array){ - let string = `${rootURL}` - array.forEach(value=>{ - let catFilterString = `&filter[filter-cat${value}][condition][path]=field_ucb_article_categories.meta.drupal_internal__target_id&filter[filter-cat${value}][condition][value]=${value}&filter[filter-cat${value}][condition][memberOf]=cat-include` - string += catFilterString - - }); - return string - } - - var URL = `${catQuery}+&sort[sort-created][path]=created&sort[sort-created][direction]=DESC` - - // Fetch - async function getArticles(URL){ - const privateTags = getPrivateTags() - const privateCats = getPrivateCategories() - fetch(URL) - .then(response=>response.json()) - .then(data=> { - // Below objects are needed to match images with their corresponding articles. - // There are two endpoints => data.data (article) and data.included (incl. media), both needed to associate a media library image with its respective article - let urlObj = {}; - let idObj = {}; - // Remove any blanks from our articles before map - if (data.included) { - let filteredData = data.included.filter((url) => { - return url.attributes.uri !== undefined; - }) - - // creates the urlObj, key: data id, value: url - filteredData.map((pair) => { - urlObj[pair.id] = pair.links.focal_image_square.href; - }) - - // removes all other included data besides images in our included media - let idFilterData = data.included.filter((item) => { - return item.type == "media--image"; - }) - // using the image-only data, creates the idObj => key: thumbnail id, value : data id - idFilterData.map((pair) => { - idObj[pair.id] = pair.relationships.thumbnail.data.id; - }) - } - let returnedArticles = data.data - // Create an array of options to render with additional checks - returnedArticles.map((article)=> { - let thisArticleCats = article.relationships.field_ucb_article_categories.data; - let thisArticleTags = article.relationships.field_ucb_article_tags.data; - let urlCheck = article.attributes.path.alias ? article.attributes.path.alias : `/node/${article.attributes.drupal_internal__nid}`; - let toInclude = true; - - // If article is external, - if(article.attributes.field_ucb_article_external_url){ - toInclude = false; - return - } - - //remove excluded category & tagss - if(thisArticleTags.length){ // if there are tags - thisArticleTags.forEach((tag)=>{ - let id = tag.meta.drupal_internal__target_id; - if(excludeTagArr.includes(id)){ - toInclude = false; - return - } - }) - } - - if(thisArticleCats.length){ // if there are categories - thisArticleCats.forEach((category)=>{ // check each category - let id = category.meta.drupal_internal__target_id; - if(excludeCatArr.includes(id)){ // if excluded, do not proceed - toInclude = false; - return - } - if( urlCheck == window.location.pathname) { // if same article, do not proceed - toInclude = false - return; - } // proceed - // create an object out of - // add to running array of possible matches - - }) - } - // if it triggered any fail conditions, do not proceed with the article - if(toInclude){ - let articleObj = {} - articleObj.id = article.id - articleObj.catMatches = checkMatches(article.relationships.field_ucb_article_categories.data, myCats, privateCats) // count the number of matches - articleObj.tagMatches = checkMatches(article.relationships.field_ucb_article_tags.data, myTags, privateTags); - articleObj.article = article // contain the existing article - articleArrayWithScores.push(articleObj) - } - - }) - - // Remove current article from those availabile in the block - - articleArrayWithScores.filter((article)=>{ - let urlCheck = article.article.attributes.path.alias ? baseURL + article.article.attributes.path.alias : `${baseURL}/node/${article.article.attributes.drupal_internal__nid}`; - if(urlCheck == window.location.origin + window.location.pathname){ - articleArrayWithScores.splice(articleArrayWithScores.indexOf(article),1) - } else { - return article; - } - }) - articleArrayWithScores.sort((a, b) => { - // First, compare by catMatches - if (a.catMatches !== b.catMatches) { - return b.catMatches - a.catMatches; - } - // If catMatches are the same, compare by tagMatches - if (a.tagMatches !== b.tagMatches) { - return b.tagMatches - a.tagMatches; +class RelatedArticles extends HTMLElement { + static get observedAttributes() { + return [ + 'baseurl', + 'loggedin', + 'related-shown', + 'categories', + 'tags', + 'category-exclude', + 'tag-exclude', + ]; + } + + constructor() { + super(); + this._baseURL = ''; + this._loggedIn = false; + this._categories = []; + this._tags = []; + this._excludeCategories = []; + this._excludeTags = []; + this._relatedShown = this.getAttribute('related-shown') !== 'Off'; // will either be 'Related Articles' or 'Off' + + // Create container for related articles + this._container = document.createElement('div'); + this._container.className = 'row related-articles-container'; + this.style.display = this._relatedShown ? 'block' : 'none'; + + // Append container only if relatedShown is true, else hide this whole component + if (this._relatedShown) { + this.appendChild(this._container); + + // Loading element + this._loadingElement = document.createElement('div'); + this._loadingElement.innerHTML = `Loading`; + this.appendChild(this._loadingElement); + this.toggleLoading(true); + + // Error element + this._errorElement = document.createElement('div'); + this._errorElement.textContent = 'Error fetching related articles.'; + this._errorElement.style.display = 'none'; + this.appendChild(this._errorElement); + } + } + + attributeChangedCallback(name, oldValue, newValue) { + if (name === 'related-shown') { + const isShown = newValue !== 'Off'; + if (this._relatedShown !== isShown) { + this._relatedShown = isShown; + this.style.display = this._relatedShown ? 'block' : 'none'; + + if (!this._relatedShown) { + // Cleanup or early exit logic if needed + this._container.innerHTML = ''; + return; + } + this.fetchAndDisplayArticles(); + } + } + + if (!this._relatedShown) return; // Early exit if not shown, skip processing + + if (name === 'baseurl') this._baseURL = newValue || ''; + if (name === 'loggedin') this._loggedIn = newValue === 'true'; + + // Parse JSON values safely, with fallback to empty arrays + if (name === 'categories') this._categories = this._parseJSON(newValue); + if (name === 'tags') this._tags = this._parseJSON(newValue); + if (name === 'category-exclude') this._excludeCategories = this._parseJSON(newValue); + if (name === 'tag-exclude') this._excludeTags = this._parseJSON(newValue); + } + + connectedCallback() { + if (!this._relatedShown) { + this.style.display = 'none'; + return; + } + + if (this._relatedShown) { + this.fetchAndDisplayArticles(); + } + } + + async fetchAndDisplayArticles() { + if (!this._baseURL) { + console.error('Base URL is missing.'); + return; + } + + try { + const endpoint = this.buildEndpoint(); + + const response = await fetch(endpoint); + if (!response.ok) { + throw new Error(`Failed to fetch articles: ${response.status}`); + } + + const data = await response.json(); + + const { articles, thumbnails } = this.processIncludedData(data.data, data.included); + const rankedArticles = this.rankArticles(articles); + + this.renderArticles(rankedArticles.slice(0, 3), thumbnails); + } catch (error) { + console.error('Error fetching articles:', error); + this.toggleError(true); + } finally { + this.toggleLoading(false); + this.toggleBlockVisibility(); + } + } + + buildEndpoint() { + let endpoint = `${this._baseURL}/jsonapi/node/ucb_article?filter[status][value]=1&include=field_ucb_article_thumbnail.field_media_image&sort=-created`; + + // Add category filters + if (this._categories.length > 0) { + const categoryFilters = this._categories + .map( + (cat) => + `&filter[cat${cat}][condition][path]=field_ucb_article_categories.meta.drupal_internal__target_id&filter[cat${cat}][condition][value]=${cat}&filter[cat${cat}][condition][memberOf]=categories` + ) + .join(''); + endpoint += `&filter[categories][group][conjunction]=OR${categoryFilters}`; + } + + // Add tag filters + if (this._tags.length > 0) { + const tagFilters = this._tags + .map( + (tag) => + `&filter[tag${tag}][condition][path]=field_ucb_article_tags.meta.drupal_internal__target_id&filter[tag${tag}][condition][value]=${tag}&filter[tag${tag}][condition][memberOf]=tags` + ) + .join(''); + endpoint += `&filter[tags][group][conjunction]=OR${tagFilters}`; + } + return endpoint; + } + + processIncludedData(articles, included) { + const thumbnails = {}; + if (included) { + const idObj = {}; + const altObj = {}; + + const idFilterData = included.filter((item) => item.type === 'media--image'); + const altFilterData = included.filter((item) => item.type === 'file--file'); + + altFilterData.forEach((item) => { + if (item.links?.focal_image_square) { + altObj[item.id] = { src: item.links.focal_image_square.href }; + } else { + altObj[item.id] = { src: item.attributes.uri.url }; + } + }); + + idFilterData.forEach((pair) => { + const thumbnailId = pair.relationships?.thumbnail?.data?.id; + if (thumbnailId) { + idObj[pair.id] = thumbnailId; + if (altObj[thumbnailId]) { + altObj[thumbnailId].alt = pair.relationships.thumbnail.data.meta.alt || 'Thumbnail'; } - // If both catMatches and tagMatches are the same, compare by created date - const dateA = new Date(a.article.attributes.created); - const dateB = new Date(b.article.attributes.created); - return dateB - dateA; + thumbnails[pair.id] = altObj[thumbnailId]; + } }); - //Remove articles without matches from those availabile in the block - var finalArr = articleArrayWithScores.filter(article=> article.catMatches > 0) - // if more than 3 articles, take the top 3 - if(finalArr.length>3){ - finalArr.length = 3 - } else if(finalArr.length<3){ - let howManyLeft = 3 - finalArr.length - // if less than 3, grab the most tags - getArticlesWithTags(tagQuery,finalArr, myTags, howManyLeft, privateTags); - } - - - - - - - // Create the article cards contained within the block, assign classes - let relatedArticlesDiv = document.createElement('div') - relatedArticlesDiv.classList = "row related-articles-section" - relatedArticlesBlock.appendChild(relatedArticlesDiv) - - finalArr.map((article)=>{ - let articleCard = document.createElement('div') - articleCard.classList = "ucb-article-card col-sm-12 col-md-6 col-lg-4" - let title = article.article.attributes.title; - - let link = article.article.attributes.path.alias ? baseURL + article.article.attributes.path.alias : `${baseURL}/node/${article.article.attributes.drupal_internal__nid}`; - // if no thumbnail, show no image - if (!article.article.relationships.field_ucb_article_thumbnail.data) { - image = ""; - } else { - //Use the idObj as a memo to add the corresponding image url - let thumbId = article.article.relationships.field_ucb_article_thumbnail.data.id; - image = urlObj[idObj[thumbId]]; - } - let body = "" - // if summary, use that - if( article.article.attributes.field_ucb_article_summary != null){ - body = article.article.attributes.field_ucb_article_summary; - } - - // if image, use it - if (!article.article.relationships.field_ucb_article_thumbnail.data) { - imageSrc = ""; - } else { - //Use the idObj as a memo to add the corresponding image url - let thumbId = article.article.relationships.field_ucb_article_thumbnail.data.id; - imageSrc = urlObj[idObj[thumbId]]; - } - - var artcardImgContainer = document.createElement('div'); - artcardImgContainer.classList = 'ucb-article-card-img'; - - var artCardImgLink = document.createElement('a'); - artCardImgLink.href = link; - - var artCardImg = document.createElement('img'); - artCardImg.src = imageSrc; - - var artCardDataContainer = document.createElement('div') - artCardDataContainer.classList = 'ucb-article-card-data' - - var artCardDataTitle = document.createElement('span') - artCardDataTitle.classList= 'ucb-article-card-title' - - var artCardTitleLink = document.createElement('a') - artCardTitleLink.href = link; - artCardTitleLink.innerText = title; - - var artCardDataBody = document.createElement('span') - artCardDataBody.classList = 'ucb-related-article-card-body' - artCardDataBody.innerText = body; - - - if(link && imageSrc) { - // image = ``; - - artCardImgLink.appendChild(artCardImg) - artcardImgContainer.appendChild(artCardImgLink) - - } - - artCardDataTitle.appendChild(artCardTitleLink) - artCardDataContainer.appendChild(artCardDataTitle) - artCardDataContainer.appendChild(artCardDataBody) - // let outputHTML = ` - //
No related articles found.
'; + return; + } + + articles.forEach(({ article }) => { + const title = article.attributes.title; + const summary = article.attributes.field_ucb_article_summary || ''; + const link = article.attributes.path.alias ? this._baseURL + article.attributes.path.alias : `${baseURL}/node/${article.attributes.drupal_internal__nid}`; + const thumbnail = thumbnails[article.relationships?.field_ucb_article_thumbnail?.data?.id]; + + const articleElement = document.createElement('div'); + articleElement.className = "ucb-article-card col-sm-12 col-md-6 col-lg-4"; + + if (thumbnail) { + const imgLink = document.createElement('a'); + imgLink.href = link; + const img = document.createElement('img'); + img.src = thumbnail.src; + img.alt = thumbnail.alt; + imgLink.appendChild(img) + articleElement.appendChild(imgLink); + } + + const titleElement = document.createElement('span'); + titleElement.classList.add('ucb-article-card-title') + titleElement.innerHTML = `${title}`; + articleElement.appendChild(titleElement); + + const summaryElement = document.createElement('p'); + summaryElement.classList.add('ucb-article-card-data') + summaryElement.textContent = summary; + articleElement.appendChild(summaryElement); + + this._container.appendChild(articleElement); + }); + } + + toggleLoading(show) { + this._loadingElement.style.display = show ? 'block' : 'none'; + } + + toggleError(show) { + this._errorElement.style.display = show ? 'block' : 'none'; + } + + toggleBlockVisibility() { + const block = this.closest('.ucb-related-articles-block'); + if (block) { + block.style.display = this._container.innerHTML.trim() ? 'block' : 'none'; + } + } + + _parseJSON(value) { + try { + return JSON.parse(value || '[]'); + } catch (e) { + console.warn(`Error parsing JSON for value: ${value}`); + return []; + } + } } -function getPrivateTags(){ - let privateTags = [] - fetch(`${baseURL}/jsonapi/taxonomy_term/tags?filter[field_ucb_tag_display]=false`) - .then(response => response.json()) - .then(data=>{ - data.data.forEach(tag=>{ - privateTags.push(tag.attributes.drupal_internal__tid) - }) - }) - return privateTags - -} +customElements.define('related-articles', RelatedArticles); diff --git a/templates/content/node--ucb-article.html.twig b/templates/content/node--ucb-article.html.twig index 5a4a9a65..73a24eb1 100644 --- a/templates/content/node--ucb-article.html.twig +++ b/templates/content/node--ucb-article.html.twig @@ -13,8 +13,8 @@ {# Base Url #} {% set baseURL = url('