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 }}
+
+
+ {{ label }}
+
+ {% if content %}
+