class FeedBlock {
|
static LOADING_QUIPS = JSON.parse(feedSettings.quips);
|
constructor(container){
|
|
this.cache = window.jvbCache;
|
this.eventHandlers = new Map();
|
|
this.rendered = {};
|
|
// Store container references
|
this.container = container;
|
this.a11y = window.jvbA11y;
|
|
|
//For tracking cache and current requests
|
this.currentRequest = null;
|
this.timeoutId = null;
|
|
// Initialize Config
|
this.initConfig();
|
|
|
// Setup error handler
|
this.error = window.jvbError;
|
|
this.resetState();
|
this.state.firstLoad = false;
|
this.state.URLProcessed = false;
|
this.highlightGot = false;
|
|
this.selectorInstances = {};
|
|
this.elements = {
|
filters: this.container.querySelector('form.feed-filters'),
|
selected: this.container.querySelector('.selected-items'),
|
clearFilters: this.container.querySelector('button.clear-filters'),
|
grid: this.container.querySelector('.feed-grid'),
|
loadMore: this.container.querySelector('button.load-more'),
|
spinner: this.container.querySelector('.loading-spinner'),
|
loading: this.container.querySelector('.feed-overlay'),
|
matchAll: this.container.querySelector('.filter-actions .toggle-text'),
|
}
|
this.filters = {
|
content: this.getCurrentContent(),
|
taxonomies: {},
|
favourites: false,
|
orderby: 'date',
|
order: 'desc',
|
}
|
|
this.feed = {
|
imageLoadThreshold: 5,
|
lazyLoadOffset: '100px',
|
gallery: [],
|
loaded: 0,
|
intersectionObserver: null,
|
templates: new Map()
|
}
|
this.imageObserver = null;
|
this.resizeObserver = null;
|
|
this.highlight = null;
|
//Loading settings
|
this.loadingIndex = 0;
|
this.quips = this.initializeQuips();
|
this.loadingMessage = this.container.querySelector('.loading-message');
|
this.dotsElement = this.container.querySelector('.loading-dots');
|
this.quipInterval = null;
|
this.loadingOptions = {
|
loadingMessages: {},
|
cycleInterval: 2000, //time between loading messages
|
}
|
|
this.initFilters();
|
if(this.container.dataset.gallery){
|
this.initGallery();
|
this.setupGalleryAccessibility();
|
}
|
this.initListeners();
|
if(!this.state.URLProcessed){
|
this.updateFilters();
|
}
|
|
this.selectedListeners = this.checkSelectedClicks.bind(this);
|
this.updateSelectedListeners();
|
}
|
|
initConfig() {
|
// Get settings from container data attribute
|
const settings = JSON.parse(this.container.dataset.settings || '{}');
|
let content = Array.from(this.container.querySelectorAll('input[name="content"]')).map(content=> content.value);
|
this.config = {
|
api: feedSettings.apiUrl,
|
nonce: feedSettings.nonce,
|
currentUser: jvbSettings.currentUser || null,
|
|
content: content[0],
|
contentTypes: content,
|
taxonomies: Array.from(this.container.querySelectorAll('.jvb-selector')).map(taxonomy => taxonomy.dataset.taxonomy),
|
|
// Source information for analytics
|
source: this.container.dataset.source || '',
|
context: this.container.dataset.context || '',
|
|
// Optional highlight
|
highlight: null,
|
|
// Gallery mode
|
isGallery: this.container.dataset.gallery || false,
|
showAuthor: !this.container.dataset.gallery || true,
|
showDate: this.container.dataset.gallery || false,
|
|
|
// User preferences
|
viewMode: localStorage.getItem('feedViewMode') || 'grid',
|
}
|
|
if(settings.isGallery){
|
this.config.highlight = this.getHighlight();
|
}
|
}
|
|
initFilters(){
|
this.updateContentFor(this.getCurrentContent());
|
}
|
|
checkSelectedClicks(e){
|
if(e.target.closest('.remove-item')){
|
let tag = e.target.closest('.selected-item');
|
|
// Uncheck checkbox if it exists
|
let taxonomy = tag.dataset.taxonomy;
|
this.clearSelectedTerm(tag.dataset.id, taxonomy);
|
// Remove tag
|
tag.remove();
|
|
// Update clear filters button visibility
|
this.updateClearFiltersButton();
|
this.updateFilters();
|
this.updateSelectedListeners();
|
}
|
}
|
|
updateSelectedListeners(){
|
this.elements.selected.removeEventListener('click', this.selectedListeners);
|
if(this.elements.selected.children.length>0){
|
this.elements.selected.addEventListener('click', this.selectedListeners);
|
}
|
}
|
|
handleFilterChange(e){
|
if(e.target.closest('.jvb-selector')){
|
return;
|
}
|
this.resetPage();
|
if(e.target.name === 'content'){
|
let content = e.target.value;
|
this.updateContentFor(content);
|
}
|
this.updateFilters();
|
}
|
handleLoadMore(){
|
if (this.state.loading || !this.state.hasMore) {
|
return;
|
}
|
this.fetchFeed();
|
}
|
initListeners(){
|
window.addEventListener('popstate', this.handlePopState.bind(this));
|
this.addEvent(this.elements.filters, 'change', (e) => this.handleFilterChange(e));
|
this.addEvent(this.elements.filters, 'submit', e => e.preventDefault());
|
this.addEvent(this.elements.loadMore, 'click', () => this.handleLoadMore());
|
this.addEvent(this.elements.clearFilters, 'click', () => this.clearSelectedTaxonomies());
|
if(this.config.isGallery){
|
this.addEvent(this.elements.grid, 'click', (e) =>this.handleGalleryOpen(e));
|
}
|
|
// Intersection observer for lazy loading
|
if ('IntersectionObserver' in window) {
|
this.imageObserver = new IntersectionObserver(entries => {
|
entries.forEach(entry => {
|
if (entry.isIntersecting) {
|
this.loadImage(entry.target);
|
this.imageObserver.unobserve(entry.target);
|
}
|
});
|
}, {
|
rootMargin: this.feed.lazyLoadOffset,
|
threshold: 0.1
|
});
|
}
|
// Resize observer for responsive images
|
if ('ResizeObserver' in window) {
|
this.resizeObserver = new ResizeObserver(window.debounce(() => {
|
this.updateImageSizes();
|
}, 250));
|
|
// Observe the container
|
this.resizeObserver.observe(this.container);
|
} else {
|
// Fallback to window resize
|
window.addEventListener('resize', window.debounce(() => {
|
this.updateImageSizes();
|
}, 250));
|
}
|
}
|
handleGalleryOpen(e){
|
const feedImage = e.target.closest('.feed-image');
|
if(feedImage){
|
const item = feedImage.closest('.feed-item');
|
if(item) {
|
const index = Array.from(this.container.querySelectorAll('.feed-item')).indexOf(item);
|
if(index !== -1){
|
this.openGallery(index);
|
}
|
}
|
}
|
}
|
initGallery(){
|
this.gallery = {
|
items: this.getGalleryItems() || [],
|
index: 0,
|
touchStart: null,
|
touchEnd: null,
|
minSwipe: 50,
|
modal: this.createGalleryModal(),
|
keyHandler: null,
|
loading: false
|
}
|
document.body.appendChild(this.gallery.modal);
|
}
|
|
/**
|
* Handle browser history navigation
|
*/
|
handlePopState(e) {
|
if (e.state && e.state.filters) {
|
if(this.processURLFilters()){
|
|
// Load items with updated filters
|
this.resetPage();
|
this.fetchFeed();
|
|
// Announce to screen readers
|
this.a11y.announce('Feed filters updated from browser history.');
|
}
|
}
|
}
|
|
processURLFilters(){
|
const params = new URLSearchParams(window.location.search);
|
if (!params.toString()) {
|
return false; // No parameters to process
|
}
|
// Initialize content type
|
const content = params.get('f_content');
|
params.delete('f_content');
|
if (content && this.elements.filters.querySelector(`input[value="${content}"]`)) {
|
this.elements.filters.querySelector(`input[value="${content}"]`).checked = true;
|
this.filters.content = content;
|
}
|
|
// Initialize order
|
const order = params.get('f_order');
|
params.delete('f_order');
|
if (order && this.elements.filters.querySelector(`input[name="order"][value="${order}"]`)) {
|
this.elements.filters.querySelector(`input[name="order"][value="${order}"]`).checked = true;
|
this.filters.order = order;
|
}
|
|
// Initialize orderby
|
const orderby = params.get('f_orderby');
|
params.delete('f_orderby');
|
if (orderby && this.elements.filters.querySelector(`input[name="orderby"][value="${orderby}"]`)) {
|
this.elements.filters.querySelector(`input[name="orderby"][value="${orderby}"]`).checked = true;
|
this.filters.orderby = orderby;
|
}
|
|
// Initialize favourites filter
|
if (params.get('f_favourites') === 'true' && this.config.currentUser !== null) {
|
params.delete('f_favourites');
|
const favCheckbox = this.elements.filters.querySelector(`input[name="favourites_only"]`);
|
if (favCheckbox) favCheckbox.checked = true;
|
this.filters.favourites = true;
|
}
|
|
// Initialize match, if present
|
if(params.get('f_match') === 'all'){
|
params.delete('f_match');
|
this.elements.matchAll.querySelector('input').checked = true;
|
}
|
|
window.removeChildren(this.elements.selected);
|
|
let filters = JSON.parse(JSON.stringify(this.filters));
|
let unprocessed = {};
|
for (var [key, value] of Object.entries(Object.fromEntries(params))) {
|
if (key.startsWith('f_') ) {
|
var taxName = key.replace('f_', '');
|
let cache = this.cache.getItem(taxName+'List');
|
if(!Object.keys(filters['taxonomies']).includes(taxName)){
|
filters.taxonomies[taxName] = {};
|
}
|
|
// Handle both single values and comma-separated values
|
const termIds = value.includes(',') ? value.split(',') : [value];
|
termIds.forEach(termId=>{
|
if(cache && cache.hasOwnProperty(termId)){
|
filters.taxonomies[taxName][termId] = cache[termId].name;
|
let tag = this.createFilterTag(taxName, termId, cache[termId].name);
|
this.elements.selected.appendChild(tag);
|
}else{
|
if(!unprocessed.hasOwnProperty(taxName)){
|
unprocessed[taxName] = [];
|
}
|
unprocessed[taxName].push(termId);
|
}
|
});
|
}
|
}
|
|
this.filters = filters;
|
|
if(!isEmptyObject(unprocessed)){
|
this.fetchTermDetails(unprocessed);
|
}
|
|
if(this.config.isGallery){
|
this.getHighlight();
|
}
|
|
this.updateContentFor(content);
|
this.updateSelectedListeners();
|
return true;
|
|
}
|
|
async loadFromURL() {
|
|
if(this.processURLFilters()){
|
// Announce to screen readers
|
this.a11y.announce('Feed filters updated from URL.');
|
}
|
return true;
|
}
|
|
//We already checked the cache, now fetch the remaining term information
|
async fetchTermDetails(terms) {
|
let params = new URLSearchParams(terms);
|
|
// Otherwise fetch the term details
|
try {
|
// Format the URL - might need to adjust based on your API structure
|
const url = `${this.config.api}terms/check?`+params.toString();
|
|
const termData = await this.cache.fetchWithCache(
|
url, {
|
method: 'GET',
|
},
|
{
|
});
|
|
for(const [taxonomy, terms] of Object.entries(termData.terms)){
|
if(!this.filters.taxonomies.hasOwnProperty(taxonomy)){
|
this.filters.taxonomies[taxonomy] = {};
|
}
|
|
this.cache.setItem(taxonomy+'List', terms, taxonomy);
|
|
for(const [termId, termData] of Object.entries(terms)){
|
this.filters.taxonomies[taxonomy][termId] = termData.name;
|
let tag = this.createFilterTag(taxonomy, termId, termData.name);
|
this.elements.selected.appendChild(tag);
|
}
|
this.updateSelectedListeners();
|
}
|
|
} catch (error) {
|
console.error(`Error fetching term details for ${terms}:`, error);
|
|
}
|
}
|
|
//State Management
|
nextPage(hasMore = true){
|
this.state.page++;
|
this.state.hasMore = hasMore;
|
}
|
resetPage(hasMore = true){
|
this.state.page = 1;
|
this.state.hasMore = hasMore;
|
}
|
resetState() {
|
this.state = {
|
page: 1,
|
loading: false,
|
hasMore: true,
|
retries: {
|
count: 0,
|
max: 3,
|
delay: 1000
|
}
|
}
|
}
|
|
updateFilters(reset = true){
|
let updated = false;
|
let content = this.getCurrentContent();
|
let favourites = this.getFavouritesOnly();
|
let order = this.getCurrentOrder();
|
let orderby = this.getCurrentOrderby();
|
let taxonomies = this.getSelectedTaxonomies();
|
|
if(this.filters.content !== content ||
|
this.filters.favourites !== favourites ||
|
this.filters.order !== order ||
|
this.filters.orderby !== orderby ||
|
this.filters.taxonomies !== taxonomies ){
|
updated = true;
|
}
|
(this.filters.content !== content)??this.updateContentFor(this.filters.content);
|
|
this.filters.content = content;
|
this.filters.favourites = favourites;
|
this.filters.order = order;
|
this.filters.orderby = orderby;
|
this.filters.taxonomies = taxonomies;
|
|
if(this.state.firstLoad){
|
this.updateURL();
|
}else{
|
this.state.firstLoad = true;
|
}
|
|
|
if(reset){
|
this.resetPage();
|
}
|
this.fetchFeed();
|
}
|
getCurrentContent(){
|
return this.elements.filters.querySelector('input[name="content"]:checked').value;
|
}
|
getFavouritesOnly(){
|
return this.elements.filters.querySelector('input[name="favourites_only"]')?.checked ??false;
|
}
|
getCurrentOrder(){
|
return this.elements.filters.querySelector('input[name="order"]:checked').value;
|
}
|
getCurrentOrderby(){
|
return this.elements.filters.querySelector('input[name="orderby"]:checked').value;
|
}
|
|
getCurrentTaxonomies(){
|
let taxonomies = this.filters.taxonomies;
|
let out = {};
|
if(!isEmptyObject(taxonomies)){
|
for(var [tax, terms] of Object.entries(taxonomies)){
|
if(!isEmptyObject(terms)){
|
out[tax] = Object.values(terms);
|
}
|
}
|
}
|
return out;
|
}
|
updateContentFor(content){
|
|
this.elements.filters.querySelectorAll('.jvb-selector').forEach(tax=> {
|
tax.hidden = !tax.dataset.for.includes(content);
|
//Ensure any selected Taxonomies are removed
|
if(!tax.dataset.for.includes(content)){
|
this.clearSelectedTerms(tax);
|
}
|
//TODO: Ensure clean up of filtered out window.jvbTaxonomySelector instances?
|
//Maybe cache them?
|
let taxonomy = tax.dataset.taxonomy;
|
if(tax.dataset.for.includes(content)){
|
if(!this.selectorInstances.hasOwnProperty(taxonomy)){
|
this.selectorInstances[taxonomy] = new window.jvbTaxonomySelector(tax, {
|
multiple: true,
|
feed: true,
|
selected: this.filters[taxonomy]??{},
|
onClose: () => this.setSelectedTerms(taxonomy)
|
});
|
}
|
}else if(this.selectorInstances.hasOwnProperty(taxonomy)){
|
this.clearSelectedTerms(taxonomy);
|
delete this.selectorInstances[taxonomy];
|
}
|
});
|
this.elements.filters.querySelectorAll('input[data-for]').forEach(toggle=>{
|
toggle.hidden = !toggle.dataset.for.includes(content);
|
});
|
this.elements.filters.querySelectorAll('input[name="order"]').forEach(order=>{
|
order.hidden = this.filters.orderby === 'random';
|
});
|
}
|
updateURL(){
|
const params = new URLSearchParams();
|
|
let taxonomies = this.filters.taxonomies;
|
if(!isEmptyObject(taxonomies)){
|
for(var [tax, terms] of Object.entries(taxonomies)){
|
if(!isEmptyObject(terms)){
|
params.set('f_'+tax, Object.keys(terms));
|
}
|
|
}
|
}
|
|
// Clone to avoid modifying original
|
let filters = JSON.parse(JSON.stringify(this.filters));
|
delete filters.taxonomies;
|
for(const [key, value] of Object.entries(filters)){
|
if(value !== false){
|
params.set('f_'+key, value);
|
}
|
}
|
if(this.elements.matchAll.querySelector('input:checked')){
|
params.set('f_match', 'all');
|
}
|
const newUrl = `${window.location.pathname}${params.toString() ? '?' + params.toString() : ''}`;
|
history.pushState({ filters }, '', newUrl);
|
}
|
|
/**
|
* Feed Fetching
|
* @param reset clear grid
|
* @param force force cache busting
|
* @returns {Promise<void>}
|
*/
|
async fetchFeed(reset = false, force = false){
|
if(this.state.loading){
|
return;
|
}
|
|
this.updateLoading(true);
|
try{
|
// Track URL processing
|
if(this.state.page === 1){
|
reset = true;
|
await this.loadFromURL();
|
}
|
|
const params = this.buildFilterRequest();
|
if(this.elements.matchAll.querySelector('input:checked')){
|
params.append('match', true);
|
}
|
params.append('page', this.state.page);
|
params.append('source', this.config.source);
|
params.append('context', this.config.context);
|
if(this.config.highlight){
|
params.append('highlight', JSON.stringify(this.config.highlight));
|
}
|
|
//fetch data with cache busting
|
const data = await this.cache.fetchWithCache(
|
`${this.config.api}feed?${params.toString()}`,
|
{
|
method: 'GET',
|
},
|
{
|
context: 'feed',
|
forceRefresh: true,
|
// forceRefresh: force,
|
}
|
)
|
|
console.log(data, 'Fetched data: ');
|
|
//Clear grid on first page
|
if(this.state.page === 1){
|
window.removeChildren(this.elements.grid);
|
}
|
|
//Handle empty results
|
if(!data || !data.items || data.items.length === 0){
|
if(this.state.page === 1){
|
this.showEmptyState();
|
}
|
this.state.hasMore = false;
|
}else{
|
this.state.hasMore = data.hasMore;
|
if(this.state.hasMore){
|
this.nextPage();
|
}
|
//Render items
|
this.renderItems(data.items, this.state.page > 1);
|
|
}
|
|
}catch(error){
|
this.handleError(error);
|
}finally{
|
this.updateLoading(false);
|
this.elements.loadMore.hidden = !this.state.hasMore;
|
}
|
|
}
|
|
buildFilterRequest(){
|
// Clone to avoid modifying original
|
const filters = JSON.parse(JSON.stringify(this.filters));
|
|
if(filters.taxonomies && !isEmptyObject(filters.taxonomies)){
|
let temp = {};
|
for(var [tax, terms] of Object.entries(filters.taxonomies)){
|
if(!isEmptyObject(terms)){
|
temp[tax] =Object.keys(terms);
|
}
|
}
|
delete filters.taxonomies;
|
if(!isEmptyObject(temp)){
|
filters.taxonomies = JSON.stringify(temp);
|
}
|
}else{
|
delete filters.taxonomies;
|
}
|
|
|
if(!filters.favourites){
|
delete filters.favourites;
|
}
|
return new URLSearchParams(filters);
|
}
|
|
|
handleError(error){
|
return this.error.handleApiError(
|
error,
|
{
|
component: 'Feed Block',
|
action: 'loaditems'
|
},
|
() => this.fetchFeed()
|
);
|
}
|
|
|
|
getHighlight() {
|
if(!this.config.highlight){
|
const searchParams = new URLSearchParams(window.location.search);
|
|
// Check for content type parameters
|
this.config.contentTypes.forEach(type => {
|
if (searchParams.has(type)) {
|
this.config.highlight = {};
|
this.config.highlight[type] = searchParams.get(type);
|
}
|
});
|
}
|
return this.config.highlight;
|
}
|
|
/**
|
* Loading
|
*/
|
updateLoading(loading) {
|
this.state.loading = loading;
|
if (loading) {
|
this.showLoading();
|
|
let content = this.filters.content;
|
content = (content === 'artwork') ? content : content+'s';
|
let tax = '';
|
let taxonomies = this.getCurrentTaxonomies();
|
if(!isEmptyObject(taxonomies)){
|
let total = 0;
|
total = Object.values(taxonomies).map((tax)=> total+tax.length);
|
let all = [];
|
let join = (total[0] === 2) ? ' and ' : ', ';
|
tax = Object.values(taxonomies).map((tax) => all.push(tax.join(join)));
|
tax = all.join(', ');
|
let index = tax.lastIndexOf(',')+1;
|
if(index> 0){
|
tax = tax.substr(0, index)+' and'+tax.substr(index);
|
}
|
|
}
|
|
|
this.a11y.announce(`Checking for more ${tax} ${content}...`);
|
} else {
|
this.hideLoading();
|
}
|
|
// Update loading spinner
|
this.elements.spinner.hidden = !loading;
|
|
// Update load more button
|
this.elements.loadMore.disabled = loading;
|
|
}
|
|
|
/**
|
* Show the loading overlay
|
*/
|
showLoading() {
|
this.hideBody();
|
this.elements.loading.classList.add('active');
|
this.startQuipCycle();
|
document.body.classList.add('loading');
|
}
|
|
/**
|
* Hide the loading overlay
|
*/
|
hideLoading() {
|
this.showBody();
|
this.container.classList.remove('active');
|
this.stopQuipCycle();
|
document.body.classList.remove('loading');
|
}
|
|
|
/**
|
* Start cycling through loading messages
|
*/
|
startQuipCycle() {
|
if (this.quipInterval) {
|
clearInterval(this.quipInterval);
|
}
|
|
if (!this.quips.length) return;
|
|
// Set initial message
|
this.updateMessage(this.quips[0]);
|
this.container.classList.remove('changing');
|
|
this.quipInterval = setInterval(() => {
|
this.container.classList.add('changing');
|
|
setTimeout(() => {
|
this.loadingIndex = (this.loadingIndex + 1) % this.quips.length;
|
this.updateMessage(this.quips[this.loadingIndex]);
|
|
setTimeout(() => {
|
this.container.classList.remove('changing');
|
}, 50);
|
}, 350);
|
}, this.loadingOptions.cycleInterval);
|
}
|
|
/**
|
* Stop cycling through loading messages
|
*/
|
stopQuipCycle() {
|
if (this.quipInterval) {
|
clearInterval(this.quipInterval);
|
this.quipInterval = null;
|
}
|
}
|
|
/**
|
* Update the loading message
|
*/
|
updateMessage(quipData) {
|
if (!this.loadingMessage) return;
|
|
const icon = feedSettings?.icons?.[quipData.icon] || '';
|
this.loadingMessage.innerHTML = `${icon}<p>${quipData.quip}</p>`;
|
}
|
|
/**
|
* Set a custom loading message
|
*/
|
setMessage(message, icon = null) {
|
if (!this.loadingMessage) return;
|
|
this.stopQuipCycle();
|
|
const iconHtml = icon ? feedSettings?.icons?.[icon] || '' : '';
|
this.loadingMessage.innerHTML = `${iconHtml}<p>${message}</p>`;
|
}
|
|
/**
|
* Shuffle an array (Fisher-Yates algorithm)
|
*/
|
shuffleArray(array) {
|
const newArray = [...array];
|
for (let i = newArray.length - 1; i > 0; i--) {
|
const j = Math.floor(Math.random() * (i + 1));
|
[newArray[i], newArray[j]] = [newArray[j], newArray[i]];
|
}
|
return newArray;
|
}
|
|
/**
|
* Initialize quips for loading messages
|
*/
|
initializeQuips() {
|
// Map pstyle, artstyle, etc. to their base types for quips
|
const typeMapping = {
|
'pstyle': 'style',
|
'artstyle': 'style',
|
'arttheme': 'theme',
|
'artmedium': 'style',
|
'artform': 'style',
|
'placement': 'style',
|
'colour': 'style'
|
};
|
|
// Gather all quips based on content types and taxonomies
|
const allQuips = [];
|
|
// Add content type quips
|
if (FeedBlock.LOADING_QUIPS[this.filters.content]) {
|
FeedBlock.LOADING_QUIPS[this.filters.content].forEach(quip => {
|
allQuips.push({
|
icon: this.filters.content,
|
quip: quip
|
});
|
});
|
}
|
|
// Add taxonomy quips
|
if(this.filters.taxonomies && !isEmptyObject(this.filters.taxonomies)){
|
Object.keys(this.filters.taxonomies).forEach(taxonomy => {
|
const baseType = typeMapping[taxonomy] || taxonomy;
|
if (FeedBlock.LOADING_QUIPS[baseType]) {
|
FeedBlock.LOADING_QUIPS[baseType].forEach(quip => {
|
allQuips.push({
|
icon: taxonomy,
|
quip: quip
|
});
|
});
|
}
|
});
|
}
|
|
|
// Shuffle the quips array
|
return this.shuffleArray(allQuips);
|
}
|
|
/**
|
* Feed Grid
|
*/
|
|
renderItems(items, append = false) {
|
// Reset if not appending
|
if (!append) {
|
window.removeChildren(this.elements.grid);
|
this.feed.loaded = 0;
|
this.feed.gallery = [];
|
}
|
|
// Add items to gallery if in gallery mode
|
if (this.config.isGallery) {
|
this.feed.gallery = this.feed.gallery.concat(items);
|
}
|
|
// Bail early if no items
|
if (items.length === 0) {
|
this.a11y.announceUpdate(0, append);
|
return;
|
}
|
|
// Use DocumentFragment for better performance
|
const fragment = document.createDocumentFragment();
|
|
// Process items in batches for better performance
|
const batchSize = 10;
|
const processBatch = (startIndex) => {
|
const endIndex = Math.min(startIndex + batchSize, items.length);
|
|
// Process this batch
|
for (let i = startIndex; i < endIndex; i++) {
|
const item = items[i];
|
const element = this.createItemElement(item);
|
fragment.appendChild(element);
|
|
// Lazy load images beyond threshold
|
if (this.feed.loaded >= this.feed.imageLoadThreshold && this.imageObserver) {
|
this.imageObserver.observe(element);
|
} else {
|
this.loadImage(element);
|
}
|
|
this.feed.loaded++;
|
}
|
|
// If we have more items, process next batch in next frame
|
if (endIndex < items.length) {
|
requestAnimationFrame(() => {
|
processBatch(endIndex);
|
});
|
} else {
|
// All batches processed, append fragment
|
this.elements.grid.appendChild(fragment);
|
if(this.container.dataset.gallery){
|
console.log(this.getGalleryItems());
|
this.updateGalleryItems(this.getGalleryItems());
|
//pagination already updated, so it'll be page 2
|
if(this.getHighlight() && !this.highlightGot){
|
this.highlightGot = true;
|
this.openGallery(0);
|
}
|
}
|
this.a11y.makeNavigable(this.elements.grid.querySelectorAll('.feed-item:not([data-keyboard-nav])'));
|
this.a11y.announceItems(items.length, append, this.state.hasMore);
|
}
|
};
|
|
// Start processing the first batch
|
if (items.length > 0) {
|
processBatch(0);
|
} else {
|
this.a11y.announceUpdate(0, append);
|
}
|
}
|
|
/**
|
* Load image for an item
|
*/
|
loadImage(element) {
|
const img = element.querySelector('img');
|
if (!img) return;
|
const size = this.getImageSize();
|
img.src = img.dataset[size] || img.dataset.src;
|
delete img.dataset.src;
|
|
element.setAttribute('data-loaded', 'true');
|
}
|
|
/**
|
* Update image sizes based on screen width
|
*/
|
updateImageSizes() {
|
const size = this.getImageSize();
|
|
// Update only visible images that aren't already loaded with the right size
|
const items = this.elements.grid.querySelectorAll('.feed-item[data-loaded="true"]');
|
items.forEach(item => {
|
const img = item.querySelector('img');
|
if (img && img.dataset[size] && img.src !== img.dataset[size]) {
|
img.src = img.dataset[size];
|
}
|
});
|
}
|
|
/**
|
* Create an element for a feed item
|
*/
|
createItemElement(item) {
|
|
if(!this.rendered[item.icon]){
|
this.rendered[item.icon] = new Map();
|
}
|
|
if(this.rendered[item.icon].has(item.id)){
|
return this.rendered[item.icon].get(item.id);
|
}
|
|
const favourited = window.isFavourited(item.icon, item.id)??false;
|
const template = window.getTemplate('feed-item');
|
|
// Set unique attributes
|
template.id = `${item.icon}-${item.id}`;
|
template.classList.add(item.icon);
|
|
if (item.umami_view) {
|
this.buildUmamiData(template, item.umami_view);
|
}
|
|
let favouriteButton = template.querySelector('button.favourite');
|
[
|
favouriteButton.dataset.id,
|
favouriteButton.dataset.type,
|
favouriteButton.dataset.artist,
|
favouriteButton.title
|
] = [
|
item.id,
|
item.icon,
|
item.user_id,
|
(favourited) ? 'Remove from Favourites' : 'Add to Favourites'
|
];
|
|
let order = item.order;
|
let single = template.querySelector('.item');
|
let list = template.querySelector('.item-list');
|
let img = template.querySelector('.feed-images');
|
let summary = template.querySelector('summary');
|
let info = template.querySelector('.item-info');
|
|
for (let [index, id] of Object.entries(order)){
|
let target;
|
let config = item[id];
|
if (id === 'title') {
|
target = template.querySelector('h3 a');
|
if (item.title !== '') {
|
[
|
target.textContent,
|
target.href,
|
target.url
|
] = [
|
item.title,
|
item.url,
|
`Learn more about this ${item.icon}`
|
];
|
if (item.icon !== '') {
|
target.closest('h3').prepend(window.getIcon(item.icon));
|
}
|
if (item.umami_click) {
|
this.buildUmamiData(target, item.umami_click);
|
}
|
} else {
|
target.remove();
|
}
|
} else if (Object.hasOwn(config, 'terms')) {
|
//Taxonomy list
|
if (config.terms.length === 0) {
|
continue;
|
}
|
let taxonomy = list.cloneNode(true);
|
let label = taxonomy.querySelector('.label');
|
let termList = taxonomy.querySelector('ul');
|
let listItem = taxonomy.querySelector('li');
|
|
if (config.label) {
|
label.textContent = config.label;
|
}
|
if (config.icon) {
|
label.prepend(window.getIcon(config.icon));
|
}
|
if (!config.label && !config.icon){
|
label.remove();
|
}
|
|
config.terms.forEach(term => {
|
let termItem = listItem.cloneNode(true);
|
let link = termItem.querySelector('a');
|
[
|
link.href,
|
link.title,
|
link.textContent
|
] = [
|
term.url,
|
`Learn more about ${term.title}`,
|
term.title
|
];
|
if (term.umami_click.length > 0) {
|
this.buildUmamiData(link, term.umami_click);
|
}
|
termList.append(termItem);
|
});
|
|
listItem.remove();
|
info.appendChild(taxonomy);
|
} else if (Object.hasOwn(config, 'value') && config.value !== '') {
|
let itemInfo = single.cloneNode(true);
|
let label = itemInfo.querySelector('.label');
|
let link = itemInfo.querySelector('a');
|
let p = itemInfo.querySelector('p');
|
if (Object.hasOwn(config, 'label')) {
|
label.textContent = config.label;
|
}
|
if (Object.hasOwn(config, 'icon')) {
|
label.prepend(window.getIcon(config.icon));
|
}
|
if (!Object.hasOwn(config, 'icon') && !Object.hasOwn(config, 'label')) {
|
label.remove();
|
}
|
if (Object.hasOwn(config, 'url')) {
|
p.remove();
|
[
|
link.textContent,
|
link.href,
|
link.title
|
] = [
|
config.value,
|
config.url,
|
`Learn more about ${config.value}`
|
];
|
} else {
|
link.remove();
|
p.textContent = config.value;
|
}
|
info.appendChild(itemInfo);
|
} else if (id === 'image') {
|
let images = summary.querySelector('.feed-images');
|
let img = images.querySelector('a');
|
|
let main = img.cloneNode(true);
|
main.href = item.url;
|
main.classList.add('feed-image');
|
this.buildImageData(main.querySelector('img'), item.image);
|
images.append(main);
|
|
if (item.content?.length > 0) {
|
images.classList.add('multi');
|
item.content.forEach(c => {
|
let image = img.cloneNode(true);
|
image.href = c.url;
|
let itemImg = image.querySelector('img');
|
itemImg.src = c.image.small;
|
itemImg.alt = c.image.alt;
|
images.append(image);
|
});
|
}
|
img.remove();
|
}
|
}
|
single.remove();
|
list.remove();
|
|
this.rendered[item.icon].set(item.id, template);
|
|
return template;
|
}
|
|
buildImageData(img, data){
|
if (typeof data.tiny !== 'string') {
|
return;
|
}
|
[
|
img.src,
|
img.dataset.small,
|
img.dataset.medium,
|
img.dataset.large,
|
img.alt
|
] =
|
[
|
data.tiny,
|
data.small,
|
data.medium,
|
data.large,
|
data.alt
|
];
|
}
|
|
buildUmamiData(item, data){
|
for(let [key, value] of Object.entries(data)){
|
item.dataset[key] = value;
|
}
|
}
|
|
/**
|
* Show empty state
|
*/
|
showEmptyState() {
|
const message = this.filters.favourites
|
? `<div class="feed-empty-state">
|
<h3>♡ BLANK CANVAS ♡</h3>
|
<p>You haven't fallen in love with any pieces... yet!</p>
|
<p>Hit that heart icon when something stops your scroll.</p>
|
<p>Your dream collection is waiting to start.</p>
|
</div>`
|
: `<div class="feed-empty-state">
|
<h3>NOTHING HERE...</h3>
|
<p>Try tweaking those filters.</p>
|
<p>Edmonton's got talent - let's find it.</p>
|
</div>`;
|
|
this.elements.grid.innerHTML = message;
|
this.a11y.announceEmpty(this.filters.favourites);
|
}
|
|
/**
|
* Clear the grid
|
*/
|
clearGrid() {
|
this.a11y.announce('Items cleared.');
|
window.removeChildren(this.elements.grid);
|
this.feed.loaded = 0;
|
}
|
|
/**
|
* Get image size based on screen width
|
*/
|
getImageSize() {
|
const width = window.innerWidth;
|
if (width > 1024) return 'medium';
|
if (width > 500) return 'medium';
|
return 'small';
|
}
|
|
/**
|
* Get gallery items for the gallery modal
|
*/
|
getGalleryItems() {
|
return Array.from(this.container.querySelectorAll('.feed-item'))
|
.map(item => {
|
const img = item.querySelector('img');
|
if (!img) return null;
|
|
return {
|
id: item.querySelector('button.favourite').dataset.id,
|
small: img.dataset.small || img.src,
|
large: img.dataset.medium || img.src,
|
full: img.dataset.full || img.src,
|
alt: img.alt || '',
|
fav: item.querySelector('button.favourite')?.cloneNode(true),
|
info: item.querySelector('.item-info')?.cloneNode(true)
|
};
|
})
|
.filter(Boolean);
|
}
|
|
/**
|
* Clean up resources when component is destroyed
|
*/
|
addEvent(element, event, handler, options) {
|
if (!element) return;
|
|
const boundHandler = handler.bind(this);
|
element.addEventListener(event, boundHandler, options);
|
|
// Store for cleanup
|
if (!this.eventHandlers.has(element)) {
|
this.eventHandlers.set(element, []);
|
}
|
this.eventHandlers.get(element).push({ event, handler: boundHandler });
|
}
|
destroy() {
|
// Clean up observers
|
if (this.imageObserver) {
|
this.imageObserver.disconnect();
|
this.imageObserver = null;
|
}
|
|
if (this.resizeObserver) {
|
this.resizeObserver.disconnect();
|
this.resizeObserver = null;
|
}
|
|
// Clean up all event listeners
|
this.eventHandlers.forEach((handlers, element) => {
|
handlers.forEach(({ event, handler }) => {
|
element.removeEventListener(event, handler);
|
});
|
});
|
this.eventHandlers.clear();
|
|
// Clean up timers
|
if (this.quipInterval) {
|
clearInterval(this.quipInterval);
|
}
|
|
if (this.timeoutId) {
|
clearTimeout(this.timeoutId);
|
}
|
|
// Clear template cache and other state
|
this.feed.templates.clear();
|
this.feed.gallery = [];
|
this.feed.loaded = 0;
|
}
|
/** Extra Term Handling **/
|
|
getSelectedTaxonomies(){
|
let taxonomies = {};
|
for(var [taxonomy, instance] of Object.entries(this.selectorInstances)){
|
taxonomies[taxonomy] = instance.selectedItems;
|
}
|
return taxonomies;
|
}
|
/**
|
* Get selected values for a taxonomy
|
*/
|
getSelectedTerms(taxonomy) {
|
const selectedItems = this.elements.selected.querySelectorAll(
|
`.selected-item[data-taxonomy="${taxonomy}"]`
|
);
|
|
return Array.from(selectedItems).map(item => item.dataset.id);
|
}
|
|
clearSelectedTaxonomies(){
|
window.removeChildren(this.elements.selected);
|
if(!isEmptyObject(this.selectorInstances)){
|
for(var [taxonomy, instance] of Object.entries(this.selectorInstances)){
|
instance.selectedItems = {};
|
}
|
}
|
|
this.elements.matchAll.querySelector(input).checked = false;
|
|
this.filters.taxonomies = {};
|
this.updateFilters();
|
}
|
setSelectedTerms(taxonomy){
|
if(this.selectorInstances[taxonomy]){
|
let selected = this.selectorInstances[taxonomy].selectedItems;
|
if(!isEmptyObject(selected)){
|
|
this.filters.taxonomies[taxonomy] = selected;
|
for(var [id, name] of Object.entries(selected)){
|
this.elements.selected.appendChild(this.createFilterTag(taxonomy, id, name));
|
}
|
this.updateFilters();
|
this.updateSelectedListeners();
|
}else{
|
delete this.filters.taxonomies[taxonomy];
|
}
|
|
}
|
}
|
clearSelectedTerm(termId, taxonomy){
|
let container = this.container.querySelector('.filters');
|
let input = container.querySelector(`li[data-id="${termId}"] input`);
|
input.checked = false
|
delete this.selectorInstances[taxonomy].selectedItems[termId];
|
}
|
clearSelectedTerms(taxonomy){
|
|
if(!isEmptyObject(this.filters.taxonomies) && this.filters.taxonomies.hasOwnProperty(taxonomy)){
|
delete this.filters.taxonomies[taxonomy];
|
}
|
if(!isEmptyObject(this.selectorInstances) && this.selectorInstances.hasOwnProperty(taxonomy)){
|
this.selectorInstances[taxonomy].selectedItems = {};
|
}
|
|
|
const selectedItems = this.elements.selected.querySelectorAll(
|
`.selected-item[data-taxonomy="${taxonomy}"]`
|
);
|
|
if(selectedItems.length > 0){
|
selectedItems.forEach(item => {
|
item.remove();
|
});
|
}
|
|
// Update clear filters button visibility
|
this.updateClearFiltersButton();
|
}
|
updateClearFiltersButton(){
|
if (!this.elements.clearFilters) return;
|
|
let filters = this.elements.selected.children.length;
|
|
const hasFilters = filters > 0;
|
|
const hasMultiple = filters > 1;
|
|
this.elements.clearFilters.hidden = !hasFilters;
|
|
this.elements.filters.classList.toggle('has-filters', hasFilters);
|
|
this.elements.matchAll.hidden = !hasMultiple;
|
}
|
|
|
/**
|
* Create a filter tag element
|
*/
|
createFilterTag(taxonomy, id, name) {
|
const tag = window.getTemplate('selectedTerm');
|
|
tag.dataset.taxonomy = taxonomy;
|
tag.dataset.id = id;
|
let icon = window.getIcon(taxonomy);
|
let span = tag.querySelector('span');
|
let button = tag.querySelector('button');
|
tag.prepend(icon);
|
span.classList.add('filter-name');
|
span.classList.remove('item-name');
|
[span.textContent, button.remove] =
|
[escapeHtml(name), `Remove ${escapeHtml(name)}`];
|
|
|
|
return tag;
|
}
|
|
/**
|
* Gallery
|
**/
|
openGallery(index){
|
this.gallery.index = index;
|
this.gallery.modal.showModal();
|
this.hideBody();
|
|
this.bindGalleryEvents();
|
//show current image
|
this.updateDisplay(index);
|
//preload adjacent images
|
this.preloadImages();
|
|
// Announce initial state
|
this.a11y.announce(`Image ${this.gallery.index + 1} of ${this.gallery.items.length}. Use arrow keys to navigate.`)
|
this.a11y.trapFocus(this.gallery.modal);
|
|
}
|
|
/**
|
* Create the modal element
|
*/
|
createGalleryModal() {
|
const modal = document.createElement('dialog');
|
modal.className = 'gallery-modal';
|
modal.setAttribute('aria-modal', 'true');
|
modal.setAttribute('aria-label', 'Image Gallery');
|
|
modal.innerHTML = `
|
<button class="gallery-close" aria-label="Close gallery">
|
${jvbSettings.icons.close}
|
</button>
|
|
<button class="gallery-nav gallery-prev" aria-label="Previous image">
|
${jvbSettings.icons.prev}
|
</button>
|
|
<button class="gallery-nav gallery-next" aria-label="Next image">
|
${jvbSettings.icons.next}
|
</button>
|
|
<div class="gallery-content">
|
<img src="" alt="" class="gallery-image">
|
<details>
|
<summary>DETAILS</summary>
|
<div class="item-info"></div>
|
</details>
|
</div>
|
|
<div class="gallery-favourite"></div>
|
<div class="gallery-counter"><span id="gallery-index">1</span> / <span class="total"></span></div>
|
`;
|
|
return modal;
|
}
|
|
|
/**
|
* Bind event handlers
|
*/
|
bindGalleryEvents() {
|
// Close button
|
this.gallery.modal.querySelector('.gallery-close').addEventListener('click', () => this.closeGallery());
|
|
// Navigation buttons
|
const prevBtn = this.gallery.modal.querySelector('.gallery-prev');
|
const nextBtn = this.gallery.modal.querySelector('.gallery-next');
|
|
prevBtn.addEventListener('click', () => this.navigate(-1));
|
nextBtn.addEventListener('click', () => this.navigate(1));
|
|
// Keyboard navigation
|
this.gallery.keyHandler = (e) => {
|
switch (e.key) {
|
case 'ArrowLeft':
|
this.navigate(-1);
|
break;
|
case 'ArrowRight':
|
this.navigate(1);
|
break;
|
case 'Escape':
|
this.closeGallery();
|
break;
|
}
|
};
|
document.addEventListener('keydown', this.gallery.keyHandler);
|
|
// Touch events
|
this.gallery.modal.addEventListener('touchstart', (e) => {
|
this.gallery.touchStart = e.touches[0].clientX;
|
});
|
|
this.gallery.modal.addEventListener('touchmove', (e) => {
|
this.gallery.touchEnd = e.touches[0].clientX;
|
});
|
|
this.gallery.modal.addEventListener('touchend', () => {
|
if (!this.gallery.touchStart || !this.gallery.touchEnd) return;
|
|
const distance = this.gallery.touchStart - this.gallery.touchEnd;
|
const isLeftSwipe = distance > this.gallery.minSwipe;
|
const isRightSwipe = distance < -this.gallery.minSwipe;
|
|
if (isLeftSwipe) {
|
this.navigate(1);
|
} else if (isRightSwipe) {
|
this.navigate(-1);
|
}
|
|
this.gallery.touchStart = null;
|
this.gallery.touchEnd = null;
|
});
|
}
|
|
/**
|
* Navigate to previous/next image
|
*/
|
async navigate(direction) {
|
const newIndex = this.gallery.index + direction;
|
|
// Check if out of bounds
|
if (newIndex < 0 || newIndex >= this.gallery.items.length) {
|
this.a11y.announceNavigation(newIndex, this.gallery.items.length,direction < 0, direction > 0)
|
return;
|
}
|
|
// Update current index
|
this.gallery.index = newIndex;
|
|
// Update display
|
this.updateDisplay(newIndex);
|
|
// Preload adjacent images
|
this.preloadImages();
|
|
// Announce to screen readers
|
this.a11y.announceNavigation(this.gallery.index,this.gallery.items.length);
|
|
//Load more if we're near the end
|
if (direction > 0 && newIndex >= (this.gallery.items.length - 3) && this.state.hasMore) {
|
await this.fetchFeed();
|
this.updateGalleryItems(this.getGalleryItems());
|
}
|
}
|
|
/**
|
* Preload adjacent images
|
*/
|
preloadImages() {
|
// Preload current, previous and next images
|
[-1, 0, 1].forEach(offset => {
|
const index = this.gallery.index + offset;
|
if (index >= 0 && index < this.gallery.items.length) {
|
const img = new Image();
|
const item = this.gallery.items[index];
|
|
if (window.innerWidth < 1000) {
|
img.src = item.large || item.src;
|
} else {
|
img.src = item.full || item.src;
|
}
|
}
|
});
|
}
|
|
/**
|
* Update display with current image
|
*/
|
updateDisplay(index) {
|
const item = this.gallery.items[index];
|
if (!item) return;
|
|
// Get elements
|
const favourite = this.gallery.modal.querySelector('.gallery-favourite');
|
const image = this.gallery.modal.querySelector('.gallery-image');
|
const counter = this.gallery.modal.querySelector('.gallery-counter');
|
const info = this.gallery.modal.querySelector('.item-info');
|
|
// Update image
|
image.src = window.innerWidth < 1000 ?
|
(item.large || item.src) :
|
(item.full || item.src);
|
|
image.alt = item.alt || '';
|
|
// Update favourite button
|
if (favourite && item.fav) {
|
window.removeChildren(favourite);
|
favourite.appendChild(item.fav.cloneNode(true));
|
}
|
|
// Update info
|
if (info && item.info) {
|
window.removeChildren(info);
|
const clone = item.info.cloneNode(true);
|
info.appendChild(clone);
|
}
|
|
// Update counter
|
counter.textContent = `${this.gallery.index + 1} / ${this.gallery.items.length}`;
|
|
// Update navigation buttons
|
this.updateNavigationButtons();
|
}
|
|
/**
|
* Update navigation button visibility
|
*/
|
updateNavigationButtons() {
|
const prevBtn = this.gallery.modal.querySelector('.gallery-prev');
|
const nextBtn = this.gallery.modal.querySelector('.gallery-next');
|
|
prevBtn.classList.toggle('end', this.gallery.index > 0 ? '' : 'none');
|
nextBtn.classList.toggle('end', this.gallery.index < this.gallery.items.length - 1 ? '' : 'none');
|
}
|
|
/**
|
* Close the gallery
|
*/
|
closeGallery() {
|
this.showBody();
|
// Remove event listeners
|
document.removeEventListener('keydown', this.gallery.keyHandler);
|
this.a11y.announce('Gallery closed.');
|
|
this.gallery.modal.close();
|
|
// Reset state
|
this.gallery.keyHandler = null;
|
}
|
|
/**
|
* Update gallery items
|
* @param {Array} newItems - New gallery items
|
*/
|
updateGalleryItems(newItems) {
|
// Store original current index and item
|
const currentItem = this.gallery.items[this.gallery.index];
|
|
// Update items array
|
this.gallery.items = newItems;
|
|
// Try to keep the same item selected
|
if (currentItem) {
|
// Find the same item in the new array by matching source
|
const newIndex = this.gallery.items.findIndex(item =>
|
item.full === currentItem.full ||
|
item.large === currentItem.large
|
);
|
|
if (newIndex !== -1) {
|
this.gallery.index = newIndex;
|
}
|
}
|
|
// Update navigation buttons
|
this.updateNavigationButtons();
|
}
|
|
/**
|
* Ensure gallery is accessible
|
*/
|
setupGalleryAccessibility() {
|
// Add ARIA attributes
|
this.gallery.modal.setAttribute('aria-modal', 'true');
|
this.gallery.modal.setAttribute('aria-label', 'Image Gallery');
|
}
|
|
hideBody(){
|
document.body.style.overflow = 'hidden';
|
}
|
showBody(){
|
document.body.style.overflow = '';
|
}
|
}
|
|
// Initialize feed blocks when DOM is loaded
|
document.addEventListener('DOMContentLoaded', () => {
|
document.querySelectorAll('.feed-block').forEach(container => {
|
// Initialize with both the container and overlay
|
window.feedBlock = new FeedBlock(container);
|
});
|
});
|
|
|
function isEmptyObject(obj) {
|
return Object.keys(obj).length === 0;
|
}
|