diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b7dab7b..f6a21fe9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- ### Adds Class Note Page + Class Notes List Page + Adds the `Class Note` Node and `Class Note List` node. A Class Note List Page lists your Class Notes and has built in filters to allow visitors to filter by year or sort by class year or date posted. + + Includes: + `tiamat-theme` => https://github.com/CuBoulder/tiamat-theme/pull/621 + `tiamat-custom-entities` => https://github.com/CuBoulder/tiamat-custom-entities/pull/91 + + + Resolves https://github.com/CuBoulder/tiamat-theme/issues/588 +--- + - ### Fixes accessibility issues with Article and Article List - Adds alt text to Article List images. - Enhances readability and fixes bugs with article title backgrounds. diff --git a/boulder_base.libraries.yml b/boulder_base.libraries.yml index 74414d5b..9e4e2e18 100644 --- a/boulder_base.libraries.yml +++ b/boulder_base.libraries.yml @@ -457,3 +457,17 @@ ucb-user-page: theme: css/ucb-status-page-block.css: {} css/ucb-user-page.css: {} + +ucb-class-notes: + version: 1.x + css: + theme: + css/ucb-class-notes.css: {} + +ucb-class-notes-list-page: + version: 1.x + js: + js/ucb-class-notes-list.js: {} + css: + theme: + css/ucb-class-notes-list.css: {} diff --git a/css/ucb-class-notes-list.css b/css/ucb-class-notes-list.css new file mode 100644 index 00000000..8a2bc3dc --- /dev/null +++ b/css/ucb-class-notes-list.css @@ -0,0 +1,101 @@ +.ucb-class-note-link{ + font-size: 200%; + font-weight: 700; + color: #959595 !important; + margin-bottom: 10px; +} + +.ucb-class-note-year-container{ + margin-bottom: 10px; +} + +.ucb-class-note-link:hover{ + color: #333 !important; +} + +.ucb-class-notes-list-container{ + margin-top: 20px; +} + +.class-note-posted-date{ + font-size: 75%; +} + +.ucb-class-notes-list-note-item{ + margin-bottom: 20px; + border-bottom: 1px solid rgba(128, 128, 128, 0.333); + /* padding-bottom: 20px; */ +} + +.class-note-year-select, .ucb-class-notes-filter-label{ + margin-right: 10px; +} + +.ucb-list-msg { + font-size: 1.25em; + font-weight: bolder; + text-align: center; + } + + .ucb-loading-data { + color: #01579b; + text-align: center; + } + + .ucb-list-msg, + .ucb-loading-data { + display: block; + } + .ucb-list-msg[hidden], + .ucb-loading-data[hidden] { + display: none; + } + +.class-notes-list-filter{ + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + padding: 10px; + border: 1px solid rgba(128, 128, 128, 0.333); + min-width: auto; +} + +.ucb-class-notes-filter-label{ + font-size: 85%; + font-weight: 600; +} + +.ucb-class-notes-view-all-container{ + flex: 1; + text-align: right; + font-size: 85%; +} +.ucb-class-notes-read-more-container{ + text-align: center; +} +.ucb-class-notes-read-more{ + padding: 5px 10px; + font-weight: bold; + font-family: "Roboto","Helvetica Neue",Helvetica,Arial,sans-serif; + margin-bottom: 5px; + background-clip: padding-box; + color: #0277BD !important; + border: 1px solid #0277BD; + border-radius: 0px; + background-color: transparent; + text-decoration: none; +} + +.ucb-class-notes-read-more:hover { + transition: background-color 0.25s ease,border-color 0.25s ease,color 0.25s ease; + background-color: #0277BD; + color: white !important; +} + +@media only screen and (max-width: 525px) { + .ucb-class-notes-view-all-container{ + flex: auto; + text-align: inherit; + } +} \ No newline at end of file diff --git a/css/ucb-class-notes.css b/css/ucb-class-notes.css new file mode 100644 index 00000000..33c8fe9c --- /dev/null +++ b/css/ucb-class-notes.css @@ -0,0 +1,3 @@ +.ucb-class-notes{ + padding: 20px 0; +} \ No newline at end of file diff --git a/js/ucb-class-notes-list.js b/js/ucb-class-notes-list.js new file mode 100644 index 00000000..8e630976 --- /dev/null +++ b/js/ucb-class-notes-list.js @@ -0,0 +1,303 @@ +class ClassNotesListElement extends HTMLElement { + constructor() { + super(); + const + chromeElement = this._chromeElement = document.createElement('div'), + userFormElement = this._userFormElement = document.createElement('div'), + notesListElement = this._notesListElement = document.createElement('div'), + messageElement = this._messageElement = document.createElement('div'), + loadingElement = this._loadingElement = document.createElement('div'); + messageElement.className = 'ucb-list-msg'; + messageElement.setAttribute('hidden', ''); + loadingElement.className = 'ucb-loading-data'; + loadingElement.innerHTML = ''; + this._notesListElement.classList.add('ucb-class-notes-list-container') + chromeElement.appendChild(userFormElement); + chromeElement.appendChild(notesListElement) + chromeElement.appendChild(messageElement); + chromeElement.appendChild(loadingElement); + this.appendChild(chromeElement); + const dates = ['--Select date--', 1900, 1901, 1902, 1903, 1904, 1905, 1906, 1907, 1908, 1909, 1910, 1911, 1912, 1913, 1914, 1915, 1916, 1917, 1918, 1919, 1920, 1921, 1922, 1923, 1924, 1925, 1926, 1927, 1928, 1929, 1930, 1931, 1932, 1933, 1934, 1935, 1936, 1937, 1938, 1939, 1940, 1941, 1942, 1943, 1944, 1945, 1946, 1947, 1948, 1949, 1950, 1951, 1952, 1953, 1954, 1955, 1956, 1957, 1958, 1959, 1960, 1961, 1962, 1963, 1964, 1965, 1966, 1967, 1968, 1969, 1970, 1971, 1972, 1973, 1974, 1975, 1976, 1977, 1978, 1979, 1980, 1981, 1982, 1983, 1984, 1985, 1986, 1987, 1988, 1989, 1990, 1991, 1992, 1993, 1994, 1995, 1996, 1997, 1998, 1999, 2000, 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025, 2026, 2027, 2028, 2029, 2030, 2031, 2032, 2033, 2034, 2035, 2036, 2037, 2038, 2039, 2040, 2041, 2042, 2043, 2044, 2045, 2046, 2047, 2048, 2049, 2050] + const JSONURL = this.getAttribute('base-uri'); + // Build user filters + this.generateForm(dates) + // Insert year filter, make call + this.getData(JSONURL, "", 'Class Year') + } + // Gets info + getData(JSONURL, year = '', sort, notes = [], nextURL = "") { + this.toggleMessageDisplay(this._loadingElement, 'block', null, null); + this.toggleMessageDisplay(this._messageElement, 'none', null, null); + let yearFilter = ''; + let publishFilter = '' + let sortFilter = '' + if(sort == 'Class Year'){ + sortFilter = '&sort=field_ucb_class_year' + } else { + sortFilter = '&sort=-created' + } + // If its not a next link, build the JSON API URL + if (year) { + yearFilter = `?filter[field_ucb_class_year]=${year}` + publishFilter = '&filter[status]=1' + } else { + yearFilter = '' + publishFilter = '?filter[status]=1' + } + const API = nextURL != "" ? nextURL : JSONURL + yearFilter + publishFilter + sortFilter + fetch(API) + .then(this.handleError) + .then((data) => { + const nextURL = data.links.next ? data.links.next.href : ""; + // Iterate over all notes + data.data.forEach(note=>{ + notes.push(note) + }) + this.build(notes, nextURL) + + }) + .catch(Error=> { + console.error('There was an error fetching data from the API - Please try again later.') + console.error(Error) + this.toggleMessageDisplay(this._loadingElement, 'none'); + this.toggleMessageDisplay(this._messageElement, 'block', 'ucb-list-msg ucb-api-error', 'Error retrieving data from the API endpoint. Please try again later.'); + }); + } + // Render handler + build(data, nextURL){ + const classNotesContainer = this._notesListElement + // Build Notes + if(!data.length){ + this.toggleMessageDisplay(this._loadingElement, 'none', null, null); + this.toggleMessageDisplay(this._messageElement, 'block', 'ucb-list-msg ucb-end-of-results', 'No results matching your filters.'); + } else { + data.forEach(note => { + const classNote = document.createElement('article') + classNote.classList.add('ucb-class-notes-list-note-item') + // Date (Class Note Link) + const classNoteYearContainer = document.createElement('div') + classNoteYearContainer.classList.add('ucb-class-note-year-container') + const classNoteYearLink = document.createElement('a'); + classNoteYearLink.classList.add('ucb-class-note-link') + classNoteYearLink.href = '#'; + classNoteYearLink.innerText = note.attributes.field_ucb_class_year; + // Class Note Link Event Listener + classNoteYearLink.addEventListener('click', (event) => { + event.preventDefault(); // Prevent default anchor behavior + this.onYearSelect(note.attributes.field_ucb_class_year); + }); + classNoteYearContainer.appendChild(classNoteYearLink) + classNote.appendChild(classNoteYearContainer) + + // Class Note Text + const classNoteParagraph = document.createElement('p') + classNoteParagraph.innerHTML = this.escapeHTML(note.attributes.body.processed) + classNote.appendChild(classNoteParagraph) + // Date posted + const classNotePosted = document.createElement('p') + classNotePosted.classList.add('class-note-posted-date') + classNotePosted.innerText = `Posted ${this.formatDateString(note.attributes.created)}` + classNote.appendChild(classNotePosted) + this.toggleMessageDisplay(this._loadingElement, 'none', null, null); + + classNotesContainer.appendChild(classNote) + }) + } + // Makes the next button + if(nextURL != ""){ + const nextButtonContainer = document.createElement('div') + nextButtonContainer.classList.add('ucb-class-notes-read-more-container') + const nextButton = document.createElement('button'); + nextButton.classList.add('ucb-class-notes-read-more'); + nextButton.innerText = 'Load More Notes'; + nextButton.addEventListener('click', () => { + this.getNextSet(nextURL); + nextButton.remove(); // Remove the button after it's clicked + }); + // Append the button to the container, then the element + nextButtonContainer.appendChild(nextButton) + this._notesListElement.appendChild(nextButtonContainer); + } + } + // Used for toggling the error messages/loader on/off + toggleMessageDisplay(element, display, className, innerText) { + if(className) + element.className = className; + if(innerText) + element.innerText = innerText; + if(display === 'none') + element.setAttribute('hidden', ''); + else element.removeAttribute('hidden'); + } + // Builds the user forms + generateForm(dates){ + // Create Elements + const form = document.createElement('form'), + formDiv = document.createElement('div'); + form.className = 'class-notes-list-filter'; + formDiv.className = 'd-flex align-items-center'; + // Create container + const container = document.createElement('div'); + container.className = `form-item`; + // Create label el + const itemLabel = document.createElement('label'), + itemLabelSpan = document.createElement('span'); + itemLabelSpan.classList.add('filter-by-label','ucb-class-notes-filter-label') + itemLabelSpan.innerText = "Filter by Year:"; + itemLabel.appendChild(itemLabelSpan); + // Create select el + const selectFilter = document.createElement('select'); + selectFilter.name = "Year" + selectFilter.classList.add('Year-Select', 'class-note-year-select'); + selectFilter.onchange = this.onYearChange.bind(this); // Bind the event handler + itemLabel.appendChild(selectFilter); + container.appendChild(itemLabel); + formDiv.appendChild(container); + // Appends + this.generateDropdown(dates, selectFilter) + form.appendChild(formDiv); + this._userFormElement.appendChild(form); + + // Sort By : Create container + const sortContainer = document.createElement('div'); + sortContainer.classList.add('form-item', "sort-form-item"); + // Create label el + const sortItemLabel = document.createElement('label'), sortItemLabelSpan = document.createElement('span'); + sortItemLabelSpan.innerText = "Sort By:"; + sortItemLabelSpan.classList.add('sort-by-label','ucb-class-notes-filter-label') + sortItemLabel.appendChild(sortItemLabelSpan); + const sortSelectFilter = document.createElement('select'); + sortSelectFilter.name = "Sort" + sortSelectFilter.classList.add('Sort-Select','class-note-sort-select'); + sortSelectFilter.onchange = this.onSortChange.bind(this); // Bind the event handler + sortItemLabel.appendChild(sortSelectFilter); + container.appendChild(sortItemLabel); + formDiv.appendChild(container); + this.generateDropdown(["Class Year", "Date Posted"],sortSelectFilter) + + + // Add 'View All Notes' Link + const viewAllLinkContainer = document.createElement('div') + viewAllLinkContainer.classList.add('ucb-class-notes-view-all-container') + const viewAllLink = document.createElement('a'); + viewAllLink.href = '#'; + viewAllLink.innerText = 'View All Notes'; + viewAllLink.addEventListener('click', this.viewAllNotes.bind(this)); + viewAllLinkContainer.appendChild(viewAllLink) + form.appendChild(viewAllLinkContainer); + this._userFormElement.appendChild(form); + } + // Generates the Dropdowns, on the user form + generateDropdown(dates, selectElement){ + dates.map(date => { + const option = document.createElement('option'); + if(date === '--Select date--'){ + option.value = '' + } else { + option.value = date; + } + option.innerText = date; + selectElement.appendChild(option); + }) + } + // Event handler for the dropdown change + onYearChange(event) { + const year = event.target.value; + const JSONURL = this.getAttribute('base-uri'); + const sort = this.getSortValue(); + this.clearNotesList(); + this.getData(JSONURL, year, sort); + } + // If a Class Note Year is selected... + onYearSelect(year){ + const JSONURL = this.getAttribute('base-uri'); + const yearDropdown = this.querySelector('.class-note-year-select'); + yearDropdown.value = year; + + this.clearNotesList() + this.getData(JSONURL, year); + } + // Event handler for Sort filter dropdown change + onSortChange(event){ + const sort = event.target.value + const JSONURL = this.getAttribute('base-uri'); + const year = this.getYearValue(); + this.clearNotesList() + this.getData(JSONURL, year, sort); + } + // Helper method to clear the notes list + clearNotesList() { + const notesListElement = this._notesListElement; + while (notesListElement.firstChild) { + notesListElement.removeChild(notesListElement.firstChild); + } + } + // Event handler for View All -- no year specified + viewAllNotes(event){ + event.preventDefault(); + this.resetDropdowns(); + const JSONURL = this.getAttribute('base-uri'); + this.clearNotesList() + this.getData(JSONURL, "", "Class Year") + } + // Prevents malicious user input + escapeHTML(raw) { + if (!raw) return ''; + + // First, escape all HTML to prevent execution of unwanted tags or JavaScript. + let escapedHTML = raw.replace(/\&/g, '&').replace(/"/g, '"') + .replace(//g, '>'); + + // Unescape the allowed tags (p, strong, em) + escapedHTML = escapedHTML.replace(/<(\/?p)>/g, '<$1>') + .replace(/<(\/?strong)>/g, '<$1>') + .replace(/<(\/?em)>/g, '<$1>'); + + return escapedHTML; + } + // Date formatter + formatDateString(dateString) { + const options = { year: 'numeric', month: 'short', day: 'numeric' }; + const date = new Date(dateString); + return date.toLocaleDateString('en-US', options); + } + // Used for loading articles beyond 50, sets up next API calls + getNextSet(nextURL){ + // Remove existing 'Load More Notes' button + const loadMoreButton = this.querySelector('.ucb-class-notes-read-more'); + if (loadMoreButton) { + loadMoreButton.remove(); + } + + // Call API and update data + this.getData(nextURL); + } + // Helper method to get the current year value from the dropdown + getYearValue() { + return this.querySelector('.class-note-year-select').value; + } + // Helper method to get the current sort value from the dropdown + getSortValue() { + return this.querySelector('.class-note-sort-select').value; + } + // Dropdown resetter + resetDropdowns() { + const yearDropdown = this.querySelector('.class-note-year-select'); + const sortDropdown = this.querySelector('.class-note-sort-select'); + + if (yearDropdown && sortDropdown) { + yearDropdown.value = ''; + sortDropdown.value = 'Class Year'; + } + } + // Error handler + handleError = response => { + if (!response.ok) { + throw new Error; + } else { + return response.json(); + } + }; +} + +customElements.define('ucb-class-notes-list', ClassNotesListElement); diff --git a/templates/content/node--ucb-class-notes-list-page.html.twig b/templates/content/node--ucb-class-notes-list-page.html.twig new file mode 100644 index 00000000..ef771ef1 --- /dev/null +++ b/templates/content/node--ucb-class-notes-list-page.html.twig @@ -0,0 +1,41 @@ +{# + + http://localhost:50282/jsonapi/node/ucb_class_notes + #} +{{ attach_library('boulder_base/ucb-class-notes-list-page') }} + +{% set classes = [ + 'node', + 'container', + 'node--type-' ~ node.bundle|clean_class, + node.isPromoted() ? 'node--promoted', + node.isSticky() ? 'node--sticky', + not node.isPublished() ? 'node--unpublished', + view_mode ? 'node--view-mode-' ~ view_mode|clean_class, + 'ucb-content-wrapper' + ] %} + + + {% set PublishedFilter = '&filter[publish-check][condition][path]=status' + ~ '&filter[publish-check][condition][value]=1' + ~ '&filter[publish-check][condition][memberOf]=published' +%} + + +
+ + {{ label }} + +
+ {% if content.body|render %} +
+ {{ content.body }} +
+ {% endif %} + ')|render|trim('/') ~ '/jsonapi/node/ucb_class_notes', +})}}> + +
+ + diff --git a/templates/content/node--ucb-class-notes.html.twig b/templates/content/node--ucb-class-notes.html.twig new file mode 100644 index 00000000..1f2aa0fe --- /dev/null +++ b/templates/content/node--ucb-class-notes.html.twig @@ -0,0 +1,19 @@ +{{ attach_library('boulder_base/ucb-class-notes') }} + +{% set classes = [ + 'node', + 'ucb-class-notes', + 'container', + ] %} + + + + {{ label }} + + {% if content %} + + {{ content }} + + {% endif %} + + \ No newline at end of file diff --git a/templates/field/field--node--field-ucb-class-year.html.twig b/templates/field/field--node--field-ucb-class-year.html.twig new file mode 100644 index 00000000..cb6251dc --- /dev/null +++ b/templates/field/field--node--field-ucb-class-year.html.twig @@ -0,0 +1,4 @@ +{% for item in items %} +

Class Year

+ {{ item.content}} +{% endfor %} \ No newline at end of file