// index.js - Main entry point for the Feed Block
|
import ErrorHandling from './services/ErrorHandlingService';
|
import StateManager from './services/StateManager';
|
import FeedService from './services/FeedService';
|
import FavouritesService from './services/FavouritesService';
|
import FilterService from './services/FilterService';
|
import FeedGrid from './components/FeedGrid';
|
import FilterPanel from './components/FilterPanel';
|
import GalleryModal from './components/GalleryModal';
|
import LoadingState from './components/LoadingState';
|
// import TaxonomySelector from './components/TaxonomySelector';
|
import cache from './utils/cache';
|
import { debounce } from './utils/formatters';
|
|
class FeedBlock {
|
constructor(container) {
|
// Store container reference
|
this.container = container;
|
|
// Initialize config
|
this.initializeConfig();
|
|
// Create services and components
|
this.initializeServices();
|
this.initializeComponents();
|
this.initializeErrorHandling();
|
this.initializeAccessibility();
|
|
// Initialize state from URL
|
this.loadStateFromURL();
|
// Set up event delegation for efficiency
|
this.setupEventDelegation();
|
this.loadItems();
|
|
|
// Check for highlighted item
|
if (this.config.highlight && this.stateManager.getState().page === 1) {
|
console.log(this.config.highlight);
|
console.log('Opening Highlighted Item');
|
this.openGallery();
|
}
|
|
}
|
|
/**
|
* Initialize error handling
|
*/
|
initializeErrorHandling() {
|
this.errorHandler = new ErrorHandling({
|
apiUrl: this.config.apiUrl,
|
logToServer: true,
|
displayNotifications: true
|
});
|
|
// Pass error handler to services
|
this.feedService.errorHandler = this.errorHandler;
|
this.favouritesService.errorHandler = this.errorHandler;
|
}
|
|
/**
|
* Initialize configuration from container data
|
*/
|
initializeConfig() {
|
// Get settings from container data attribute
|
const settings = JSON.parse(this.container.dataset.settings || '{}');
|
|
// Merge with globals
|
this.config = {
|
apiUrl: window.feedSettings?.apiUrl || '',
|
nonce: window.feedSettings?.nonce || '',
|
currentUser: window.feedSettings?.currentUser || null,
|
|
content: settings.content || 'tattoo',
|
contentTypes: settings.contentTypes || ['tattoo'],
|
taxonomies: settings.taxonomies || [],
|
defaultOrder: settings.defaultOrder || 'date',
|
itemsPerPage: settings.itemsPerPage || 12,
|
|
// Context information
|
context: settings.context || null,
|
name: settings.name || '',
|
id: settings.id || '',
|
inheritQuery: settings.inheritQuery || false,
|
|
// Initial terms for taxonomies
|
initialTerms: settings.initial_terms || {},
|
|
// Source information for analytics
|
source: this.container.dataset.source || '',
|
sourceType: this.container.dataset.context || '',
|
|
// Optional highlight
|
highlight: null,
|
|
// Gallery mode
|
isGallery: settings.isGallery || false,
|
showAuthor: true,
|
showDate: false,
|
|
// Feature flags
|
loadMoreTax: settings.loadMoreTax ?? true,
|
|
// User preferences
|
viewMode: localStorage.getItem('feedViewMode') || 'grid',
|
};
|
|
// Get highlight from URL if present
|
this.config.highlight = this.getHighlightFromURL();
|
|
// Adjust config based on context
|
if (this.config.context) {
|
switch (this.config.context.type) {
|
case 'author':
|
this.config.isGallery = true;
|
this.config.showAuthor = false;
|
this.config.showDate = true;
|
break;
|
}
|
}
|
}
|
|
/**
|
* Initialize services
|
*/
|
initializeServices() {
|
// Create state manager
|
this.stateManager = new StateManager({
|
content: this.config.content,
|
defaultOrder: this.config.defaultOrder,
|
});
|
|
// Create filter service
|
this.filterService = new FilterService({
|
taxonomyMap: window.taxonomy_for || {},
|
contentTypeMap: window.feed_types || {},
|
initialTerms: this.config.initialTerms,
|
});
|
|
// Create API service
|
this.feedService = new FeedService(
|
this.config.apiUrl,
|
this.config.nonce
|
);
|
|
// Create favourites service
|
this.favouritesService = new FavouritesService(
|
this.config.apiUrl,
|
this.config.nonce
|
);
|
|
// Initialize favourites
|
this.favouritesService.init();
|
|
// Make available globally for legacy code
|
window.hasFavourited = (type, id) => this.favouritesService.isFavourited(type, id);
|
}
|
|
/**
|
* Initialize components
|
*/
|
initializeComponents() {
|
// Create filter panel with centralized onChange handler
|
this.filterPanel = new FilterPanel(
|
this.container.querySelector('.feed-filters'),
|
{
|
taxonomies: this.config.taxonomies,
|
contentTypes: this.config.contentTypes,
|
taxonomyFor: window.taxonomy_for || {},
|
defaultContent: this.config.content,
|
// Pass a reference to the handler instead of binding to avoid duplication
|
onChange: this.handleFilterChange.bind(this)
|
}
|
);
|
|
// IMPORTANT: Initialize taxonomy filters with current content type
|
this.filterPanel.updateTaxonomyFilters(this.config.content);
|
this.filterPanel.updateOrderFilters(this.config.content);
|
|
// Create feed grid
|
this.feedGrid = new FeedGrid(
|
this.container.querySelector('.feed-grid'),
|
{
|
isGallery: this.config.isGallery,
|
showAuthor: this.config.showAuthor,
|
showDate: this.config.showDate,
|
}
|
);
|
|
// Create loading state
|
this.loadingState = new LoadingState(
|
this.container.querySelector('.feed-overlay'),
|
{
|
contentTypes: this.config.contentTypes,
|
taxonomies: this.config.taxonomies,
|
}
|
);
|
|
// Set up gallery if needed
|
if (this.config.isGallery) {
|
this.feedGrid.setGalleryOpenHandler(this.openGallery.bind(this));
|
}
|
}
|
|
/**
|
* Use event delegation for improved performance
|
*/
|
setupEventDelegation() {
|
// State change listener
|
this.stateManager.subscribe(this.handleStateChange.bind(this));
|
|
// Single container event listener for load more
|
this.container.addEventListener('click', (e) => {
|
// Handle load more button
|
if (e.target.closest('.load-more')) {
|
this.handleLoadMore();
|
e.preventDefault();
|
}
|
});
|
|
// Global events that should only have one listener
|
window.addEventListener('popstate', this.handlePopState.bind(this));
|
document.addEventListener('galleryClose', () => this.gallery = null);
|
document.addEventListener('favourites-updated', this.handleFavouritesUpdate.bind(this));
|
}
|
|
/**
|
* Handle browser history navigation
|
*/
|
handlePopState(e) {
|
if (e.state && e.state.filters) {
|
// Update state manager with filters from URL
|
this.stateManager.updateFilters(e.state.filters);
|
|
// Update UI
|
this.filterPanel.loadFromURL();
|
|
// Update taxonomy and order filters for current content type
|
const contentType = e.state.filters.content || this.config.content;
|
this.filterPanel.updateTaxonomyFilters(contentType);
|
this.filterPanel.updateOrderFilters(contentType);
|
|
// Load items with updated filters
|
this.loadItems();
|
|
|
|
// Announce to screen readers
|
this.announceToScreenReader('Updated filters from browser history');
|
}
|
}
|
|
/**
|
* Handle state changes
|
*/
|
handleStateChange(state) {
|
// Update loading UI
|
this.updateLoadingUI(state.loading);
|
|
// Update load more button visibility
|
this.updateLoadMoreButton(state.hasMore);
|
}
|
|
/**
|
* Update loading UI
|
*/
|
updateLoadingUI(loading) {
|
if (loading) {
|
this.loadingState.show();
|
} else {
|
this.loadingState.hide();
|
}
|
|
// Update loading spinner
|
const spinner = this.container.querySelector('.loading-spinner');
|
if (spinner) {
|
spinner.hidden = !loading;
|
}
|
|
// Update load more button
|
const loadMoreBtn = this.container.querySelector('.load-more');
|
if (loadMoreBtn) {
|
loadMoreBtn.disabled = loading;
|
}
|
}
|
|
/**
|
* Update load more button visibility
|
*/
|
updateLoadMoreButton(hasMore) {
|
const loadMoreBtn = this.container.querySelector('.load-more');
|
if (loadMoreBtn) {
|
loadMoreBtn.hidden = !hasMore;
|
}
|
}
|
|
/**
|
* Handle filter changes
|
*/
|
handleFilterChange(filters) {
|
// Check if content type has changed
|
const prevContentType = this.stateManager.getState().filters;
|
const contentTypeChanged = prevContentType !== filters;
|
|
// Update state
|
this.stateManager.updateFilters(filters);
|
|
// Reset pagination
|
this.stateManager.resetPagination();
|
|
// Reload items (with cache reset if content type changed)
|
this.loadItems(contentTypeChanged);
|
}
|
|
/**
|
* Handle favourites updates
|
*/
|
handleFavouritesUpdate(event) {
|
const { type, id, isFavourited } = event.detail;
|
|
// Update UI
|
this.feedGrid.updateFavouriteStatus(type, id, isFavourited);
|
|
// Show notification
|
this.showNotification(
|
isFavourited ? 'Added to favourites' : 'Removed from favourites',
|
'success'
|
);
|
}
|
|
/**
|
* Handle load more button click
|
*/
|
handleLoadMore() {
|
const state = this.stateManager.getState();
|
|
if (state.loading || !state.hasMore) {
|
return;
|
}
|
|
// Increment page
|
this.stateManager.nextPage();
|
|
// Load more items
|
this.loadItems();
|
}
|
|
/**
|
* Load items from API with optional cache busting
|
*/
|
async loadItems(resetCache = false) {
|
const state = this.stateManager.getState();
|
|
// Skip if already loading
|
if (state.loading) {
|
return;
|
}
|
|
// Update loading state
|
this.stateManager.setLoading(true);
|
console.log(this.config);
|
try {
|
// Get query parameters - explicitly include content type
|
const params = {
|
filters: {
|
...state.filters,
|
// Ensure content type is explicitly set
|
content: state.filters.content || this.config.content
|
},
|
page: state.page,
|
highlight: this.config.highlight,
|
source: this.config.source,
|
sourceType: this.config.sourceType
|
};
|
|
|
// Fetch data with cache busting if specified
|
const data = await this.feedService.fetchFeed(params, resetCache);
|
|
// Clear grid on first page
|
if (state.page === 1) {
|
this.feedGrid.clear();
|
}
|
// Handle empty results
|
if (!data || !data.items || data.items.length === 0) {
|
if (state.page === 1) {
|
this.feedGrid.showEmptyState(!!state.filters.favouritesOnly);
|
}
|
this.stateManager.setState({ hasMore: false });
|
} else {
|
// Render items
|
this.feedGrid.renderItems(data.items, state.page > 1);
|
|
// Update has more
|
this.stateManager.setState({ hasMore: data.hasMore });
|
|
|
}
|
} catch (error) {
|
this.handleError(error);
|
} finally {
|
// Update loading state
|
this.stateManager.setLoading(false);
|
}
|
}
|
|
/**
|
* Handle errors
|
*/
|
handleError(error) {
|
if (this.errorHandler) {
|
return this.errorHandler.handleApiError(
|
error,
|
{
|
component: 'FeedBlock',
|
action: 'loadItems'
|
},
|
() => this.loadItems()
|
);
|
}
|
|
// Fallback to basic error handling if errorHandler not available
|
this.showNotification(
|
'Failed to load content. Please try again.',
|
'error',
|
[{
|
label: 'Refresh',
|
icon: 'refresh',
|
action: () => {
|
window.location.reload();
|
}
|
}]
|
);
|
}
|
|
/**
|
* Show notification
|
*/
|
showNotification(message, type = 'info', actions = []) {
|
if (window.jvbNotifications) {
|
window.jvbNotifications.queuePopupNotification({
|
type: type,
|
message: message,
|
icon: type === 'error' ? 'alert' : (type === 'success' ? 'heart' : 'info'),
|
priority: type === 'error' ? 'high' : 'medium',
|
displayDuration: 3000,
|
actions: actions
|
});
|
}
|
|
// Update live region for accessibility
|
const liveRegion = this.container.querySelector('.live-region');
|
if (liveRegion) {
|
liveRegion.textContent = message;
|
}
|
}
|
|
/**
|
* Load state from URL parameters
|
*/
|
loadStateFromURL() {
|
// Get filters from URL via the filter service
|
const urlFilters = this.filterService.parseFiltersFromURL();
|
if (Object.keys(urlFilters).length === 0) {
|
return;
|
}
|
|
// Determine content type - from URL or default config
|
const contentType = urlFilters['f_content'] || this.config.content;
|
|
// Update state manager with URL filters
|
if (Object.keys(urlFilters).length > 0) {
|
this.stateManager.updateFilters(urlFilters);
|
}
|
|
// Update UI to match current state (handled by FilterPanel)
|
this.filterPanel.loadFromURL();
|
|
// IMPORTANT: Make sure taxonomy filters visibility is correct
|
// for the current content type
|
this.filterPanel.updateTaxonomyFilters(contentType);
|
this.filterPanel.updateOrderFilters(contentType);
|
|
}
|
|
/**
|
* Get highlighted item from URL
|
*/
|
getHighlightFromURL() {
|
const searchParams = new URLSearchParams(window.location.search);
|
|
// Check for content type parameters
|
const contentTypes = ['tattoo', 'piercing', 'artwork'];
|
|
for (const type of contentTypes) {
|
if (searchParams.has(type)) {
|
return { [type]: searchParams.get(type) };
|
}
|
}
|
|
return null;
|
}
|
|
/**
|
* Scroll to highlighted item
|
*/
|
scrollToHighlightedItem() {
|
if (!this.config.highlight) return;
|
|
// Get highlight type and ID
|
const type = Object.keys(this.config.highlight)[0];
|
const id = this.config.highlight[type];
|
|
if (!type || !id) return;
|
|
// Find the item
|
const item = this.container.querySelector(`#${type}-${id}`);
|
|
if (item) {
|
// Scroll to item
|
item.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
// Highlight the item
|
item.classList.add('highlighted');
|
|
// Open gallery if in gallery mode
|
if (this.config.isGallery) {
|
console.log('It is a gallery item!');
|
const items = Array.from(this.container.querySelectorAll('.feed-item'));
|
const index = items.indexOf(item);
|
|
if (index !== -1) {
|
this.openGallery(index);
|
}
|
}
|
|
// Clean up URL
|
window.history.replaceState('', '', window.location.origin + window.location.pathname);
|
}
|
}
|
|
/**
|
* Open gallery view
|
*/
|
openGallery(index) {
|
if (!this.config.isGallery || this.gallery) return;
|
|
// Get gallery items from grid
|
const items = this.feedGrid.getGalleryItems();
|
console.log(items);
|
|
// Create gallery with unified callbacks object
|
this.gallery = new GalleryModal(items, index);
|
|
// Set callbacks
|
this.gallery.setCallbacks({
|
onClose: () => this.gallery = null,
|
onLoadMore: this.handleGalleryLoadMore.bind(this),
|
onNavigate: (newIndex) => {
|
if (newIndex >= items.length - 3 && this.stateManager.getState().hasMore) {
|
this.handleGalleryLoadMore();
|
}
|
}
|
});
|
|
this.gallery.show();
|
}
|
|
/**
|
* Handle load more request from gallery
|
*/
|
async handleGalleryLoadMore() {
|
const state = this.stateManager.getState();
|
|
if (!state.hasMore || state.loading) {
|
return false;
|
}
|
|
// Increment page
|
this.stateManager.nextPage();
|
|
// Load more items
|
await this.loadItems();
|
|
// Update gallery items
|
if (this.gallery) {
|
this.gallery.updateItems(this.feedGrid.getGalleryItems());
|
}
|
|
return true;
|
}
|
|
/**
|
* Initialize accessibility features
|
*/
|
initializeAccessibility() {
|
// Create live region for screen reader announcements
|
this.liveRegion = document.createElement('div');
|
this.liveRegion.setAttribute('aria-live', 'polite');
|
this.liveRegion.setAttribute('role', 'status');
|
this.liveRegion.className = 'screen-reader-text live-region';
|
this.container.appendChild(this.liveRegion);
|
|
// Add keyboard shortcut support
|
this.bindKeyboardShortcuts();
|
}
|
|
/**
|
* Set up focus traps for modals
|
*/
|
setupFocusTraps() {
|
// Add focus trap to filter dropdowns
|
this.filterDropdowns = this.container.querySelectorAll('.filter-dropdown');
|
this.filterDropdowns.forEach(dialog => {
|
dialog.addEventListener('show', () => {
|
// Store current focus
|
this._previouslyFocused = document.activeElement;
|
|
// Focus first focusable element
|
const focusable = dialog.querySelectorAll(
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
);
|
if (focusable.length) {
|
focusable[0].focus();
|
}
|
});
|
|
dialog.addEventListener('close', () => {
|
// Restore focus
|
if (this._previouslyFocused) {
|
this._previouslyFocused.focus();
|
}
|
});
|
});
|
}
|
|
/**
|
* Bind keyboard shortcuts
|
*/
|
bindKeyboardShortcuts() {
|
// Use a single event listener for keyboard shortcuts
|
document.addEventListener('keydown', (e) => {
|
// Only handle when feed is visible (check if in viewport)
|
if (!this.isElementInViewport(this.container)) return;
|
|
// Escape key closes any open dialogs
|
if (e.key === 'Escape') {
|
const openDialogs = this.container.querySelectorAll('dialog[open]');
|
if (openDialogs.length) {
|
openDialogs[0].close();
|
e.preventDefault();
|
}
|
}
|
|
// Alt+F opens filters (common accessibility pattern)
|
if (e.key === 'f' && e.altKey) {
|
const filterToggle = this.container.querySelector('.filter-toggle');
|
if (filterToggle) {
|
filterToggle.click();
|
e.preventDefault();
|
}
|
}
|
|
// Space or Enter on focused items activates them
|
if ((e.key === ' ' || e.key === 'Enter') && document.activeElement.classList.contains('feed-item')) {
|
const link = document.activeElement.querySelector('a');
|
if (link) {
|
link.click();
|
e.preventDefault();
|
}
|
}
|
});
|
}
|
|
|
/**
|
* Check if element is in viewport
|
*/
|
isElementInViewport(el) {
|
const rect = el.getBoundingClientRect();
|
return (
|
rect.top >= 0 &&
|
rect.left >= 0 &&
|
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
|
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
|
);
|
}
|
|
/**
|
* Announce message to screen readers
|
*/
|
announceToScreenReader(message) {
|
if (!this.liveRegion) return;
|
|
this.liveRegion.textContent = message;
|
}
|
}
|
|
export default FeedBlock;
|