/** * FavouritesManager - Manages user favourites and lists * Works with FavouritesRoutes.php on the backend */ class FavouritesManager { constructor() { this.queue = window.jvbQueue; this.loadingManager = window.jvbLoading; this.cache = window.jvbCache; this.a11y = window.jvbA11y; this.error = window.jvbError; this.tabs = new window.jvbTabs(document.querySelector('.replace')); // Core configuration this.config = { endpoints: { favourites: 'favourites', lists: 'favourites/lists', shares: 'favourites/lists/shares', }, selectors: { container: '.favourites.container', itemsTab: '.tab-content[data-tab="items"]', listsTab: '.tab-content[data-tab="lists"]', grid: '.item-grid', typeFilters: '.type-filters', viewControls: '.view-controls', bulkControls: '.bulk-controls', selectAll: '#select-all', createListModal: '.create-list-modal', addToListModal: '.add-to-list-modal', shareListModal: '.share-list-modal', noItems: '.no-favourites', listContainer: '.lists-container', listDetails: '.list-details', loader: '.favourites-loader' }, defaultPage: 1, defaultPerPage: 24, defaultViewMode: 'grid', // 'grid' or 'list' refreshInterval: 60000, // 1 minute - for background refresh of shared lists toastDuration: 3000 // Duration for toast notifications }; // Add a single document-level event listener for the Escape key document.addEventListener('keydown', this.handleKeyDown.bind(this)); // State variables this.state = { selectedItems: new Set(), page: this.config.defaultPage, filter: { type: 'all', order: 'desc', orderBy: 'date_added' }, view: { mode: localStorage.getItem('favourites_view') || this.config.defaultViewMode, activeTab: 'items' // 'items' or 'lists' }, pagination: { hasMore: false, totalItems: 0, totalPages: 0 }, currentListId: null, loading: false, initialized: false }; // Cache DOM elements this.initDom(); // Set up event listeners this.initEvents(); // Load initial data this.loadInitialData(); // Mark as initialized this.state.initialized = true; } /** * Initialize DOM references */ initDom() { // Cache DOM elements this.container = document.querySelector(this.config.selectors.container); if (!this.container) { console.warn('Favourites container not found'); return; } // Main elements this.grid = this.container.querySelector(this.config.selectors.grid); this.typeFilters = this.container.querySelector(this.config.selectors.typeFilters); this.viewControls = this.container.querySelector(this.config.selectors.viewControls); this.bulkControls = this.container.querySelector(this.config.selectors.bulkControls); this.listContainer = this.container.querySelector(this.config.selectors.listContainer); this.listDetails = this.container.querySelector(this.config.selectors.listDetails); this.loader = this.container.querySelector(this.config.selectors.loader); // Modals this.createListModal = document.querySelector(this.config.selectors.createListModal); this.addToListModal = document.querySelector(this.config.selectors.addToListModal); this.shareListModal = document.querySelector(this.config.selectors.shareListModal); // Initialize view mode from localStorage if (this.grid && this.state.view.mode) { this.grid.classList.add(`${this.state.view.mode}-view`); } } /** * Initialize event listeners */ initEvents() { // Type filters if (this.typeFilters) { this.typeFilters.addEventListener('click', e => { const button = e.target.closest('.type-filter'); if (button) { this.setFilterType(button.dataset.type); } }); } // View controls if (this.viewControls) { this.viewControls.addEventListener('click', e => { const button = e.target.closest('.view-toggle'); if (button) { this.setView(button.dataset.view); } }); } // Bulk selection if (this.container) { // Select all checkbox const selectAll = this.container.querySelector(this.config.selectors.selectAll); if (selectAll) { selectAll.addEventListener('change', () => { this.toggleSelectAll(selectAll.checked); }); } // Individual item selection this.container.addEventListener('change', e => { if (e.target.matches('.item-select input[type=checkbox]')) { this.handleItemSelection(e.target); } }); // Bulk actions const bulkActionSelect = this.container.querySelector('.bulk-action-select'); const applyBulk = this.container.querySelector('.apply-bulk'); if (bulkActionSelect && applyBulk) { applyBulk.addEventListener('click', () => { this.applyBulkAction(bulkActionSelect.value); }); } // Cancel bulk selection const cancelButton = this.container.querySelector('.cancel-bulk'); if (cancelButton) { cancelButton.addEventListener('click', () => { this.clearSelection(); }); } } // Modal events this.initModalEvents(); // Delegate for item-level actions this.container.addEventListener('click', this.handleItemActions.bind(this)); // Infinite scroll if (this.grid) { // Using Intersection Observer for infinite scroll this.setupInfiniteScroll(); } } /** * Initialize modal event handlers */ initModalEvents() { // Create List Modal if (this.createListModal) { const form = this.createListModal.querySelector('form'); if (form) { form.addEventListener('submit', (e) => { e.preventDefault(); this.handleCreateList(new FormData(form)); }); } const cancelButton = this.createListModal.querySelector('.cancel'); if (cancelButton) { cancelButton.addEventListener('click', () => { this.createListModal.close(); }); } } // Add to List Modal if (this.addToListModal) { const form = this.addToListModal.querySelector('form'); if (form) { form.addEventListener('submit', (e) => { e.preventDefault(); this.handleAddToList(new FormData(form)); }); } const cancelButton = this.addToListModal.querySelector('.cancel'); if (cancelButton) { cancelButton.addEventListener('click', () => { this.addToListModal.close(); }); } } // Share List Modal if (this.shareListModal) { const form = this.shareListModal.querySelector('form'); if (form) { form.addEventListener('submit', (e) => { e.preventDefault(); this.handleShareList(new FormData(form)); }); } const cancelButton = this.shareListModal.querySelector('.cancel'); if (cancelButton) { cancelButton.addEventListener('click', () => { this.shareListModal.close(); }); } // Add email button const addEmailButton = this.shareListModal.querySelector('.add-email'); if (addEmailButton) { addEmailButton.addEventListener('click', () => { const emailInput = this.shareListModal.querySelector('#share-email'); if (emailInput && emailInput.value) { this.handleShareList(new FormData(form)); } }); } } } /** * Set up infinite scroll using Intersection Observer */ setupInfiniteScroll() { // Get or create a sentinel element to detect when user scrolls to bottom let sentinel = this.container.querySelector('.scroll-sentinel'); if (!sentinel) { sentinel = document.createElement('div'); sentinel.className = 'scroll-sentinel'; sentinel.setAttribute('aria-hidden', 'true'); this.grid.parentNode.appendChild(sentinel); } // Create and set up the observer const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting && this.state.pagination.hasMore && !this.state.loading) { this.state.page++; this.loadFavourites(); } }); }, { rootMargin: '200px' // Start loading more content before reaching the sentinel }); // Start observing the sentinel observer.observe(sentinel); } /** * Load initial data * TODO: Replace with the basic this.loadFavourites method */ async loadInitialData() { this.loadingManager.show(); try { // Load favourites for first tab await this.loadFavourites(); // Load lists data (non-blocking) this.loadLists().catch(error => { console.error('Error loading lists:', error); }); } catch (error) { this.handleError(error, 'loading initial data'); } finally { this.loadingManager.hide(); } } /** * Load favourites from the server * @returns {Promise} Response data */ async loadFavourites(reset = true) { if(this.state.loading) return; try { this.state.loading = true; this.loadingManager.show(); const params = new URLSearchParams({ page: this.state.page, per_page: this.config.defaultPerPage, type: this.state.filter.type !== 'all' ? this.state.filter.type : '', order: this.state.filter.order, orderby: this.state.filter.orderBy }); if(reset){ this.state.page = 1; removeChildren(this.grid); this.grid.classList.remove('empty'); } const data = await this.cache.fetchWithCache( `${jvbSettings.api}${this.config.endpoints.favourites}?${params}`, { method: 'GET', headers: { 'X-WP-Nonce': window.auth.getNonce(), 'X-Action-Nonce': window.auth.getNonce('favourites'), } },{ context: 'favouritesManager', forceRefresh: true, } ); // Process and render the favourites this.renderFavourites(data.favourites || [], this.state.page > 1); // Update type filters with counts if (data.counts) { this.updateTypeFilters(data.counts); } // Update pagination info if (data.pagination) { this.state.pagination = { hasMore: data.pagination.has_more, totalItems: data.pagination.total_items, totalPages: data.pagination.total_pages }; } return data; } catch (error) { this.handleError(error, 'loading favourites'); throw error; } finally { this.state.loading = false; this.loadingManager.hide(); } } /** * Render favourites to the grid * @param {Array} favourites - Array of favourite items * @param {boolean} append - Whether to append to existing items */ renderFavourites(favourites, append = false) { if (!this.grid) return; // Check for empty state if (favourites.length === 0 && !append) { this.showEmptyState(); return; } // Hide empty state if we have items this.hideEmptyState(); // Clear grid if not appending if (!append) { removeChildren(this.grid); } // Create and append item elements favourites.forEach(item => { const itemEl = this.createItemElement(item); this.grid.appendChild(itemEl); // Initialize any JS functionality needed for the item this.initItemFunctionality(itemEl, item); }); // Update accessibility announcement if (window.jvbA11y) { window.jvbA11y.announce(`${append ? 'Added' : 'Loaded'} ${favourites.length} favourites`); } } /** * Create a DOM element for a favourite item * @param {Object} item - Favourite item data * @returns {HTMLElement} The created element */ createItemElement(item) { const itemEl = document.createElement('div'); itemEl.className = `item ${item.type} favourited`; itemEl.dataset.id = item.target_id; itemEl.dataset.type = item.type; // Sanitize item data for security const title = sanitizeHtml(item.title || false); const notes = sanitizeHtml(item.notes || ''); // Create item template let thumbnailHtml = ''; if (item.thumbnail) { thumbnailHtml = `
${item.thumbnail}
`; } itemEl.innerHTML = `
${thumbnailHtml}
${title ? `

${title}

` : `View Item`} ${item.author ? `
By ${item.author.name}
` : ''} ${item.taxonomies?.length ? `
${item.taxonomies.map(tax => `
${jvbSettings.icons[tax.icon]}
`).join('')}
` : ''}
`; return itemEl; } /** * Initialize JS functionality for an item * @param {HTMLElement} itemEl - The item element * @param {Object} item - Item data */ initItemFunctionality(itemEl, item) { // Toggle notes visibility const notesToggle = itemEl.querySelector('.toggle-notes'); const notesContent = itemEl.querySelector('.notes-content'); if (notesToggle && notesContent) { notesToggle.addEventListener('click', () => { const expanded = notesToggle.ariaExpanded === 'true'; notesToggle.ariaExpanded = !expanded.toString(); notesContent.hidden = expanded; if (!expanded) { notesContent.querySelector('textarea')?.focus(); } }); } // Notes save button const saveButton = itemEl.querySelector('.save-notes'); const notesInput = itemEl.querySelector('.notes-input'); if (saveButton && notesInput) { saveButton.addEventListener('click', () => { this.saveNotes(notesInput); }); // Also save on enter key when textarea is focused notesInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); this.saveNotes(notesInput); } }); } } /** * Save notes for an item * @param {HTMLTextAreaElement} textarea - Notes textarea */ saveNotes(textarea) { if (!textarea) return; const notes = textarea.value.trim(); const itemId = textarea.dataset.id; const itemType = textarea.dataset.type; if (!itemId || !itemType) return; // Queue the operation via QueueManager this.queue.addToQueue({ type: 'favourite_notes', data: { type: itemType, target_id: parseInt(itemId), notes: notes } }); showToast('Notes saved'); this.a11y.announce('Notes saved'); } /** * Show empty state when no items are found */ showEmptyState(list = false) { const emptyEl = this.container.querySelector(this.config.selectors.noItems)??this.createEmptyElement; if (emptyEl) { emptyEl.hidden = false; } if (this.grid) { this.grid.classList.add('empty'); } this.a11y.announce('No favourites to show!'); } /** * Hide empty state */ hideEmptyState() { const emptyEl = this.container.querySelector('.no-favourites'); if (emptyEl) { emptyEl.remove(); } if (this.grid) { this.grid.classList.remove('empty'); } } createEmptyElement(list = false){ const empty = document.createElement('div'); empty.className = 'no-favourites'; empty.innerHTML = `

♡ BLANK CANVAS ♡

You haven't fallen in love with any pieces... yet!

Hit that heart icon when something stops your scroll.

Your dream collection is waiting to start.

`; this.grid.after(empty); } showEmptyListState(list = false){ const empty = document.createElement('div'); empty.className = 'no-favourites'; empty.innerHTML = `

♡ FULL OF POSSIBILITY ♡

There's nothing in this list yet.

Add some gap fillers from the main favourites tab.

`; this.grid.after(empty); this.grid.classList.add('empty'); this.a11y.announce('No favourites to show!'); } /** * Load more items (for infinite scroll) * TODO: remove this, replace with default loadItems */ async loadMoreItems() { if (this.state.loading || !this.state.pagination.hasMore) return; this.state.page += 1; await this.loadFavourites(); } /** * Update type filters with count data * @param {Object} counts - Counts by type */ updateTypeFilters(counts) { if (!this.typeFilters) return; // Update counts for each filter this.typeFilters.querySelectorAll('.type-filter').forEach(btn => { const countEl = btn.querySelector('.count'); if (!countEl) return; const type = btn.dataset.type; if (type === 'all') { // Calculate total for "all" filter const total = Object.values(counts).reduce((sum, count) => sum + (parseInt(count) || 0), 0); countEl.textContent = `(${total})`; } else { countEl.textContent = `(${counts[type] || 0})`; } }); } /** * Set filter type * @param {string} type - Filter type */ setFilterType(type) { if (type === this.state.filter.type) return; // Update active state on filter buttons if (this.typeFilters) { this.typeFilters.querySelectorAll('.type-filter').forEach(btn => { btn.classList.toggle('active', btn.dataset.type === type); btn.setAttribute('aria-selected', btn.dataset.type === type); }); } // Update state this.state.filter.type = type; this.state.page = 1; // Reload with new filter this.loadFavourites(); // Announce to screen readers if (window.jvbA11y) { window.jvbA11y.announce(`Filtered to show ${type === 'all' ? 'all' : type} items`); } } /** * Set view mode (grid/list) * @param {string} viewMode - View mode ('grid' or 'list') */ setView(viewMode) { if (viewMode === this.state.view.mode) return; // Update view toggle buttons if (this.viewControls) { this.viewControls.querySelectorAll('.view-toggle').forEach(btn => { const isActive = btn.dataset.view === viewMode; btn.setAttribute('aria-pressed', isActive); }); } // Update grid class if (this.grid) { this.grid.classList.remove('grid-view', 'list-view'); this.grid.classList.add(`${viewMode}-view`); } // Update state and save preference this.state.view.mode = viewMode; localStorage.setItem('favourites_view', viewMode); // Announce to screen readers if (window.jvbA11y) { window.jvbA11y.announce(`Changed to ${viewMode} view`); } } /** * Toggle select all items * @param {boolean} selected - Whether to select or deselect all */ toggleSelectAll(selected) { // Get all visible items const items = this.getVisibleItems(); // Update checkboxes items.forEach(item => { const checkbox = item.querySelector('.item-select input[type="checkbox"]'); if (checkbox) { checkbox.checked = selected; this.toggleItemSelection(checkbox.value, selected); } }); // Update bulk controls visibility this.updateBulkControls(); // Announce to screen readers if (window.jvbA11y) { window.jvbA11y.announce(selected ? `Selected all ${items.length} items` : 'Deselected all items'); } } /** * Get all visible items * @returns {Array} Array of visible item elements */ getVisibleItems() { if (!this.grid) return []; return Array.from(this.grid.querySelectorAll('.item:not([hidden])')); } /** * Toggle selection of an individual item * @param {string} itemId - Item ID * @param {boolean} selected - Whether to select or deselect */ toggleItemSelection(itemId, selected) { // Add or remove from selected items set if (selected) { this.state.selectedItems.add(itemId); } else { this.state.selectedItems.delete(itemId); } // Toggle selected class on the item element const item = this.grid.querySelector(`.item[data-id="${itemId}"]`); if (item) { item.classList.toggle('selected', selected); } } /** * Handle item selection via checkbox * @param {HTMLInputElement} checkbox - The checkbox element */ handleItemSelection(checkbox) { const isSelected = checkbox.checked; const itemId = checkbox.value; this.toggleItemSelection(itemId, isSelected); this.updateBulkControls(); this.updateSelectAllState(); // Announce to screen readers if (window.jvbA11y) { const itemEl = checkbox.closest('.item'); const itemName = itemEl ? (itemEl.querySelector('h3')?.textContent || 'item') : 'item'; window.jvbA11y.announce(isSelected ? `Selected ${itemName}` : `Deselected ${itemName}`); } } /** * Update select all checkbox state based on current selection */ updateSelectAllState() { const selectAll = this.container.querySelector(this.config.selectors.selectAll); if (!selectAll) return; const visibleItems = this.getVisibleItems(); if (visibleItems.length === 0) { selectAll.checked = false; selectAll.indeterminate = false; return; } const checkedCount = visibleItems.filter(item => { const checkbox = item.querySelector('.item-select input[type="checkbox"]'); return checkbox && checkbox.checked; }).length; if (checkedCount === 0) { selectAll.checked = false; selectAll.indeterminate = false; } else if (checkedCount === visibleItems.length) { selectAll.checked = true; selectAll.indeterminate = false; } else { selectAll.checked = false; selectAll.indeterminate = true; } } /** * Update bulk controls visibility and content */ updateBulkControls() { if (!this.bulkControls) return; const bulkActions = this.bulkControls.querySelector('.bulk-actions'); if (!bulkActions) return; const hasSelectedItems = this.state.selectedItems.size > 0; // Toggle visibility of bulk actions bulkActions.hidden = !hasSelectedItems; // Update selected count const countEl = this.bulkControls.querySelector('.selected-count'); if (countEl) { countEl.textContent = hasSelectedItems ? `${this.state.selectedItems.size} selected` : ''; } } /** * Handle keyboard events for the favourites manager * @param {KeyboardEvent} e - Keyboard event */ handleKeyDown(e) { // If ESC is pressed and we have items selected, clear the selection if (e.key === 'Escape' && this.state.selectedItems.size > 0) { e.preventDefault(); // Prevent default escape behavior like closing dialogs this.clearSelection(); // Announce to screen readers if (window.jvbA11y) { window.jvbA11y.announce('Selection cleared using Escape key'); } } } /** * Clear all selected items */ clearSelection() { // Clear selection state this.state.selectedItems.clear(); // Uncheck all checkboxes this.getVisibleItems().forEach(item => { const checkbox = item.querySelector('.item-select input[type="checkbox"]'); if (checkbox) { checkbox.checked = false; } item.classList.remove('selected'); }); // Reset select all checkbox const selectAll = this.container.querySelector(this.config.selectors.selectAll); if (selectAll) { selectAll.checked = false; selectAll.indeterminate = false; } // Update bulk controls this.updateBulkControls(); // Announce to screen readers if (window.jvbA11y) { window.jvbA11y.announce('Selection cleared'); } } /** * Apply a bulk action to selected items * @param {string} action - Action to apply */ applyBulkAction(action) { if (!action || this.state.selectedItems.size === 0) return; switch (action) { case 'unfavourite': this.bulkUnfavourite(); break; case 'add-to-list': this.showAddToListModal(); break; case 'create-list': this.showCreateListModal(); break; case 'add-notes': this.showBulkNotesModal(); break; } // Reset the dropdown const select = this.container.querySelector('.bulk-action-select'); if (select) { select.value = ''; } } /** * Handle various item actions * @param {Event} e - Click event */ handleItemActions(e) { // Notes toggle if (e.target.closest('.toggle-notes')) { const button = e.target.closest('.toggle-notes'); const isExpanded = button.getAttribute('aria-expanded') === 'true'; const notesContent = button.closest('.notes-section').querySelector('.notes-content'); button.setAttribute('aria-expanded', !isExpanded); notesContent.hidden = isExpanded; if (!isExpanded && notesContent) { notesContent.querySelector('textarea')?.focus(); } e.preventDefault(); return; } // Save notes button if (e.target.closest('.save-notes')) { const button = e.target.closest('.save-notes'); const textarea = button.closest('.notes-content').querySelector('textarea'); if (textarea) { this.saveNotes(textarea); } e.preventDefault(); return; } // View list button if (e.target.closest('.view-list')) { const button = e.target.closest('.view-list'); const listCard = button.closest('.list-card'); if (listCard && listCard.dataset.id) { this.viewList(listCard.dataset.id); } e.preventDefault(); return; } // Share list button if (e.target.closest('.share-list')) { const button = e.target.closest('.share-list'); const listCard = button.closest('.list-card'); if (listCard && listCard.dataset.id) { this.showShareModal(listCard.dataset.id); } e.preventDefault(); return; } // Delete list button if (e.target.closest('.delete-list')) { const button = e.target.closest('.delete-list'); const listCard = button.closest('.list-card'); if (listCard && listCard.dataset.id) { this.deleteList(listCard.dataset.id); } e.preventDefault(); return; } // Back to lists button if (e.target.closest('.back-to-lists')) { this.exitListView(); e.preventDefault(); return; } } /** * Load user's lists from the server * @returns {Promise} Response data */ async loadLists(reset = true) { try { this.state.loading = true; this.loadingManager.show('Loading lists...'); const data = await this.cache.fetchWithCache( `${jvbSettings.api}${this.config.endpoints.lists}`, { method: 'GET', headers: { 'X-WP-Nonce': window.auth.getNonce(), 'X-Action-Nonce': window.auth.getNonce('favourites') } }, { context: 'favourite-lists', forceRefresh: false, } ); // Render lists to UI if (data.lists) { this.renderLists(data.lists); } return data; } catch (error) { this.handleError(error, 'loading lists'); throw error; } finally { this.state.loading = false; this.loadingManager.hide(); } } /** * Render lists to the container * @param {Array} lists - List data array */ renderLists(lists) { if (!this.listContainer) return; // Clear existing content removeChildren(this.listContainer); if (!lists || lists.length === 0) { this.listContainer.innerHTML = `

No Lists Yet

Select favourites from the main tab to organize into lists.

`; return; } // Separate owned and shared lists const ownedLists = lists.owned; const sharedLists = lists.shared; // Create owned lists section if (ownedLists.length > 0) { const ownedSection = document.createElement('details'); ownedSection.className = 'lists-section owned-lists'; ownedSection.open = true; ownedSection.innerHTML = `Your Lists:`; // Create list cards ownedLists.forEach(list => { const card = this.createListCard(list); ownedSection.appendChild(card); }); this.listContainer.appendChild(ownedSection); } // Create shared lists section if (sharedLists.length > 0) { const sharedSection = document.createElement('details'); sharedSection.className = 'lists-section shared-lists'; sharedSection.innerHTML = `Lists Shared with You:`; // Create list cards sharedLists.forEach(list => { const card = this.createListCard(list); sharedSection.appendChild(card); }); this.listContainer.appendChild(sharedSection); } } /** * Create a list card element * @param {Object} list - List data * @returns {HTMLElement} List card element */ createListCard(list) { const card = document.createElement('div'); card.className = 'list-card'; card.dataset.id = list.id; const isShared = list.is_shared === '1'; if (isShared) { card.classList.add('shared'); } if (list.is_temp) { card.classList.add('temp'); } const isOwner = list.is_owner === '1'; const name = sanitizeHtml(list.name || 'Untitled List'); const description = sanitizeHtml(list.description || ''); card.innerHTML = `

${name}

${!isShared ? ` ` : ''}
${description ? `

${description}

` : ''}
${list.item_count || 0} items ${formatDate(list.created_at)}
${isShared ? `
Shared by ${list.owner_name || 'another user'}
` : list.share_count > 0 ? ` ` : ''}
`; return card; } /** * View a specific list * @param {string} listId - List ID */ async viewList(listId) { try { this.state.loading = true; this.loadingManager.show('Loading list...'); // Set current list ID this.state.currentListId = listId; // Fetch list details from server const data = await this.cache.fetchWithCache( `${jvbSettings.api}${this.config.endpoints.lists}?id=${listId}`, { method: 'GET', headers: { 'X-WP-Nonce': window.auth.getNonce(), 'X-Action-Nonce': window.auth.getNonce('favourites') } }, { context: 'list-item', forceRefresh: false, } ); if (data.list) { this.showListDetails(data.list); } else { throw new Error('List not found'); } } catch (error) { this.handleError(error, 'viewing list'); } finally { this.state.loading = false; this.loadingManager.hide(); } } /** * Display list details view * @param {Object} list - List data */ showListDetails(list) { if (!this.listDetails || !this.listContainer) return; console.log(list); // Update list title this.listDetails.querySelector('.list-title').value= list.name || 'Untitled List'; this.listDetails.querySelector('.list-description').value = list.description || ''; if(!list['is_owner']){ this.listDetails.querySelector('.list-actions')?.remove(); }else if(!this.listDetails.querySelector('.list-actions')){ this.createListActions(); } removeChildren(this.grid); this.renderFavourites(list.items || [], false); if(list.items.length === 0){ this.showEmptyListState(); } // Announce to screen readers if (window.jvbA11y) { window.jvbA11y.announce(`Viewing list: ${list.name} with ${list.items?.length || 0} items`); } } createListActions(){ const actions = document.createElement('div'); actions.className = 'list-actions'; actions.innerHTML = ` `; this.listDetails.insertBefore(actions, this.listDetails.querySelector('.bulk-controls')); } /** * Exit list view and return to lists overview */ exitListView() { if (!this.listDetails || !this.listContainer) return; // Hide details and show list container this.listDetails.hidden = true; this.listContainer.hidden = false; // Remove viewing-list class this.container.classList.remove('viewing-list'); // Clear current list ID this.state.currentListId = null; // Announce to screen readers if (window.jvbA11y) { window.jvbA11y.announce('Returned to lists view'); } } /** * Show create list modal */ showCreateListModal() { if (!this.createListModal) return; // Reset form this.createListModal.querySelector('form')?.reset(); // Show modal this.createListModal.showModal(); // Focus first input setTimeout(() => { this.createListModal.querySelector('#list-name')?.focus(); }, 100); // Announce to screen readers if (window.jvbA11y) { window.jvbA11y.announce('Create list dialog opened'); } } /** * Show add to list modal */ showAddToListModal() { if (!this.addToListModal) return; // Populate lists options this.populateAddToListModal(); // Show modal this.addToListModal.showModal(); // Announce to screen readers if (window.jvbA11y) { window.jvbA11y.announce('Add to list dialog opened'); } } /** * Populate add to list modal with available lists */ async populateAddToListModal() { if (!this.addToListModal) return; const listsContainer = this.addToListModal.querySelector('.lists-options'); if (!listsContainer) return; // Clear existing content removeChildren(listsContainer); try { const data = await this.loadLists(); // Filter to only include owned lists const ownedLists = data.lists.owned; if (ownedLists.length === 0) { // No lists available listsContainer.innerHTML = `

You don't have any lists yet.

`; // Add event listener for create list button listsContainer.querySelector('.create-list-button')?.addEventListener('click', () => { this.addToListModal.close(); this.showCreateListModal(); }); return; } // Create checkboxes for each list ownedLists.forEach(list => { const listOption = document.createElement('div'); listOption.className = 'list-option'; listOption.innerHTML = ` `; listsContainer.appendChild(listOption); }); } catch (error) { listsContainer.innerHTML = `

Error loading lists. Please try again.

`; console.error('Error loading lists for modal:', error); } } /** * Handle creating a new list * @param {FormData} formData - Form data */ async handleCreateList(formData) { const listName = formData.get('list_name'); const description = formData.get('list_description'); if (!listName) { showToast('Please enter a list name', 'error'); return; } try { this.showLoader('Creating list...'); // Get selected items const items = []; this.state.selectedItems.forEach(id => { const itemEl = this.grid.querySelector(`.item[data-id="${id}"]`); if (itemEl) { items.push({ type: itemEl.dataset.type, target_id: parseInt(id) }); } }); // Queue the operation this.queue.addToQueue({ type: 'favourite_list_create', data: { name: listName, description: description, items: items } }); // Show success message showToast(`List "${listName}" created`); this.a11y.announce(`List ${listName} created with ${items.length} items`); // Close modal this.createListModal.close(); // Clear selection this.clearSelection(); // Switch to lists tab this.switchTab('lists'); } catch (error) { this.handleError(error, 'creating list'); } finally { this.hideLoader(); } } /** * Handle adding items to list * @param {FormData} formData - Form data */ async handleAddToList(formData) { const listIds = formData.getAll('list_ids[]'); if (!listIds.length) { showToast('Please select at least one list', 'error'); return; } try { this.showLoader('Adding to list...'); // Get selected items const items = []; this.state.selectedItems.forEach(id => { const itemEl = this.grid.querySelector(`.item[data-id="${id}"]`); if (itemEl) { items.push({ type: itemEl.dataset.type, target_id: parseInt(id) }); } }); // Queue an operation for each selected list this.queue.addToQueue({ type: 'favourite_list_add', data: { list_id: listIds.join(','), items: items } }); //Show success message showToast(`Added to ${listIds.length} ${listIds.length === 1 ? 'list' : 'lists'}`); this.a11y.announce(`Added ${items.length} items to ${listIds.length} ${listIds.length === 1 ? 'list' : 'lists'}`); // Close modal this.addToListModal.close(); // Clear selection this.clearSelection(); } catch (error) { this.handleError(error, 'adding to list'); } finally { this.hideLoader(); } } /** * Handle adding items to list * @param {FormData} formData - Form data */ async handleRemoveFromList(formData) { const listIds = formData.getAll('list_ids[]'); if (!listIds.length) { showToast('Please select at least one list', 'error'); return; } try { this.showLoader('Removing from list...'); // Get selected items const items = []; this.state.selectedItems.forEach(id => { const itemEl = this.grid.querySelector(`.item[data-id="${id}"]`); if (itemEl) { items.push({ type: itemEl.dataset.type, target_id: parseInt(id) }); } }); // Queue an operation for each selected list this.queue.addToQueue({ type: 'favourite_list_remove', data: { list_id: listIds.join(','), items: items } }); //Show success message showToast(`Removed from ${listIds.length} ${listIds.length === 1 ? 'list' : 'lists'}`); this.a11y.announce(`Removed ${items.length} items to ${listIds.length} ${listIds.length === 1 ? 'list' : 'lists'}`); // Close modal this.addToListModal.close(); // Clear selection this.clearSelection(); } catch (error) { this.handleError(error, 'remove from list'); } finally { this.hideLoader(); } } /** * Show share list modal * @param {string} listId - List ID */ showShareModal(listId) { if (!this.shareListModal) return; // Store current list ID this.state.currentListId = listId; // Reset form this.shareListModal.querySelector('form')?.reset(); // Load shared users this.loadSharedUsers(listId); // Show modal this.shareListModal.showModal(); // Focus email input setTimeout(() => { this.shareListModal.querySelector('#share-email')?.focus(); }, 100); // Announce to screen readers if (window.jvbA11y) { window.jvbA11y.announce('Share list dialog opened'); } } /** * Load users a list is shared with * @param {string} listId - List ID */ async loadSharedUsers(listId) { try { // Get shared users container const container = this.shareListModal.querySelector('.shared-users'); if (!container) return; // Show loading state container.innerHTML = '
Loading shared users...
'; // Fetch list details including shared users const data = await this.cache.fetchWithCache( `${jvbSettings.api}${this.config.endpoints.lists}?id=${listId}`, { method: 'GET', headers: { 'X-WP-Nonce': window.auth.getNonce(), 'X-Action-Nonce': window.auth.getNonce('favourites') } }, { context: 'list-item', forceRefresh: false, } ); // Clear container removeChildren(container); if (data.list && data.list.shared_users && data.list.shared_users.length > 0) { // Create element for each shared user data.list.shared_users.forEach(user => { const userEl = document.createElement('div'); userEl.className = `shared-user ${user.status}`; userEl.innerHTML = ` ${user.email} ${user.status === 'pending' ? `Invitation sent` : `${user.permission_type || 'view'}` } `; container.appendChild(userEl); }); // Add event listeners for remove buttons container.querySelectorAll('.remove-share').forEach(btn => { btn.addEventListener('click', () => { this.unshareList(btn.dataset.email); }); }); } else { // Show empty state container.innerHTML = '
This list is not shared with anyone yet.
'; } } catch (error) { console.error('Error loading shared users:', error); } } /** * Remove a user's access to a list * @param {string} email - Email address to remove */ async unshareList(email) { if (!confirm(`Remove ${email}'s access to this list?`)) return; if (!this.state.currentListId) { showToast('No list selected', 'error'); return; } try { this.showLoader('Removing access...'); // Queue the operation this.queue.addToQueue({ type: 'favourite_list_unshare', data: { list_id: parseInt(this.state.currentListId), email: email } }); // Update UI optimistically const sharedUser = Array.from(this.shareListModal.querySelectorAll('.shared-user')).find( el => el.querySelector('.user-email')?.textContent === email ); if (sharedUser) { // Add removing class for animation sharedUser.classList.add('removing'); // Remove after animation setTimeout(() => { sharedUser.remove(); // Show empty state if no more shared users if (this.shareListModal.querySelectorAll('.shared-user').length === 0) { const container = this.shareListModal.querySelector('.shared-users'); if (container) { container.innerHTML = '
This list is not shared with anyone yet.
'; } } }, 300); } // Show success message showToast(`Removed ${email}'s access`); // Announce to screen readers this.a11y.announce(`Removed ${email}'s access to list`); } catch (error) { this.handleError(error, 'removing share access'); } finally { this.hideLoader(); } } /** * Delete a list * @param {string} listId - List ID */ async deleteList(listId) { if (!confirm('Are you sure you want to delete this list? This cannot be undone.')) return; try { this.showLoader('Deleting list...'); // Queue the operation this.queue.addToQueue({ type: 'favourite_list_delete', data: { list_id: parseInt(listId) } }); // Update UI optimistically const listCard = this.container.querySelector(`.list-card[data-id="${listId}"]`); if (listCard) { // Add removing class for animation listCard.classList.add('removing'); // Remove after animation setTimeout(() => { listCard.remove(); // Check if we need to show empty state const remainingCards = this.container.querySelectorAll('.list-card').length; if (remainingCards === 0) { this.listContainer.innerHTML = `

No Lists Yet

Create your first list to organize your favourites!

`; } }, 300); } // Show success message showToast('List deleted'); // Announce to screen readers this.a11y.announce('List deleted'); } catch (error) { this.handleError(error, 'deleting list'); } finally { this.hideLoader(); } } /** * Show bulk notes modal */ showBulkNotesModal() { // Check if we've created the modal yet let bulkNotesModal = document.querySelector('.bulk-notes-modal'); // Create modal if it doesn't exist if (!bulkNotesModal) { bulkNotesModal = document.createElement('dialog'); bulkNotesModal.className = 'bulk-notes-modal'; bulkNotesModal.innerHTML = `

Add Notes to Selected Items

`; document.body.appendChild(bulkNotesModal); // Add event listeners const form = bulkNotesModal.querySelector('form'); form.addEventListener('submit', (e) => { e.preventDefault(); const notes = bulkNotesModal.querySelector('#bulk-notes').value; this.saveBulkNotes(notes); bulkNotesModal.close(); }); const cancelButton = bulkNotesModal.querySelector('.cancel'); cancelButton.addEventListener('click', () => { bulkNotesModal.close(); }); } // Reset form bulkNotesModal.querySelector('form')?.reset(); // Show modal bulkNotesModal.showModal(); // Focus textarea setTimeout(() => { bulkNotesModal.querySelector('#bulk-notes')?.focus(); }, 100); // Announce to screen readers if (window.jvbA11y) { window.jvbA11y.announce('Add notes dialog opened'); } } /** * Save notes to multiple items * @param {string} notes - Notes text */ saveBulkNotes(notes) { if (!notes) return; try { this.showLoader('Saving notes...'); let items = Array.from(this.state.selectedItems.values()); this.queue.addToQueue({ type: 'favourite_notes', data:{ target_id: items.join(','), notes: notes } }) // Show success message showToast(`Notes saved for ${items.length} items`); // Announce to screen readers this.a11y.announce(`Notes saved for ${items.length} items`); // Clear selection this.clearSelection(); } catch (error) { this.handleError(error, 'saving bulk notes'); } finally { this.hideLoader(); } } /** * Remove selected items from favourites */ async bulkUnfavourite() { if (!confirm('Are you sure you want to remove these items from your favourites?')) return; try { this.showLoader('Removing from favourites...'); // Create batch operation data const items = []; this.state.selectedItems.forEach(id => { const itemEl = this.grid.querySelector(`.item[data-id="${id}"]`); if (!itemEl) return; const type = itemEl.dataset.type; items.push({ target_id: parseInt(id), type: type, action: 'remove' }); }); // Queue the operation this.queue.addToQueue({ type: 'favourite_toggle', data: items }); // Update UI immediately for better UX const fadePromises = []; this.state.selectedItems.forEach(id => { const itemEl = this.grid.querySelector(`.item[data-id="${id}"]`); if (!itemEl) return; // Add removing class for animation itemEl.style.opacity = '0'; itemEl.style.transform = 'scale(0.9)'; itemEl.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; // Create promise for removal after animation const fadePromise = new Promise(resolve => { setTimeout(() => { itemEl.remove(); resolve(); }, 300); }); fadePromises.push(fadePromise); }); // Wait for all animations to complete await Promise.all(fadePromises); // Check if grid is now empty if (this.grid.children.length === 0) { this.showEmptyState(); } // Clear selection this.clearSelection(); // Show success message showToast(`Removed ${items.length} items from favourites`); // Announce to screen readers this.a11y.announce(`Removed ${items.length} items from favourites`); } catch (error) { this.handleError(error, 'removing favourites'); } finally { this.hideLoader(); } } /** * Handle sharing a list * @param {FormData} formData - Form data */ async handleShareList(formData) { const email = formData.get('share_email'); if (!email) { showToast('Please enter an email address', 'error'); return; } if (!this.validateEmail(email)) { showToast('Please enter a valid email address', 'error'); return; } try { this.showLoader('Sharing list...'); // Queue the operation this.queue.addToQueue({ type: 'favourite_list_share', data: { list_id: parseInt(this.state.currentListId), email: email, permission_type: 'view' // Default to view permission } }); // Clear the email input this.shareListModal.querySelector('#share-email').value = ''; // Update shared users list this.loadSharedUsers(this.state.currentListId); // Show success message showToast(`Invitation sent to ${email}`); // Announce to screen readers this.a11y.announce(`Invitation sent to ${email}`); } catch (error) { this.handleError(error, 'sharing list'); } finally { this.hideLoader(); } } /** * Show loading indicator * @param {string} message - Optional message to display */ showLoader(message = 'Loading...') { if (!this.loader) return; const messageEl = this.loader.querySelector('.loader-message'); if (messageEl) { messageEl.textContent = message; } this.loader.hidden = false; } /** * Hide loading indicator */ hideLoader() { if (this.loader) { this.loader.hidden = true; } } showToast(message, type){ window.jvbNotifications.showToast(message, type); } /** * Handle errors * @param {Error} error - Error object * @param {string} action - Action being performed when error occurred */ handleError(error, action) { console.error(`Favourites error (${action}):`, error); // Show toast notification showToast( `Error ${action}: ${error.message || 'Something went wrong'}`, 'error' ); // Log with error handler if available if (window.jvbError) { window.jvbError.log(error, { component: 'FavouritesManager', action: action }); } // Announce to screen readers if (window.jvbA11y) { window.jvbA11y.announce(`Error ${action}. ${error.message || 'Please try again.'}`); } } /** * Validate email format * @param {string} email - Email to validate * @returns {boolean} Whether email is valid */ validateEmail(email) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); } } window.favouritesManager = FavouritesManager;