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"]')??false;
|
this.ui.taxonomies = this.ui.filterContainer.querySelectorAll('[data-taxonomy]');
|
if (this.ui.content && 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() {
|
if (this.ui.filterContainer) {
|
// 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 (this.ui.filterContainer && 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);
|
|
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);
|
}
|
|
if (this.ui.filters.match) {
|
this.ui.filters.match.hidden = Object.keys(this.taxonomyFilters).length === 0;
|
}
|
if (this.ui.clearFilter) {
|
this.ui.clearFilter.hidden = Object.keys(this.taxonomyFilters).length === 0;
|
}
|
}
|
|
/**
|
*
|
* @param {object} item
|
*/
|
createItemElement(item) {
|
let template = window.getTemplate(`feedItem${window.uppercaseFirst(item.content)}`);
|
|
const isTimeline = Object.hasOwn(template.dataset, 'timeline');
|
|
// Format fields using helpers
|
for (let [fieldName, value] of Object.entries(item.fields)) {
|
if (isTimeline && ['timeline', 'number'].includes(fieldName)) continue;
|
let el = template.querySelector(`[data-field="${fieldName}"]`);
|
if (!el) continue;
|
|
if (value === '') {
|
el.remove();
|
continue;
|
}
|
|
if (this.isImageField(item, value)) {
|
this.formatImageFields(el, value, item);
|
} else if (this.isTaxonomyField(item, fieldName)) {
|
this.formatTaxonomyField(el, item, fieldName, value);
|
} else if (this.isTimeField(el)) {
|
this.formatTimeField(el, value);
|
} else {
|
this.formatField(el, value);
|
}
|
}
|
|
// Handle link
|
let link = template.querySelector('a');
|
if (link && item.url !== '') {
|
[
|
link.href,
|
link.title
|
] = [
|
item.url,
|
`View ${item.fields['post_title']??'Item'}`
|
];
|
}
|
|
if (isTimeline) {
|
this.addTimelineElements(item, template);
|
}
|
|
return template;
|
}
|
splitIDs(value) {
|
return String(value).split(',').map((value) => parseInt(value.trim())).filter(value=>value);
|
}
|
isImageField(item, value) {
|
if (!Object.hasOwn(item, 'images') || Object.keys(item.images).length === 0) {
|
return false;
|
}
|
let values = this.splitIDs(value);
|
|
return values.some(v =>
|
Object.keys(item.images).map(k => parseInt(k)).includes(parseInt(v))
|
);
|
}
|
formatImageFields(element, value, item) {
|
let values = this.splitIDs(value); // Convert string to array first
|
if (values.length === 0) return;
|
|
if (values.length > 1) {
|
let image = element.querySelector('img');
|
if (!image) return;
|
values.forEach(imgID => {
|
let img = image.cloneNode(true);
|
this.formatImageField(img, imgID, item);
|
element.append(img);
|
});
|
image.remove();
|
} else {
|
if (element.tagName !== 'IMG') {
|
element = element.querySelector('img');
|
if (!element) return;
|
}
|
this.formatImageField(element, values[0], item);
|
}
|
}
|
formatImageField(element, value, item) {
|
let imgData = item.images[value]??false;
|
if (!imgData) return;
|
[
|
element.src,
|
element.srcset,
|
element.alt
|
] = [
|
imgData.tiny,
|
`${imgData.tiny} 50w, ${imgData.small} 300w, ${imgData.medium} 1024w`,
|
imgData['image-alt-text']
|
]
|
}
|
isTaxonomyField(item, field) {
|
if (!Object.hasOwn(item, 'taxonomies') || Object.keys(item.taxonomies).length === 0) {
|
return false;
|
}
|
|
return Object.keys(item.taxonomies).includes(field);
|
}
|
formatTaxonomyField(element, item, field, value) {
|
if (element.tagName !== 'UL' || !element.querySelector('li')) return;
|
let values = this.splitIDs(value);
|
if (values.length === 0) {
|
element.remove();
|
}
|
let listItem = element.querySelector('li');
|
for (let termID of values) {
|
let term = item.taxonomies[field][termID]??false;
|
if (!term) continue;
|
let termItem = listItem.cloneNode(true);
|
let link = termItem.querySelector('a');
|
if (!link) continue;
|
|
[
|
link.href,
|
link.title,
|
link.textContent
|
] = [
|
term.url,
|
`See more ${term.title}`,
|
term.title
|
];
|
element.append(termItem);
|
}
|
listItem.remove();
|
}
|
isTimeField(el) {
|
return el.tagName === 'TIME' || el.querySelector('time') !== null;
|
}
|
formatTimeField(element, value) {
|
if (element.tagName !== 'TIME') {
|
element = element.querySelector('time');
|
if (!element) return;
|
}
|
element.setAttribute('datetime', value);
|
element.textContent = window.formatTimeAgo(value, 'F Y');
|
}
|
formatField(element, value) {
|
element.textContent = value;
|
}
|
|
addTimelineElements(item, template) {
|
let [
|
afterEl,
|
number,
|
started,
|
last
|
] = [
|
template.querySelector('span.after-text'),
|
template.querySelector('[data-field="number"] b'),
|
template.querySelector('[data-field="started"] time'),
|
template.querySelector('[data-field="updated"] time')
|
];
|
|
if (afterEl) {
|
afterEl.textContent = `After ${item.fields.number} Tx`;
|
}
|
if (number) {
|
number.textContent = item.fields.number;
|
}
|
if (started) {
|
this.formatTimeField(started, item.fields.timeline[0]['post_date']);
|
}
|
if (last) {
|
this.formatTimeField(last, item.fields.timeline[item.fields.timeline.length - 1]['post_date']);
|
}
|
}
|
|
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 && 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.debouncer.schedule(
|
'feed-update-images',
|
() => this.updateImageSizes(),
|
250
|
);
|
});
|
} else {
|
window.addEventListener('resize', () => {
|
window.debouncer.schedule(
|
'feed-update-images',
|
() => this.updateImageSizes(),
|
250
|
);
|
});
|
}
|
|
window.addEventListener('popstate', this.popStateHandler);
|
document.addEventListener('click', this.clickHandler);
|
document.addEventListener('change', this.changeHandler);
|
}
|
|
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', async function() {
|
window.auth.subscribe(event => {
|
if (event === 'auth-loaded') {
|
window.feedBlock = new FeedBlock();
|
}
|
});
|
});
|