// 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;