/**
|
* 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<Object>} 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 = `
|
<div class="item-thumbnail">
|
<a href="${item.url}">${item.thumbnail}</a>
|
</div>
|
`;
|
}
|
|
itemEl.innerHTML = `
|
<div class="item-select">
|
<input type="checkbox"
|
class="favourite-checkbox"
|
id="select-${item.target_id}"
|
value="${item.target_id}">
|
<label for="select-${item.target_id}"><span class="screen-reader-text">Select this ${item.type}</span</label>
|
</div>
|
|
<button type="button" class="favourite-button favourited"
|
onclick="toggleFavourite(this)"
|
data-id="${item.target_id}"
|
data-type="${item.type}"
|
title="Remove from favourites">
|
${jvbSettings.icons['heart-filled']}
|
</button>
|
|
${thumbnailHtml}
|
|
<div class="item-info">
|
${title ? `<h3><a href="${item.url}">${title}</a></h3>` :
|
`<a href="${item.url}">View Item</a>`}
|
|
${item.author ? `
|
<div class="item-artist">
|
<span>By ${item.author.name}</span>
|
</div>` : ''}
|
|
${item.taxonomies?.length ? `
|
<div class="taxonomy-lists">
|
${item.taxonomies.map(tax => `
|
<div class="taxonomy-group">
|
${jvbSettings.icons[tax.icon]}
|
<ul>
|
${tax.terms.slice(0, 3).map(term => `
|
<li>
|
<a href="${term.url}" ${term.umami_click}>
|
${term.title}
|
</a>
|
</li>
|
`).join('')}
|
</ul>
|
</div>
|
`).join('')}
|
</div>
|
` : ''}
|
|
<div class="notes-section">
|
<button type="button" class="toggle-notes" aria-expanded="false">
|
${jvbSettings.icons['note'] || 'Notes'}
|
<span>Notes</span>
|
</button>
|
|
<div class="notes-content" hidden>
|
<textarea class="notes-input"
|
placeholder="Add notes about this item..."
|
data-id="${item.target_id}"
|
data-type="${item.type}">${notes}</textarea>
|
<button type="button" class="save-notes">Save Notes</button>
|
</div>
|
</div>
|
</div>
|
`;
|
|
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 = `
|
<h3>♡ BLANK CANVAS ♡</h3>
|
<p>You haven't fallen in love with any pieces... yet!</p>
|
<p>Hit that heart icon when something stops your scroll.</p>
|
<p>Your dream collection is waiting to start.</p>
|
`;
|
this.grid.after(empty);
|
}
|
|
showEmptyListState(list = false){
|
const empty = document.createElement('div');
|
empty.className = 'no-favourites';
|
empty.innerHTML = `
|
<h3>♡ FULL OF POSSIBILITY ♡</h3>
|
<p>There's nothing in this list yet.</p>
|
<p>Add some gap fillers from the main favourites tab.</p>
|
`;
|
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<Object>} 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 = `
|
<div class="no-lists">
|
<h3>No Lists Yet</h3>
|
<p>Select favourites from the main tab to organize into lists.</p>
|
</div>
|
`;
|
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 = `<summary>Your Lists:</summary>`;
|
|
// 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 = `<summary>Lists Shared with You:</summary>`;
|
|
// 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 = `
|
<div class="list-header">
|
<h3>${name}</h3>
|
<div class="list-actions">
|
<button type="button" class="view-list" title="View List">
|
${jvbSettings.icons?.show || 'View'}
|
</button>
|
${!isShared ? `
|
<button type="button" class="share-list" title="Share List">
|
${jvbSettings.icons?.share || 'Share'}
|
</button>
|
<button type="button" class="delete-list" title="Delete List">
|
${jvbSettings.icons?.delete || 'Delete'}
|
</button>
|
` : ''}
|
</div>
|
</div>
|
|
${description ? `<p class="list-description">${description}</p>` : ''}
|
|
<div class="list-meta">
|
<div class="meta-stats">
|
<span class="item-count">${list.item_count || 0} items</span>
|
<span class="date">${formatDate(list.created_at)}</span>
|
</div>
|
|
|
${isShared ? `
|
<div class="owner-info">
|
Shared by ${list.owner_name || 'another user'}
|
</div>
|
` : list.share_count > 0 ? `
|
<div class="share-info">
|
Shared with ${list.share_count} ${list.share_count === 1 ? 'person' : 'people'}
|
</div>
|
` : ''}
|
</div>
|
`;
|
|
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 = `
|
<button type="button" class="share-list" title="Share List">
|
<i class="icon icon-share-fat"></i>
|
<span>Share</span>
|
</button>
|
<button type="button" class="duplicate-list" title="Duplicate List">
|
<i class="icon icon-copy"></i>
|
<span>Duplicate</span>
|
</button>
|
<button type="button" class="delete-list" title="Delete List">
|
<i class="icon icon-trash"></i>
|
<span>Delete</span>
|
</button>
|
`;
|
|
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 = `
|
<div class="no-lists">
|
<p>You don't have any lists yet.</p>
|
<button type="button" class="create-list-button">Create a list</button>
|
</div>
|
`;
|
|
// 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 = `
|
<input type="checkbox" id="${list.id}" name="list_ids[]" value="${list.id}">
|
<label for="${list.id}">
|
|
<span class="list-name">${sanitizeHtml(list.name)}</span>
|
<span class="item-count">( ${list.item_count || 0} items )</span>
|
</label>
|
`;
|
|
listsContainer.appendChild(listOption);
|
});
|
|
} catch (error) {
|
listsContainer.innerHTML = `
|
<div class="error-message">
|
<p>Error loading lists. Please try again.</p>
|
</div>
|
`;
|
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 = '<div class="loading">Loading shared users...</div>';
|
|
// 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 = `
|
<span class="user-email">${user.email}</span>
|
${user.status === 'pending' ?
|
`<span class="pending-badge">Invitation sent</span>` :
|
`<span class="permission-badge">${user.permission_type || 'view'}</span>`
|
}
|
<button type="button" class="remove-share" data-email="${user.email}">
|
${jvbSettings.icons?.delete || 'Remove'}
|
</button>
|
`;
|
|
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 = '<div class="no-shares">This list is not shared with anyone yet.</div>';
|
}
|
} 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 = '<div class="no-shares">This list is not shared with anyone yet.</div>';
|
}
|
}
|
}, 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 = `
|
<div class="no-lists">
|
<h3>No Lists Yet</h3>
|
<p>Create your first list to organize your favourites!</p>
|
</div>
|
`;
|
}
|
}, 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 = `
|
<form method="dialog" data-save="favourites">
|
<h2>Add Notes to Selected Items</h2>
|
|
<div class="field">
|
<label for="bulk-notes">Notes (will be applied to all selected items)</label>
|
<textarea id="bulk-notes" name="bulk_notes" rows="5"></textarea>
|
</div>
|
|
<div class="actions">
|
<button type="button" class="cancel">Cancel</button>
|
<button type="submit" class="save">Save Notes</button>
|
</div>
|
</form>
|
`;
|
|
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;
|