class JVBAdmin { constructor(){ this.queue = window.jvbQueue; this.loading = window.jvbLoading; this.cache = window.jvbCache; this.a11y = window.jvbA11y; this.error = window.jvbError; this.activeTab = 'artist'; this.reset = false; this.observer = null; this.form = {}; this.isSaving = false; this.hasChanges = false; this.trackedChanges = new Map(); this.items = new Map(); this.isLoading = false; this.tabNav = localStorage.getItem('jvbTabNav') === 'vertical'; this.template = new Map(); this.endpoints = 'myster'; this.resetFilters(); this.hasMore = true; this.maxPages = 1; this.totalItems = 0; this.initElements(); this.initEvents(); this.firstLoad = false; if(!this.firstLoad){ this.resetTable(); this.firstLoad = true; } // Define all your tabs in an array let tabs = document.querySelectorAll('button.tab'); let tabsConfig = {}; this.modals = {}; tabs.forEach(tab =>{ let name = tab.dataset.tab; tabsConfig[name] = () => { if (tab.classList.contains('active')) { this.activeTab = name; } this.loading.setContent([this.activeTab]); this.resetTable(); this.resetFilters(); this.filters.content = name; localStorage.setItem('jvbAdminTab', name); this.loadItems(true).then(()=>{}); } this.modals[name] = new window.jvbModal(document.querySelector(`dialog.edit-modal.${name}`, { open: false, onSave: () => this.saveEditModal.bind(this) })); this.items.set(name, new Map()); }); // Initialize tabs with the generated config this.tabs = new window.jvbTabs(document.querySelector('.replace'), tabsConfig); this.loading.setContent([this.activeTab]); this.tabs.switchTab(this.activeTab); this.loadItems(); this.saveTimeout = null; this.SAVE_DELAY = 5000; // 5 seconds // Bind the method to preserve 'this' context this.debouncedSave = this.debouncedSave.bind(this); } resetFilters(){ this.filters = { page: 1, order: 'DESC', orderby: 'name', content: this.activeTab } } resetTable(){ removeChildren(this.grid); let table = window.getTemplate(`${this.activeTab}Table`).cloneNode(true); this.row = `${this.activeTab}Row`; let head = table.querySelector('thead').cloneNode(true); let foot = table.querySelector('tfoot'); Array.from(head.children).forEach(child => { foot.appendChild(child.cloneNode(true)); }); this.grid.append(table); this.body = this.grid.querySelector('tbody'); this.grid.removeEventListener('change', this.boundChanges); this.boundChanges = this.trackChanges.bind(this); this.grid.addEventListener('change', this.boundChanges); } initElements(){ this.container = document.querySelector('.replace'); this.grid = this.container.querySelector('.items-container'); this.tabToggle = this.container.querySelector('input#vertical'); this.tabToggle.checked = this.tabNav; // this.filterForm = this.container.querySelector('form'); // this.dateRangeFilter = new window.jvbModal( // this.container.querySelector('dialog.date-range'), // { // open: false, // }); // this.clearFilters = this.container.querySelector('.clear-filters'); // // this.replyModal = new window.jvbModal( // this.container.querySelector('.create-response'), // { // open: false, // content: 'response', // openMessage: 'Opened Response modal', // onSave: this.saveCreatedResponse.bind(this) // }); } trackChanges(e) { this.hasChanges = true; let id = e.target.closest('tr').id; if(!this.trackedChanges.has(id)){ this.trackedChanges.set(id, new Map()); } let name = e.target.name; let value = e.target.value; this.trackedChanges.get(id).set(name, value); // Schedule the save with debouncing this.debouncedSave(); } // Add this method to handle the debounced save debouncedSave() { // Clear any existing timeout if (this.saveTimeout) { clearTimeout(this.saveTimeout); } // Set a new timeout this.saveTimeout = setTimeout(() => { this.processChanges(); }, this.SAVE_DELAY); } async processChanges(){ if (this.trackedChanges.size === 0) return; try { this.loading.showLoading(); let changes = mapToObj(this.trackedChanges); console.log("Saving changes:", changes); // Send to server const response = await fetch(`${jvbSettings.api}${this.endpoints}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': jvbSettings.nonce, 'action_nonce': jvbAdmin.nonce }, body: JSON.stringify({ user: jvbSettings.currentUser, data: changes, content: this.activeTab }) }); if (!response.ok) { throw new Error(`Server returned ${response.status}`); } const data = await response.json(); if (data.success) { this.reset = true; // Clear tracked changes after successful save this.trackedChanges = new Map(); this.hasChanges = false; this.loadItems(); // Notify user of successful save this.a11y.announce(`Changes saved successfully`); } else { throw new Error(data.message || 'Unknown error'); } } catch (error) { this.handleError(error, 'saving changes'); } finally { this.loading.hideLoading(); } } initEvents(){ this.tabToggle.addEventListener('change', (e)=>{ this.tabNav = e.target.checked; let value = e.target.checked ? 'vertical' : 'horizontal'; localStorage.setItem('jvbTabNav', value); window.jvbA11y.announce((this.tabNav)?'Changed to vertical navigation':'Changed to horizontal navigation'); }); this.grid.addEventListener('keydown', (e)=>{ if (e.key === 'Tab' && this.tabNav) { let current = e.target.closest('td').dataset.id; let row = e.target.closest('tr'); let rows = Array.from(this.body.querySelectorAll('tr')); let index = rows.indexOf(row); let total = rows.length; if (index !== -1 && index < total) { e.preventDefault(); // Prevent default tab behavior //down if just tab, up if shift key let next = (e.shiftKey) ? index-1 : index+1; rows[next].scrollIntoView({behavior: 'smooth',block: 'center', inline: 'center'}); rows[next].querySelector(`[data-id="${current}"] input`).focus(); } if(index === total - 5 && this.hasMore){ this.loadItems(false); } } }); this.clickListener = this.handleClick.bind(this); this.container.addEventListener('click', this.clickListener); } handleClick(e){ if(e.target !== 'button' && !e.target.closest('button[data-action="edit"]')){ return; } let id = (e.target === 'button') ? e.target.dataset.id : e.target.closest('button').dataset.id; let item = this.items.get(this.activeTab).get(parseInt(id)); let modal = this.container.querySelector(`dialog.edit-modal.${this.activeTab}`); for(let[name, value] of Object.entries(item)){ let field = modal.querySelector(`[name="${name}"]`); if(field){ field.value =value; } } if(this.form.instance){ this.form.instance = null; } this.form.instance = this.handleForm(modal.querySelector('form'), id); this.modals[this.activeTab].modal.dataset.id = item.id; this.modals[this.activeTab].handleOpen(); } handleForm(form, id){ return window.jvbForm.addForm(form, { onSave: this.saveEditModal.bind(this), itemID: id, }); } async saveEditModal(){ if(this.isSaving){ return; } this.isSaving = true; let modal = this.modals[this.activeTab]; let form = modal.modal.querySelector('form'); let id = modal.modal.dataset.id; let original = this.items.get(this.activeTab).get(parseInt(id)); let newData = new FormData(form); console.log(newData); let changes = {}; for(const [name, value] of newData.entries()){ if(name.includes(':')){ let split = name.split(':'); if(!changes[split[0]]){ changes[split[0]] = {}; } changes[split[0]][split[1]] = value; }else{ changes[name] = value; } } console.log(changes); newData = {}; newData[id] = changes; try { const response = await fetch(`${jvbSettings.api}${this.endpoints}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': jvbSettings.nonce, 'action_nonce': jvbAdmin.nonce }, body: JSON.stringify({ user: jvbSettings.currentUser, data: newData, content: this.activeTab }) }); } catch (error) { } finally { this.modals[this.activeTab].handleClose(); this.isSaving = false; } form.reset(); } setupInfiniteScroll() { // If we already have an observer, disconnect it first if (this.observer) { this.observer.disconnect(); } // Find the last row in the tbody const lastRow = this.body.lastElementChild; if (!lastRow) return; // No rows to observe yet // Create and set up the observer this.observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting && this.hasMore && !this.isLoading) { console.log('Last row visible, loading more items'); this.loadItems(false); } }); }, { rootMargin: '200px 0px', // Start loading when within 200px of the viewport threshold: 0.1 // Trigger when at least 10% of the element is visible }); // Start observing the last row this.observer.observe(lastRow); console.log('Observing last row:', lastRow); } /** * Load favourites from the server * @returns {Promise} Response data */ async loadItems(reset = true) { if(this.isLoading || !this.hasMore) return; try { this.isLoading = true; this.loading.showLoading(); if(reset){ this.filters.page = 1; this.grid.classList.remove('empty'); } const params = this.buildFilters(); console.log(this.filters); console.log('Reset? ',this.reset); const data = await this.cache.fetchWithCache( `${jvbSettings.api}${this.endpoints}?${params.toString()}`, { method: 'GET', headers: { 'X-WP-Nonce': jvbSettings.nonce, 'action_nonce': jvbAdmin.nonce, } },{ context: 'admin', forceRefresh: true } ); console.log(data, 'Fetched Data:'); // Process and render the favourites this.renderItems(data.items || [], this.filters.page > 1); [ this.hasMore, this.totalItems, this.maxPages ] = [ data.has_more, data.total_items, data.total_pages ]; if(this.hasMore){ this.filters.page++; } this.setupInfiniteScroll(); return data; } catch (error) { this.handleError(error, 'loading news'); throw error; } finally { this.isLoading = false; this.loading.hideLoading(); } } buildFilters(){ //Clone to avoid modifying original const filters = JSON.parse(JSON.stringify(this.filters)); let temp = {}; for(var[name, value] of Object.entries(filters)){ if(value !== false && value !== null){ temp[name] = value; } } return new URLSearchParams(temp); } renderItems(items, append = false){ // if(!append && this.body.children.length>0){ // removeChildren(this.body); // } // 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); this.items.get(this.activeTab).set(item.id, item); fragment.appendChild(element); } // 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.body.appendChild(fragment); this.a11y.makeNavigable(this.grid.querySelectorAll('.item:not([data-keyboard-nav])')); this.a11y.announceItems(items.length, append, this.hasMore); // Set up the observer for the new last row this.setupInfiniteScroll(); } }; // Start processing the first batch if (items.length > 0) { processBatch(0); } else { this.a11y.announceItems(0, append); } } createItemElement(item){ let row = window.getTemplate(this.row); row.id = item.id; let fields = row.querySelectorAll('td'); fields.forEach(field => { let name = field.dataset.id; let input = field.querySelector('input[type="text"]'); if(input){ input.value = item[name]; field.querySelector('label').remove(); field.querySelector('.description').remove(); } }); let [ input, inputLabel, editButton ] = [ row.querySelector('[data-id="actions"] input'), row.querySelector('[data-id="actions"] label'), row.querySelector('[data-id="actions"] button'), ]; [ input.checked, input.id, inputLabel.htmlFor, editButton.dataset.id ] = [ item.public, `public-${item.id}`, `public-${item.id}`, item.id ]; return row; } createShopElement(item){ let row = window.getTemplate(this.row); row.id = item.id; let [ input, inputLabel, editButton, shopName, owner, managers, city, address, opened, phone, email, admin, publicContact, links, rate, languages, keywords, tagline, instagram, socialFollowers ] = [ row.querySelector('[data-id="actions"] input'), row.querySelector('[data-id="actions"] label'), row.querySelector('[data-id="actions"] button'), row.querySelector('[data-id="term_name"] input'), row.querySelector('[data-id="owner"] input'), row.querySelector('[data-id="managers"] input'), row.querySelector('[data-id="city"] input'), row.querySelector('[data-id="location"] input'), row.querySelector('[data-id="established"] input'), row.querySelector('[data-id="phone"] input'), row.querySelector('[data-id="email"] input'), row.querySelector('[data-id="admin_contact"] input'), row.querySelector('[data-id="public_contact"] input'), row.querySelector('[data-id="links"] input'), row.querySelector('[data-id="rate"] input'), row.querySelector('[data-id="languages"] input'), row.querySelector('[data-id="keywords"] input'), row.querySelector('[data-id="slogan"] input'), row.querySelector('[data-id="insta_handle"] input'), row.querySelector('[data-id="followers"] input'), ]; [ input.checked, input.id, inputLabel.htmlFor, editButton.dataset.id, shopName.value, owner.value, managers.value, city.value, address.value, opened.value, phone.value, email.value, admin.value, publicContact.value, rate.value, tagline.value, instagram.value ] = [ item.public, `public-${item.id}`, `public-${item.id}`, item.id, item.term_name, item.owner, item.managers, item.city, item.location.address, item.established, item.phone, item.email, item.admin_contact, item.public_contact, item.rate, item.slogan, item.insta_handle, ]; if(item.links.length>0){ let l = ''; item.links.forEach(li => { l += `[${li.url}, ${li.title}, ${li.tracker}]`; }); links.value = l.trim(); } if(item.followers.length>0){ let l = ''; item.followers.forEach(f=>{ l+= `[${f.count}, ${f.source}, ${formatDate(f.checked)}]`; }); socialFollowers.value = l; } if(item.keywords.length > 0){ let l = []; item.keywords.forEach(keyword => { l.push(keyword.keyword); }); keywords.value =l.join(', '); } if(item.languages.length > 0){ let l = []; item.languages.forEach(language => { l.push(language.language); }); languages.value =l.join(', '); } return row; } createPartnerElement(item){ let row = window.getTemplate(this.row); row.id = item.id; let [ input, inputLabel, editButton ] = [ row.querySelector('[data-id="actions"] input'), row.querySelector('[data-id="actions"] label'), row.querySelector('[data-id="actions"] button'), ]; [ input.checked, input.id, inputLabel.htmlFor, editButton.dataset.id ] = [ item.public, `public-${item.id}`, `public-${item.id}`, item.id ]; return row; } createStyleElement(item){ let row = window.getTemplate(this.row); row.id = item.id; let fields = row.querySelectorAll('td'); fields.forEach(field => { let name = field.dataset.id; // field.label.remove(); // field.querySelector('.description').remove(); let input = field.querySelector('input[type="text"]'); if(input){ input.value = item[name]; } }); let [ input, inputLabel, editButton ] = [ row.querySelector('[data-id="actions"] input'), row.querySelector('[data-id="actions"] label'), row.querySelector('[data-id="actions"] button'), ]; [ input.checked, input.id, inputLabel.htmlFor, editButton.dataset.id ] = [ item.public, `public-${item.id}`, `public-${item.id}`, item.id ]; return row; } createArtistElement(item){ let row = window.getTemplate(this.row); row.id = item.id; let [ input, inputLabel, editButton, display, first, phone, email, links, admin, publicContact, socialFollowers, instagram, type, city, shop, rate, language, keywords, credential ] = [ row.querySelector('[data-id="actions"] input'), row.querySelector('[data-id="actions"] label'), row.querySelector('[data-id="actions"] button'), row.querySelector('[data-id="display_name"] input'), row.querySelector('[data-id="first_name"] input'), row.querySelector('[data-id="phone"] input'), row.querySelector('[data-id="email"] input'), row.querySelector('[data-id="links"] input'), row.querySelector('[data-id="admin_contact"] input'), row.querySelector('[data-id="public_contact"] input'), row.querySelector('[data-id="followers"] input'), row.querySelector('[data-id="insta_handle"] input'), row.querySelector('[data-id="type"] input'), row.querySelector('[data-id="city"] input'), row.querySelector('[data-id="shop"] input'), row.querySelector('[data-id="rate"] input'), row.querySelector('[data-id="languages"] input'), row.querySelector('[data-id="keywords"] input'), row.querySelector('[data-id="credentials"] input') ]; [ input.checked, input.id, inputLabel.htmlFor, editButton.dataset.id, display.value, first.value, phone.value, email.value, admin.value, publicContact.value, type.value, city.value, shop.value, instagram.value, rate.value, ] = [ item.public, `public-${item.id}`, `public-${item.id}`, item.id, item['display_name'], item['first_name'], item.phone, item.email, item.admin_contact, item.public_contact, item.type, item.city, item.shop, item['insta_handle'], item.rate, ]; if(item.links.length>0){ let l = ''; item.links.forEach(li => { l += `[${li.url}, ${li.title}, ${li.tracker}]`; }); links.value = l.trim(); } if(item.followers.length>0){ let l = ''; item.followers.forEach(f=>{ l+= `[${f.count}, ${f.source}, ${formatDate(f.checked)}]`; }); socialFollowers.value = l; } if(item.keywords.length > 0){ let l = []; item.keywords.forEach(keyword => { l.push(keyword.keyword); }); keywords.value =l.join(', '); } if(item.languages.length > 0){ let l = []; item.languages.forEach(language => { l.push(language.language); }); language.value =l.join(', '); } return row; } /** * Handle errors * @param {Error} error - Error object * @param {string} action - Action being performed when error occurred */ handleError(error, action) { console.error(`News error (${action}):`, error); // Log with error handler if available if (window.jvbError) { window.jvbError.log(error, { component: 'Admin', action: action }); } // Announce to screen readers if (window.jvbA11y) { window.jvbA11y.announce(`Error ${action}. ${error.message || 'Please try again.'}`); } } } // Initialize when DOM is ready document.addEventListener('DOMContentLoaded', () => { new JVBAdmin(); }); function mapToObj(map){ return Array.from(map).reduce((obj, [key, value]) => { if(value instanceof Map){ value = mapToObj(value); } obj[key] = value; return obj; }, {}); }