class FeedBlock {
|
constructor() {
|
this.container = document.querySelector('section.feed-block');
|
if (!this.container) {
|
return;
|
}
|
|
this.a11y = window.jvbA11y;
|
this.cache = new window.jvbCache('feed');
|
this.error = window.jvbError;
|
|
this.config = {
|
source: '',
|
context: '',
|
highlight: null,
|
gallery: false,
|
view: this.cache.get('feedView') || 'grid',
|
... this.container.dataset
|
};
|
this.initElements();
|
this.initFilters();
|
|
|
this.loadWhenAble();
|
}
|
|
loadWhenAble() {
|
if ('requestIdleCallback' in window) {
|
requestIdleCallback(() => {
|
this.initTaxonomies();
|
this.initStore();
|
this.initListeners();
|
this.initGallery();
|
}, { timeout: 2000 });
|
} else {
|
setTimeout(() => {
|
this.initTaxonomies();
|
this.initStore();
|
this.initListeners();
|
this.initGallery();
|
}, 100);
|
}
|
}
|
|
initElements() {
|
this.currentTaxonomies = new Set(); // Allowed Taxonomies, grabbed from active buttons
|
this.taxonomyFilters = {};
|
this.elements = {
|
filterTrigger: '[data-filter]',
|
filters: {
|
content: '[data-filter="content"]',
|
orderby: '[data-filter="orderby"]',
|
order: '[data-filter="order"]',
|
match: '[data-filter="match"]',
|
favourites: '[data-filter="favourites"]',
|
taxonomy: '[data-filter^="taxonomy"]'
|
},
|
selectedTax: '.selected-items',
|
clearFilter: 'button.clear-filters',
|
loadMore: 'button.load-more',
|
filterContainer: '.filters',
|
grid: '.item-grid',
|
};
|
this.ui = window.uiFromSelectors(this.elements);
|
|
this.ui.content = this.ui.filterContainer.querySelectorAll('[name="content"]');
|
this.ui.taxonomies = this.ui.filterContainer.querySelectorAll('[data-taxonomy]');
|
if (this.ui.content.length > 0) {
|
this.contentTypes = Array.from(
|
this.ui.content
|
).map(content => content.value);
|
} else {
|
this.contentTypes = [this.container.dataset['content']];
|
}
|
if (this.ui.taxonomies.length>0) {
|
this.taxonomies = Array.from(
|
this.ui.taxonomies,
|
).map(content => content.dataset.taxonomy);
|
} else {
|
this.taxonomies = [];
|
}
|
}
|
|
async initTaxonomies() {
|
this.selector = window.jvbSelector;
|
const buttons = document.querySelectorAll('[data-filter="taxonomy"]');
|
|
this.selector.isInitializing = true;
|
buttons.forEach((button) => {
|
const taxonomy = button.dataset.taxonomy;
|
this.currentTaxonomies.add(taxonomy);
|
|
this.selector.registerFilterButton(button, {
|
button: button,
|
buttonSelector: '[data-filter="taxonomy"]',
|
selected: this.ui.selectedTax
|
});
|
|
// Add preload listeners
|
this.addTaxonomyPreloadListeners(button, taxonomy);
|
});
|
|
this.selector.isInitializing = false;
|
|
this.selector.subscribe((event, data) => {
|
if (event === 'selected-terms') this.handleTaxonomyChange(data);
|
});
|
}
|
|
addTaxonomyPreloadListeners(button, taxonomy) {
|
const preload = () => {
|
this.selector.preloadTaxonomy(taxonomy);
|
};
|
|
// Desktop hover
|
button.addEventListener('mouseenter', preload, { once: true });
|
|
// Touch/keyboard (fires before click)
|
button.addEventListener('pointerdown', preload, { once: true });
|
|
// Keyboard focus
|
button.addEventListener('focus', preload, { once: true });
|
}
|
|
handleTaxonomyChange(data) {
|
const { terms, taxonomy } = data;
|
|
// Update only the current taxonomy's terms
|
if (terms.size > 0) {
|
this.taxonomyFilters[taxonomy] = Array.from(terms.keys());
|
} else {
|
// Remove taxonomy if no terms selected
|
delete this.taxonomyFilters[taxonomy];
|
}
|
|
// Build filters object with all taxonomies
|
let filters = {
|
page: 1
|
};
|
|
// Add taxonomy filters if any exist
|
if (Object.keys(this.taxonomyFilters).length > 0) {
|
filters.taxonomy = this.taxonomyFilters;
|
}
|
|
this.updateFilter(filters);
|
}
|
|
clearAllTaxonomies() {
|
this.taxonomyFilters = {};
|
window.removeChildren(this.ui.selectedTax);
|
|
this.updateFilter({
|
taxonomy: null,
|
page: 1
|
});
|
}
|
|
initFilters() {
|
//defaults
|
this.filters = {
|
content: this.contentTypes[0],
|
orderby: 'date',
|
order: 'desc',
|
page: 1
|
};
|
if (this.config.context) this.filters.context = this.config.context;
|
if (this.config.source) this.filters.source = this.config.source;
|
|
//check the cache
|
this.processCachedFilters();
|
//check url
|
this.processURLFilters();
|
|
// Set initial UI state
|
this.syncUIToFilters();
|
}
|
syncUIToFilters() {
|
// Check radio buttons
|
Object.entries(this.filters).forEach(([key, value]) => {
|
const input = this.ui.filterContainer.querySelector(`[data-filter="${key}"][value="${value}"]`);
|
if (input) {
|
input.checked = true;
|
}
|
});
|
|
// Update content-specific visibility
|
this.updateContentFor(this.filters.content);
|
}
|
nextPage() {
|
this.store.setFilter('page', this.store.filters.page++);
|
}
|
|
initStore() {
|
const store = window.jvbStore.register(
|
'feed',
|
{
|
storeName: 'feed',
|
endpoint: 'feed',
|
keyPath: 'id',
|
indexes: [
|
{ name: 'content', keyPath: 'content'},
|
{ name: 'taxonomy', keyPath: 'taxonomy'},
|
{ name: 'user', keyPath: 'user'},
|
{ name: 'date', keyPath: 'modified'},
|
{ name: 'title', keyPath: 'title'}
|
],
|
filters: this.filters,
|
TTL: 6 * 60 * 60 * 1000,
|
showLoading: true,
|
required: 'content',
|
delayFetch: true
|
}
|
);
|
this.store = store.feed;
|
|
this.store.subscribe((event, data) => {
|
switch (event) {
|
case 'data-loaded':
|
this.renderItems();
|
this.ui.loadMore.hidden = true;
|
if (this.store.lastResponse && this.store.lastResponse['has_more']) {
|
this.ui.loadMore.hidden = !this.store.lastResponse['has_more'];
|
}
|
break;
|
}
|
});
|
}
|
|
initGallery() {
|
this.gallery = (this.config.gallery) ? window.jvbGallery : false;
|
if (this.gallery) {
|
this.gallery.subscribe((event, data) => {
|
if (event === 'load-more' && this.store.lastResponse) {
|
if (this.store.lastResponse['has_more']) {
|
this.nextPage();
|
}
|
}
|
});
|
}
|
}
|
|
processCachedFilters() {
|
Object.keys(this.filters).forEach(filter => {
|
let cached = this.cache.get(`${this.config.source}_${this.config.context}_${filter}`);
|
if (cached && cached !== this.filters[filter]){
|
this.filters[filter] = cached;
|
}
|
});
|
}
|
|
processURLFilters() {
|
if (this.filters.page > 1) {
|
return false;
|
}
|
const params = new URLSearchParams(window.location.search);
|
|
if (!params.toString()) {
|
return false;
|
}
|
let filters = ['content', 'order', 'orderby', 'favourites', 'match'];
|
filters.forEach(filter => {
|
let value = params.get(`f_${filter}`);
|
if (value) {
|
this.filters[filter] = value;
|
let input = this.ui.filters[filter];
|
if (input) {
|
input.checked = true;
|
}
|
}
|
});
|
|
let hasTaxonomy = false;
|
// Load taxonomy filters from URL
|
params.forEach((value, key) => {
|
if (key.startsWith('f_tax_')) {
|
hasTaxonomy = true;
|
const taxonomy = key.replace('f_tax_', '');
|
if (!this.taxonomyFilters[taxonomy]) {
|
this.taxonomyFilters[taxonomy] = [];
|
}
|
this.taxonomyFilters[taxonomy] = value.split(',').map(Number);
|
}
|
});
|
if (hasTaxonomy) {
|
for (let [tax, ids] in Object.entries(this.taxonomyFilters)) {
|
let button = this.ui.filterContainer.querySelector(`[data-taxonomy="${tax}"]`);
|
if (button) {
|
if (button.dataset.fieldId) {
|
let field = this.selector.get(button.dataset.fieldId);
|
field.selectedTerms = new Set(ids);
|
this.selector.initFieldDisplay(button.dataset.fieldId);
|
} else {
|
this.selector.registerField(button, {
|
button: button,
|
buttonSelector: '[data-filter="taxonomy"]',
|
selected: this.ui.selectedTax,
|
selectedItems: ids
|
});
|
}
|
}
|
}
|
}
|
return true;
|
}
|
|
/**
|
* Update URL with current filters (for sharing/bookmarking)
|
*/
|
updateURL() {
|
const params = new URLSearchParams();
|
|
// Add simple filters
|
['content', 'order', 'orderby', 'match'].forEach(key => {
|
if (this.filters[key]) {
|
params.set(`f_${key}`, this.filters[key]);
|
}
|
});
|
|
// Add taxonomy filters
|
Object.entries(this.taxonomyFilters).forEach(([taxonomy, terms]) => {
|
if (terms.length > 0) {
|
params.set(`f_tax_${taxonomy}`, terms.join(','));
|
}
|
});
|
|
// Update URL without reload
|
const newURL = `${window.location.pathname}${params.toString() ? '?' + params.toString() : ''}`;
|
window.history.pushState({ filters: this.filters }, '', newURL);
|
}
|
|
renderItems() {
|
let items = this.store.getFiltered();
|
if (this.store.filters['page'] === 1) {
|
window.removeChildren(this.ui.grid);
|
}
|
|
if (items.length === 0) {
|
this.a11y.announceItems(0, this.store.filters['page'] > 0);
|
return;
|
}
|
|
const fragment = document.createDocumentFragment();
|
const batchSize = 10;
|
|
const processBatch = (startIndex) => {
|
const endIndex = Math.min(startIndex + batchSize, items.length);
|
|
for (let i = startIndex; i < endIndex; i++) {
|
const item = items[i];
|
const element = this.createItemElement(item);
|
|
fragment.appendChild(element);
|
}
|
|
if (endIndex < items.length) {
|
requestAnimationFrame(() => processBatch(endIndex));
|
} else {
|
this.removePlaceholders();
|
this.ui.grid.append(fragment);
|
|
// Observe images after adding to DOM
|
this.observeImages(this.ui.grid);
|
|
if (this.config.gallery) {
|
this.gallery.updateGalleryItems(this.gallery.getGalleryItems());
|
}
|
|
this.a11y.makeNavigable(this.ui.grid.querySelectorAll('.item:not([data-keyboard-nav])'));
|
this.a11y.announceItems(items.length, this.store.filters['page'] > 1, this.store.hasMore);
|
}
|
};
|
|
if (items.length > 0) {
|
processBatch(0);
|
} else {
|
this.a11y.announceItems(0, this.store.filters['page'] >1, false);
|
}
|
|
this.ui.filters.match.hidden = window.isEmptyObject(this.taxonomyFilters);
|
this.ui.clearFilter.hidden = window.isEmptyObject(this.taxonomyFilters);
|
}
|
|
/**
|
*
|
* @param {object} item
|
*/
|
createItemElement(item) {
|
let template = window.getTemplate('feed-item');
|
if (Object.hasOwn(template.dataset, 'timeline')) {
|
return this.createTimelineElement(item, template);
|
}
|
return template;
|
}
|
|
createTimelineElement(item, template) {
|
let [
|
main,
|
link,
|
beforeImg,
|
afterImg,
|
afterText,
|
started,
|
lastTreated,
|
total,
|
termList,
|
timeline
|
] = [
|
template,
|
template.querySelector('a'),
|
template.querySelector('img.before'),
|
template.querySelector('img.after'),
|
template.querySelector('summary span:last-of-type'),
|
template.querySelector('p.started time'),
|
template.querySelector('p.updated time'),
|
template.querySelector('p.total b'),
|
template.querySelector('.term-list'),
|
Object.values(item.fields.order)
|
];
|
let numberTreatments = timeline.length - 1;
|
let beforeImgData = item.images[timeline[0]['post_thumbnail']];
|
let afterImgData = item.images[timeline[numberTreatments]['post_thumbnail']];
|
|
[
|
main.dataset.id,
|
link.href,
|
beforeImg.src,
|
beforeImg.dataset.small,
|
beforeImg.dataset.medium,
|
afterImg.src,
|
afterImg.dataset.small,
|
afterImg.dataset.medium,
|
afterText.textContent,
|
started.textContent,
|
lastTreated.textContent,
|
total.textContent
|
] = [
|
item.id,
|
item.url,
|
beforeImgData['tiny'],
|
beforeImgData.small,
|
beforeImgData.medium,
|
afterImgData['tiny'],
|
afterImgData.small,
|
afterImgData.medium,
|
`${afterText.textContent} ${numberTreatments} Tx`,
|
timeline[0].date??item.date,
|
timeline[numberTreatments].date??'',
|
`${numberTreatments} Treatments`
|
];
|
return template;
|
}
|
|
removePlaceholders() {
|
const placeholders = this.ui.grid.querySelectorAll('.placeholder');
|
if (placeholders.length > 0) {
|
placeholders.forEach(p => p.remove());
|
}
|
}
|
|
|
addPlaceholders() {
|
let total = this.contentTypes.length;
|
const fragment = document.createDocumentFragment();
|
for (let i = 0; i < 12; i++) {
|
let template = window.getTemplate('placeholderTemplate');
|
|
let rand = Math.floor(Math.random() * total);
|
let icon;
|
if (this.ui.content.length > 0) {
|
icon = this.ui.content.filter((content) => { return content.value === this.contentTypes[rand]}).querySelector('.icon').cloneNode(true);
|
} else {
|
icon = window.getIcon(this.container.dataset.icon);
|
}
|
template.append(icon);
|
fragment.append(template);
|
}
|
this.ui.grid.append(fragment);
|
}
|
|
|
|
/**
|
*
|
* @param {object} filters {name: value}
|
*/
|
updateFilter(filters) {
|
//double check filters are what we're expecting
|
let allowed = ['taxonomy','favourites','match', ... Object.keys(this.filters)];
|
|
filters = Object.keys(filters)
|
.filter(key => allowed.includes(key))
|
.reduce((obj, key) => {
|
obj[key] = filters[key];
|
return obj;
|
}, {});
|
|
if (window.getDifferences.map(this.filters, filters)) {
|
this.filters = { ...this.filters, ...filters }; // Merge instead of replace
|
this.updateURL();
|
this.store.setFilters(filters);
|
}
|
}
|
/**
|
* Update visible filters based on selected content type
|
*/
|
updateContentFor(contentType) {
|
// Update taxonomy filter visibility
|
const taxonomyButtons = this.ui.filterContainer.querySelectorAll('[data-filter="taxonomy"]');
|
taxonomyButtons.forEach(button => {
|
const forTypes = button.dataset.for?.split(',') || [];
|
button.hidden = forTypes.length > 0 && !forTypes.includes(contentType);
|
});
|
|
// Update ordering options
|
const orderButtons = this.ui.filterContainer.querySelectorAll('[data-for]');
|
orderButtons.forEach(button => {
|
const forTypes = button.dataset.for?.split(',') || [];
|
if (forTypes.length > 0) {
|
button.hidden = !forTypes.includes(contentType);
|
// Uncheck if hiding
|
if (button.hidden && button.checked) {
|
button.checked = false;
|
}
|
}
|
});
|
|
// Update order direction visibility based on selected orderby
|
const orderBy = this.ui.filterContainer.querySelector('[name="orderby"]:checked');
|
this.updateOrderDirectionVisibility(orderBy?.value);
|
}
|
|
/**
|
* Show/hide order direction based on orderby selection
|
*/
|
updateOrderDirectionVisibility(orderBy) {
|
const orderDirection = this.ui.filterContainer.querySelector('.order-direction');
|
if (orderDirection) {
|
const forOrders = orderDirection.dataset.forOrder?.split(',') || [];
|
orderDirection.hidden = forOrders.length > 0 && !forOrders.includes(orderBy);
|
}
|
}
|
/*********************************************************************
|
LISTENERS
|
*********************************************************************/
|
initListeners() {
|
this.popStateHandler = this.handlePopState.bind(this);
|
this.clickHandler = this.handleClick.bind(this);
|
this.changeHandler = this.handleChange.bind(this);
|
this.imageObserver = null;
|
this.resizeObserver = null;
|
if ('IntersectionObserver' in window) {
|
this.imageObserver = new IntersectionObserver(entries => {
|
entries.forEach(entry => {
|
this.loadImage(entry.target);
|
this.imageObserver.unobserve(entry.target);
|
});
|
}, {
|
rootMargin: '100px',
|
threshold: .1
|
});
|
}
|
|
if ('ResizeObserver' in window) {
|
this.resizeObserver = new ResizeObserver(window.debounce(() => {
|
this.updateImageSizes();
|
}, 250));
|
} else {
|
window.addEventListener('resize', window.debounce(()=> {
|
this.updateImageSizes();
|
}, 250));
|
}
|
|
window.addEventListener('popstate', this.popStateHandler);
|
document.addEventListener('click', this.clickHandler);
|
document.addEventListener('change', this.changeHandler);
|
}
|
|
loadImage(img) {
|
const src = this.getAppropriateImageSize(img);
|
if (src && src !== img.src) {
|
img.src = src;
|
img.dataset.loaded = 'true';
|
}
|
}
|
|
getAppropriateImageSize(img) {
|
const width = window.innerWidth;
|
|
if (width < 768 && img.dataset.small) {
|
return img.dataset.small;
|
} else if (img.dataset.medium) {
|
return img.dataset.medium;
|
}
|
|
return img.src; // Fallback to current src
|
}
|
|
observeImages(container) {
|
const images = container.querySelectorAll('img[data-small], img[data-medium]');
|
images.forEach(img => {
|
if (!img.dataset.loaded) {
|
this.imageObserver.observe(img);
|
}
|
});
|
}
|
|
handlePopState(e) {
|
if (e.state?.filters) {
|
if (this.processURLFilters()) {
|
this.store.setFilters(this.filters);
|
this.a11y.announce('Feed filters updated from browser history');
|
}
|
}
|
}
|
|
handleClick(e) {
|
if (window.targetCheck(e, this.elements.loadMore)) {
|
this.nextPage();
|
} else if (window.targetCheck(e, this.elements.clearFilter)) {
|
this.clearAllTaxonomies();
|
} else if (window.targetCheck(e, '.remove-item')) {
|
this.handleRemoveSelectedTerm(e);
|
}
|
}
|
|
handleRemoveSelectedTerm(e) {
|
const selectedItem = e.target.closest('.selected-item');
|
if (!selectedItem) return;
|
|
const termId = parseInt(selectedItem.dataset.id);
|
const taxonomy = selectedItem.dataset.taxonomy;
|
|
// Remove from filters
|
if (this.taxonomyFilters[taxonomy]) {
|
this.taxonomyFilters[taxonomy] = this.taxonomyFilters[taxonomy]
|
.filter(id => id !== termId);
|
|
if (this.taxonomyFilters[taxonomy].length === 0) {
|
delete this.taxonomyFilters[taxonomy];
|
}
|
}
|
|
// Remove from UI
|
selectedItem.remove();
|
|
// Update filters
|
this.updateFilter({
|
taxonomy: Object.keys(this.taxonomyFilters).length > 0
|
? this.taxonomyFilters
|
: null,
|
page: 1
|
});
|
}
|
|
handleChange(e) {
|
let target = e.target;
|
if (Object.hasOwn(target.dataset, 'filter')) {
|
if (target.dataset.filter === 'content') {
|
this.updateContentFor(target.value);
|
this.updateFilter({ content: target.value, page: 1 });
|
} else if (target.dataset.filter === 'orderby') {
|
this.updateOrderDirectionVisibility(target.value);
|
this.updateFilter({ orderby: target.value, page: 1 });
|
} else if (target.dataset.filter === 'order') {
|
this.updateFilter({ order: target.value, page: 1 });
|
} else if (target.dataset.filter === 'match') {
|
this.updateFilter({ match: target.checked ? 'all' : 'any', page: 1 });
|
} else if (target.dataset.filter === 'favourites') {
|
this.updateFilter({ favourites: target.checked, page: 1 });
|
}
|
}
|
}
|
}
|
|
document.addEventListener('DOMContentLoaded', function() {
|
window.feedBlock = new FeedBlock();
|
});
|