/** * The base CRUD manager: helps reduce code duplication between managing different content types */ class CRUD { /** * @param config */ constructor(config) { console.log('Initializing Crud.js'); this.config = { content: '', type: 'post', api: 'content', batchSize: 10, upload: { mode: 'direct', allowMultiple: true, allowedTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] }, selectors: { container: '.replace', gridWrap: '.items-list', grid: '.items-list .item-grid', view: '.radio-options.view', columnsSelect: 'details.multi-select', bulk: '.bulk-controls', bulkActions: '.bulk-action-select', selectAll: 'input.select-all', count: '.bulk-controls .selected-count', search: 'input[type="search"]', scroll: '.scroll-sentinel', add: '.create-item', dateRange: 'dialog.date-range', filters: '.items-list .all-filters', clearButton: '.clear-filters', uploader: 'details.uploader' }, filters: {}, modals: {}, tabs: {}, ...config }; this.editing = false; this.api = this.config.api; this.content = this.config.content; this.plural = jvbSettings.labels[this.content.replace('-', '_')].plural ?? this.content + 's'; this.single = jvbSettings.labels[this.content.replace('-', '_')].single ?? this.content; this.batchSize = this.config.batchSize; // for renderItems document fragment this.tabs = window.isEmptyObject(this.config.tabs) ? false : this.config.tabs; this.tabNav = false; this.editModal = document.querySelector('dialog.edit-modal').firstElementChild.cloneNode(true).cloneNode(true); this.bulkEditModal = document.querySelector('dialog.bulk-edit-modal').firstElementChild.cloneNode(true).cloneNode(true); this.createModal = document.querySelector('dialog.create-modal').firstElementChild.cloneNode(true).cloneNode(true); this.config.modals = { create: { open: '.create-item', selector: '.create-modal', openMessage: 'Opened Create New ' + this.single + ' Modal', closeMessage: 'Closed Create New ' + this.single + ' Modal', onOpen: () => this.openCreateModal(), onRender: () => this.renderCreateModal(), onClose: () => this.closeCreateModal(), onSave: (changes) => this.handleCreateModalSave(changes), }, edit: { selector: '.edit-modal', open: 'button[data-action="edit"]', openMessage: 'Opened Edit ' + this.single + ' Modal', closeMessage: 'Closed Edit ' + this.single + ' Modal', // onOpen: (e, modal) => this.openEditModal(e, modal), onRender: (e, modal) => this.renderEditModal(e, modal), onClose: (e, changes) => this.closeEditModal(e, changes), onSave: (changes) => this.handleEditModalSave(changes), }, bulkEdit: { selector: '.bulk-edit-modal', openMessage: 'Opened Bulk Edit ' + this.plural + ' Modal', closeMessage: 'Closed Bulk Edit ' + this.plural + ' Modal', onOpen: (e, modal) => this.openBulkEditModal(e, modal), onRender: () => this.renderBulkEditModal(), onClose: (e, changes) => this.closeBulkEditModal(e, changes), onSave: (changes) => this.handleBulkEditModalSave(changes), }, dateRange: { selector: '.date-range', openMessage: 'Opened Date Range selection', closeMessage: 'Closed Date Range selection', onOpen: () => this.handleDateRangeOpen(), onClose: () => this.handleDateRangeClose(), } , ...this.config.modals }; //Core components this.a11y = window.jvbA11y; this.errors = window.jvbError; this.cache = window.jvbCache; this.queue = window.jvbQueue; this.loading = window.jvbLoading; window.jvbLoading.setContent(this.content); //Filters Management this.filters = {}; this.resetFilters(); this.isLoading = false; this.hasMore = true; this.totalItems = null; this.pages = 1; //View Management this.view = null; this.views = new Map(); this.viewSettings = {}; //Stores fetched posts this.posts = new Map(); //Stores selected items in a map this.selected = new Set(); this.initElements(); this.initListeners(); this.table = null; this.isTable = false; this.initView(); this.handleEscape = (e) => { if (e.key === 'Escape' && this.selected.size > 0) { this.clearSelection(); this.elements.selectAll.nextElementSibling.firstElementChild.innerText = 'Select All'; this.a11y.announce('Selection cleared'); } } this.lastSelectedItem = null; // Track last selected item for range selection this.selectionMode = false; } async initView() { this.views.set('grid', { name: 'grid', type: 'visual', template: 'gridView', containerClass: 'grid-view', init: () => this.initVisualView('grid'), activate: () => this.activateVisualView('grid'), deactivate: () => this.deactivateVisualView('grid'), render: (items, append) => this.renderVisualItems(items, 'grid', append) }); this.views.set('list', { name: 'list', type: 'visual', template: 'listView', containerClass: 'list-view', init: () => this.initVisualView('list'), activate: () => this.activateVisualView('list'), deactivate: () => this.deactivateVisualView('list'), render: (items, append) => this.renderVisualItems(items, 'list', append) }); this.views.set('table', { name: 'table', type: 'editable', template: 'tableView', containerClass: 'table-view', tableContainer: null, tableForm: null, init: () => this.initTableView(), activate: () => this.activateTableView(), deactivate: () => this.deactivateTableView(), render: (items, append) => this.renderTableItems(items, append), cleanup: () => this.cleanupTableView() }); let view = await this.loadViewSettings(); await this.switchView(view); await this.loadContent(true); } clearSelection() { if (this.selected.size === 0) { return; } this.selected.clear(); let checkboxes; if (this.isTable) { checkboxes = document.querySelectorAll('table .select-checkbox:checked'); } else { checkboxes = this.elements.grid.querySelectorAll('.select-checkbox:checked'); } checkboxes.forEach((check) => { check.checked = false; }); this.elements.selectAll.checked = false; this.lastSelectedItem = null; // Reset last selected item this.selectionMode = false; // Exit selection mode this.updateBulkControls(); } initElements() { this.elements = {}; // Cache all configured selectors Object.entries(this.config.selectors).forEach(([key, selector]) => { if (typeof selector === 'object') { this.elements[key] = {}; Object.entries(selector).forEach(([subKey, subSelector]) => { this.elements[key][subKey] = document.querySelector(subSelector); }); } else { this.elements[key] = document.querySelector(selector); } }); // Ensure required elements exist if (!this.elements.container) { throw new Error(`CRUD Manager: Container element not found for ${this.config.content}`); } if (!this.elements.grid) { throw new Error(`CRUD Manager: Grid element not found for ${this.config.content}`); } } initListeners() { this.clickHandler = this.handleClick.bind(this); this.changeHandler = this.handleChange.bind(this); this.keyHandler = this.handleKeydown.bind(this); document.addEventListener('click', this.clickHandler); document.addEventListener('change', this.changeHandler); document.addEventListener('keydown', this.keyHandler); this.initInfiniteScroll(); this.initUploader(); this.initModals(); this.initTabs(); this.initFilters(); } handleClick(e) { if (window.targetCheck(e, '.item-select') && e.shiftKey) { e.preventDefault(); this.handleRangeSelection(e.target); } else if (window.targetCheck(e, '.item-select')) { const checkbox = e.target.closest('.item-select').querySelector('.select-checkbox'); if (checkbox) { this.lastSelectedItem = checkbox.closest('.item'); this.selectionMode = this.selected.size > 0; } } else if (window.targetCheck(e, '.action') && window.targetCheck(e, '.item')) { let action = window.targetCheck(e, '.action'); let item = window.targetCheck(e, '.item'); this.handleItemAction(action.dataset.action, item); } else if (this.isTable && window.targetCheck(e, '.create-item')) { this.addEmptyRow(); } else if (window.targetCheck(e, '.apply-bulk')) { this.handleBulkControl(); } else if (window.targetCheck(e, '.cancel-bulk')) { this.clearSelection(); } else if (window.targetCheck(e, this.config.selectors.clearButton)) { this.handleClearFilters(); } else if (window.targetCheck(e, 'details.uploader summary')) { this.saveViewSettings(); } } handleRangeSelection(target) { if (!this.lastSelectedItem) { // If no last selected item, treat as normal selection this.updateSelected(target.closest('.item-select').querySelector('.select-checkbox')); return; } if (this.isTable) { } let container = (this.isTable) ? document.querySelector('table') : this.elements.grid; const currentItem = target.closest('.item'); const allItems = Array.from(container.querySelectorAll('.item')); const lastIndex = allItems.indexOf(this.lastSelectedItem); const currentIndex = allItems.indexOf(currentItem); if (lastIndex === -1 || currentIndex === -1) { // Fallback to normal selection if items not found this.updateSelected(target.closest('.item-select').querySelector('.select-checkbox')); return; } // Determine range const startIndex = Math.min(lastIndex, currentIndex); const endIndex = Math.max(lastIndex, currentIndex); // Select all items in range let newSelections = 0; for (let i = startIndex; i <= endIndex; i++) { const item = allItems[i]; const checkbox = item.querySelector('.select-checkbox'); if (checkbox && !checkbox.checked) { checkbox.checked = true; this.selected.add(checkbox.value); newSelections++; } } this.updateBulkControls(); this.selectionMode = this.selected.size > 0; // Announce to screen readers if (window.jvbA11y) { const rangeSize = endIndex - startIndex + 1; window.jvbA11y.announce(`Selected range of ${rangeSize} items. ${newSelections} new selections. ${this.selected.size} total selected.`); } } handleKeydown(e) { if ((e.ctrlKey || e.metaKey) && e.key === 'a') { e.preventDefault(); this.elements.selectAll.checked = true; this.selectAll(); } } handleChange(e) { if (this.isTable && window.targetCheck(e, 'input#vertical')) { e.preventDefault(); this.viewSettings.tabNav = e.target.checked; // Save preference localStorage.setItem('jvbTabNav', e.target.checked ? 'vertical' : 'horizontal'); // Announce change window.jvbA11y.announce( this.viewSettings.tabNav ? 'Changed to vertical navigation' : 'Changed to horizontal navigation' ); // Save view settings this.saveViewSettings(); return; } else if (this.isTable && window.targetCheck(e, '.multi-select')) { this.handleColumnVisibility(e); } if (window.targetCheck('select.date-filter')) { let value = e.target.value; if (value !== 'custom') { if (this.elements.dateRange.open) { this.modals.dateRange.handleClose(); } const monthSelect = this.elements.dateRange.querySelector('.month-select'); if (monthSelect) { monthSelect.value = ''; } this.setDateFilter(value); } } switch (true) { case e.target.value === 'custom': this.openModal('dateRange'); break; case 'action' in e.target.dataset: break; case 'taxonomy' in e.target.dataset: this.updateFilters(e.target.dataset.filter, e.target.value, e.target.dataset.taxonomy); break; case 'filter' in e.target.dataset: this.updateFilters(e.target.dataset.filter, e.target.value); break; case 'view' in e.target.dataset: this.switchView(e.target.dataset.view); break; } if (window.targetCheck(e, '.item-select')) { this.updateSelected(e.target); } else if (window.targetCheck(e, this.config.selectors.selectAll)) { this.selectAll(); } else if (window.targetCheck(e, '.date-range .month-select')) { this.handleMonthSelect(e); } else if (window.targetCheck(e, '.date-range .date-start') || window.targetCheck(e, '.date-range .date-end')) { let start = e.target.closest('.date-range').querySelector('.date-start').value; let end = e.target.closest('.date-range').querySelector('.date-end').value; if (start && end) { let startDate = new Date(start); let endDate = new Date(end); endDate.setHours(23, 59, 59, 999); this.setDateFilter('custom', startDate, endDate); this.modals.dateRange.handleClose(); } } } selectAll() { if (this.elements.selectAll.checked) { this.elements.selectAll.nextElementSibling.firstElementChild.innerText = 'Clear Selection'; let container = (this.isTable) ? document.querySelector('table') : this.elements.grid; container.querySelectorAll('.select-checkbox:not(:checked)').forEach((check) => { check.checked = true; this.selected.add(check.value); }); this.updateBulkControls(); } else { this.elements.selectAll.nextElementSibling.firstElementChild.innerText = 'Select All'; this.clearSelection(); } } openModal(modal) { if (this.modals[modal]) { this.modals[modal].handleOpen(); } } initInfiniteScroll() { if (!this.elements.scroll) return; const observer = new IntersectionObserver(entries => { entries.forEach(entry => { if (entry.isIntersecting && this.hasMore) { this.loadContent(); } }) }); observer.observe(this.elements.scroll); } initModals() { this.modals = {}; for (let [modal, config] of Object.entries(this.config.modals)) { let m = document.querySelector(config.selector); if (m) { this.modals[modal] = new window.jvbModal(m, config); } } } initUploader() { const uploader = document.querySelector('details.uploader .field.image'); if (!uploader) { return; } // Register with centralized UploadManager instead of creating new instance // Store reference for later use this.uploaderFieldId = window.jvbUploadManager.registerUploader(uploader, { content: this.content, onUploadComplete: (result) => this.handleUploadComplete(result), onGroupingComplete: (result) => this.loadContent(true) }); } handleUploadComplete(result) { if (result.success && result.data) { location.reload(); } } showPreview(e) { console.log(e); } initTabs() { } initFilters() { } createContent() { } editContent() { } bulkEditContent() { } updateSelected(element) { if (element.checked) { this.selected.add(element.value); this.lastSelectedItem = element.closest('.item'); // Update last selected } else if (this.selected.has(element.value)) { this.selected.delete(element.value); this.lastSelectedItem = null; } this.selectionMode = this.selected.size > 0; this.updateBulkControls(); } updateBulkControls() { const selecting = this.selected.size > 0; this.elements.grid.classList.toggle('selecting', selecting); this.elements.bulk.querySelector('.bulk-actions').hidden = !selecting; if (selecting) { document.addEventListener('keydown', this.handleEscape); } else { document.removeEventListener('keydown', this.handleEscape); } if (this.elements.count) { this.elements.count.textContent = selecting ? `( ${this.selected.size} selected )` : ''; } window.removeChildren(this.elements.bulkActions); let options; if (this.filters.status === 'trash') { options = window.getTemplate('trashOptions'); } else { options = window.getTemplate('notTrashOptions'); } options.querySelectorAll('option').forEach((option) => { this.elements.bulkActions.append(option); }); this.elements.bulkActions.firstElementChild.checked = true; this.elements.bulkActions.firstElementChild.selected = true; options.remove(); } handleBulkControl() { let action = this.elements.bulkActions.value; switch (action) { case 'delete': if (confirm(`Hold up! Are you sure you want to permanently delete these ${this.selected.size} ${this.plural}?\n\nThis is a forever kind of deal - no takebacks!`)) { this.handleBulkEdit('delete'); } break; case 'edit': this.modals['bulkEdit'].handleOpen(); break; case 'restore': this.handleBulkEdit('draft'); break; case 'trash': case 'publish': case 'draft': this.handleBulkEdit(action); break; } } handleItemAction(action, item) { const ID = item.dataset.id; this.clearSelection(); switch (action) { case 'restore': case 'trash': this.selected.add(ID); this.handleBulkEdit(action); break; case 'delete': if (confirm(`Hold up! Are you sure you want to permanently delete this ${this.single}?\n\nThis is a forever kind of deal - no taking it back.`)) { this.selected.add(ID); this.handleBulkEdit(action); } break; case 'toggle-status': const current = item.dataset.status; const newStatus = current === 'publish' ? 'draft' : 'publish'; this.selected.add(ID); this.handleBulkEdit(newStatus); item.dataset.status = newStatus; window.removeChildren(item.querySelector('[data-action="toggle-status"]')); item.querySelector('[data-action="toggle-status"]').append(window.getIcon(newStatus)); break; } } resetFilters(elements = false) { this.filters = { content: this.content, status: 'all', taxonomies: {}, page: 1, order: 'DESC', orderby: 'date', ...this.config.filters //additional filters can be added in constructor } if (elements) { let checks = [this.filters.status, this.filters.order, this.filters.orderby]; checks.forEach((check) => { let item = this.elements.filters.querySelector(`[data-filter][value="${check}"]`); if (item) { item.checked = true; } }); this.elements.filters.querySelectorAll('select').forEach(select => { select.value = ''; }); this.updateClearFiltersButton(); this.hasMore = true; this.loadContent(true); } } async switchView(view) { if (!this.views.has(view)) { console.error(`View "${view}" not registered`); return; } // Don't switch if already in this view if (this.view === view) { return; } try { // Store current data if we have any const hasData = this.posts.size > 0; // Deactivate current view if (this.view) { const currentViewObj = this.views.get(this.view); await currentViewObj.deactivate(); } // Activate new view const newView = this.views.get(view); await newView.activate(); this.view = view; this.elements.view.querySelector(`[data-view="${view}"]`).checked = true; // Save view preference this.saveViewSettings(); // If we already have data, just re-render it in the new view if (hasData) { // Convert Map to array for rendering const items = Array.from(this.posts.values()); // Clear the display area first this.clearContent(); // Render existing data in new view format this.renderItems(items, false); // Show loading state briefly for UX window.jvbA11y?.announce(`Switched to ${view} view`); } else { // Only load data if we don't have any this.hasMore = true; this.loadContent(true); } } catch (error) { console.error('Error switching view:', error); window.showToast?.(`Failed to switch to ${view} view`, 'error'); } } /** * Updates the filters from the node element * HTML element MUST have: data-filter-by and data-filter * @param {string} filter * @param {string} value * @param {?string} taxonomy */ updateFilters(filter, value, taxonomy = null) { // If the filter isn't defined in the filter object, we won't be able to filter by it if (Object.hasOwn(this.filters, filter)) { if (taxonomy !== null) { if (!Object.hasOwn(this.filters[filter], taxonomy)) { this.filters[filter][taxonomy] = []; } this.filters[filter][taxonomy].push(value); } else { this.filters[filter] = value; } this.saveViewSettings(); this.updateClearFiltersButton(); } this.hasMore = true; this.loadContent(true); } buildFilters() { const temp = {}; for (const [name, value] of Object.entries(this.filters)) { if (value) { if (typeof value === 'object' && window.isEmptyObject(value)) { } else if (typeof value === 'object') { temp[name] = {}; for (let [key, v] of Object.entries(value)) { temp[name][key] = v.join(','); } temp[name] = JSON.stringify(temp[name]); } else { temp[name] = value; } } } temp.user = jvbSettings.currentUser; return new URLSearchParams(temp); } async loadContent(reset = false, force = false) { if (this.isLoading || !this.hasMore) return; try { this.isLoading = true; this.loading.showLoading(); if (reset) { this.filters.page = 1; this.clearContent(); } const filters = this.buildFilters(); const data = await this.cache.fetchWithCache( `${jvbSettings.api}${this.api}?${filters.toString()}`, { method: 'GET', headers: { 'X-WP-Nonce': jvbSettings.nonce, 'action_nonce': jvbSettings.dash, } }, { context: this.content, forceRefresh: force, } ); console.log('Fetched data: ', data); if (data.pagination) { this.hasMore = data['has_more']; this.totalItems = data.items; this.pages = data.pages; } else { this.hasMore = false; this.totalItems = 0; this.pages = 0; } if (data.items && data.items.length > 0) { this.renderItems(data.items, this.filters.page >1); this.cacheItems(data.items); } else if (reset) { this.showEmptyState(); } if (this.hasMore) { this.filters.page++; } } catch (error) { this.handleError( error, 'loadContent' ); throw error; } finally { this.isLoading = false; this.loading.hideLoading(); } } clearContent() { if (this.view === 'table') { const tbody = document.querySelector('form.table tbody'); if (tbody) { window.removeChildren(tbody); } } else { const grid = document.querySelector(this.config.selectors.grid); if (grid) { window.removeChildren(grid); } } } /** * Cache items for later reference */ cacheItems(items) { items.forEach(item => { this.posts.set(item.id, item); }); } showEmptyState() { if (this.view === 'table') { const template = window.getTemplate('emptyState'); const table = document.querySelector('form.table tbody'); if (table) { table.appendChild(template); } } else { const template = window.getTemplate('emptyState'); const grid = document.querySelector(this.config.selectors.grid); if (grid) { grid.appendChild(template); } } } hideEmptyState() { if (this.isTable) { this.table.classList.remove('empty'); } else { this.elements.grid.classList.remove('empty'); } this.elements.container.querySelector('.empty-state')?.remove(); } /** * Handle errors * @param {Error} error - Error object * @param {string} action - Action being performed when error occurred */ handleError(error, action) { console.error(`CRUD 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: 'CRUD', action: action }); } // Announce to screen readers if (window.jvbA11y) { window.jvbA11y.announce(`Error ${action}. ${error.message || 'Please try again.'}`); } } /** * Modal Handlers */ renderEditModal(e, modal) { let item = e.target.closest('.item'); this.editing = item.dataset.id; let fields = JSON.parse(item.dataset.fields); let images = JSON.parse(item.dataset.images); modal.querySelector('h2').textContent = (fields['post_title'] !== '') ? `Editing "${fields['post_title']}"` : `Editing ${this.single}`; if (item.dataset.status) { modal.querySelector(`[name="status"][value="${item.dataset.status}"]`).checked = true; } window.jvbForm.removeChangeListener(); window.jvbForm.populateFormFields(modal.querySelector('form'), fields, images); // window.jvbForm.processChanges(modal.form, {processSave: false}); window.jvbForm.addChangeListener(); } closeEditModal() { let modal = document.querySelector('dialog.edit-modal'); window.removeChildren(modal); let inside = this.editModal.cloneNode(true); modal.append(inside); } handleEditModalSave(changes) { let data = {}; data[this.editing] = changes; this.saveData(data); } openBulkEditModal(e, modal) { let selected = modal.querySelector('.selected'); for (let item of this.selected) { item = parseInt(item); if (!this.posts.has(item)) { let element = document.querySelector(`.item-grid .item[data-id="${item}"]`); item = { fields: JSON.parse(element.dataset.fields), images: JSON.parse(element.dataset.images) } } else { item = this.posts.get(item); } let img = window.getTemplate('bulkItem'); let check = img.querySelector('input[type=checkbox]'); let image = img.querySelector('img'); [ img.htmlFor, check.name, check.id, check.checked, image.src, image.alt, ] = [ item.id, item.id, item.id, true, item.images[item.fields['post_thumbnail']].medium, item.images[item.fields['post_thumbnail']].alt, ]; selected.append(img); } } renderBulkEditModal(modal) { } closeBulkEditModal(e, changes) { for (let id of this.selected) { //Remove any selected that are not selected from the bulk editor if (!changes[id]) { this.selected.delete(id); } //Remove the selected from the changes, because we don't need to send that to the server delete changes[id]; } if (window.isEmptyObject(changes) || this.selected.size === 0) { return; } let temp = {}; for (let key in changes) { let value = changes[key]; key = key.replace('bulk-edit-', ''); temp[key] = value; } let data = {}; for (let id of this.selected){ data[id] = temp; } this.saveData(data); let modal = document.querySelector('dialog.bulk-edit-modal'); window.removeChildren(modal); modal.append(this.bulkEditModal); } handleBulkEditModalSave(changes) { } openCreateModal(modal) { if (this.isTable) { this.modals['create'].handleClose(); } } renderCreateModal(modal) { } closeCreateModal() { let modal = document.querySelector('dialog.create-modal'); window.removeChildren(modal); modal.append(this.createModal); } handleCreateModalSave(changes) { let id = this.modals.create.modal.querySelector('input[name="form-id"]').value; let data = {}; if (!Object.hasOwn(data, id)) { data[id] = {content: this.content}; } for (let [field, value] of Object.entries(changes)) { if (field === 'image_temp') { continue; } data[id][field] = value; } this.saveData(data); } saveData(data) { if (data.length === 0 || window.isEmptyObject(data)) { return; } for (var [id, value] of Object.entries(data)) { data[id].content = this.content; } let title = (data.length > 1) ? this.plural : this.single; let operation = { endpoint: 'content', headers: { 'action_nonce': jvbSettings.dash, }, title: `Adding ${data.length} ${title} to Queue`, popup: `Queuing ${title}...`, data: { posts: data, } }; this.queue.addToQueue(operation); } handleBulkEdit(status) { this.loading.showLoading('Processing bulk changes...'); try { let posts = {}; this.selected.forEach(postID => { posts[postID] = { content: this.content, status: status }; if (['delete', 'trash', 'restore'].includes(status)) { this.elements.grid.querySelector(`[data-id="${postID}"]`).remove(); } }); this.saveData(posts); this.clearSelection(); } catch (error) { console.error('Bulk operation failed: ', error); } finally { this.loading.hideLoading(); } } handleDateRangeOpen() { } handleDateRangeClose() { let start = this.elements.dateRange.querySelector('.date-start'); let end = this.elements.dateRange.querySelector('.date-end'); let select = this.elements.dateRange.querySelector('.month-select'); } handleMonthSelect(e) { const [year, month] = e.target.value.split('-'); if (year && month) { const start = new Date(year, month - 1, 1); const end = new Date(year, month, 0); end.setHours(23, 59, 59, 999); this.setDateFilter('custom', start, end); this.modals.dateRange.handleClose(); } } setDateFilter(type, startDate = null, endDate = null) { const now = new Date(); now.setHours(23, 59, 59, 999); let start = startDate; let end = endDate || now; if (!startDate && type !== '') { start = new Date(); switch (type) { case 'today': start.setHours(0, 0, 0, 0); break; case 'week': start.setDate(now.getDate() - 7); break; case 'month': start.setMonth(now.getMonth() - 1); break; case 'year': start.setFullYear(now.getFullYear() - 1); break; } } this.filters.date = type ? { range: { after: start.toISOString(), before: end.toISOString() }, custom: type === 'custom' } : { range: null, custom: false }; this.updateClearFiltersButton(); this.page = 1; this.loadContent(true); } updateClearFiltersButton() { const hasFilters = Object.keys(this.filters.taxonomies).length > 0 || this.filters.date.range !== null; this.elements.clearButton.hidden = !hasFilters; } handleClearFilters() { this.resetFilters(true); } /***************************************************** * * VIEW CONTROLS * *****************************************************/ /** * Initialize visual view (grid/list) */ initVisualView(viewName) { // Visual views are initialized on first load const view = this.views.get(viewName); view.initialized = true; } /** * Activate visual view */ activateVisualView(viewName) { const view = this.views.get(viewName); // Remove all view classes this.elements.grid.classList.remove('grid-view', 'list-view', 'table'); // Add specific view class this.elements.grid.classList.add(view.containerClass); // Hide table-specific controls if (this.elements.columnsSelect) { this.elements.columnsSelect.hidden = true; } return Promise.resolve(); } /** * Deactivate visual view */ deactivateVisualView(viewName) { // Clear rendered items if needed return Promise.resolve(); } /** * Initialize table view */ initTableView() { const view = this.views.get('table'); if (view.initialized) { return; } // Create table container structure const tableTemplate = window.getTemplate('contentTable'); if (!tableTemplate) { throw new Error('Table template not found'); } view.tableContainer = tableTemplate; view.initialized = true; } /** * Activate table view */ async activateTableView() { const view = this.views.get('table'); // Initialize if needed if (!view.initialized) { this.initTableView(); } // Remove visual view classes this.elements.grid.classList.remove('grid-view', 'list-view'); this.elements.grid.classList.add('table'); // Insert table before grid if (!this.elements.gridWrap.querySelector('form.table')) { this.elements.gridWrap.insertBefore( view.tableContainer.cloneNode(true), this.elements.grid ); } // Get table reference const table = this.elements.gridWrap.querySelector('form.table'); // Initialize the vertical navigation checkbox const verticalCheckbox = table.querySelector('input#vertical'); if (verticalCheckbox) { // Load saved preference const savedPref = localStorage.getItem('jvbTabNav'); this.viewSettings.tabNav = savedPref === 'vertical'; verticalCheckbox.checked = this.viewSettings.tabNav; } // Register form with jvbForm if (window.jvbForm && !view.tableForm) { view.tableForm = window.jvbForm.registerForm(table, { onSave: (data) => this.handleTableSave(data), isRow: true, content: this.config.content, autoSave: true, saveDelay: 3000 }); } // Show column controls if (this.elements.columnsSelect) { this.elements.columnsSelect.hidden = false; } // Load saved column preferences await this.loadTableColumns(); // Add keyboard navigation listeners this.addTableListeners(); // Mark table view as active this.isTable = true; } /** * Deactivate table view */ async deactivateTableView() { const view = this.views.get('table'); // Remove table listeners this.removeTableListeners(); // Unregister form if (view.tableForm && window.jvbForm) { window.jvbForm.removeForm(view.tableForm.id); view.tableForm = null; } // Remove table from DOM const table = this.elements.gridWrap.querySelector('form.table'); if (table) { table.remove(); } // Hide column controls if (this.elements.columnsSelect) { this.elements.columnsSelect.hidden = true; } // Remove table class this.elements.grid.classList.remove('table'); // Mark table view as inactive this.isTable = false; } /** * Cleanup table view (full cleanup) */ cleanupTableView() { const view = this.views.get('table'); view.initialized = false; view.tableContainer = null; view.tableForm = null; } /** * Render items for visual views */ renderVisualItems(items, viewName, append) { const view = this.views.get(viewName); const template = view.template; if (!append){ // Clear existing items window.removeChildren(this.elements.grid); } // Batch render items const fragment = document.createDocumentFragment(); items.forEach(item => { const element = this.createVisualElement(item, template); fragment.appendChild(element); }); this.elements.grid.appendChild(fragment); // Make items keyboard navigable if (window.jvbA11y) { window.jvbA11y.makeNavigable( this.elements.grid.querySelectorAll('.item:not([data-keyboard-nav])') ); } } /** * Render items for table view */ renderTableItems(items, append) { const table = this.elements.gridWrap.querySelector('form.table tbody'); if (!table) return; if (!append) { // Clear existing rows window.removeChildren(table); } // Batch render rows const fragment = document.createDocumentFragment(); items.forEach(item => { const row = this.createTableRow(item); fragment.appendChild(row); }); table.appendChild(fragment); window.loadTemplates(); // Process form changes const view = this.views.get('table'); if (view.tableForm && window.jvbForm) { window.jvbForm.processChanges(view.tableForm, {processChanges: false}); // Scan for selectors and uploaders window.jvbSelector?.scanExistingFields(); window.jvbUploadManager?.scanExistingFields(); } } /** * Create visual element (grid/list item) */ createVisualElement(item, templateName) { const template = window.getTemplate(templateName); // Set basic attributes template.dataset.id = item.id; template.dataset.img = item.thumbnail || ''; if (item.fields) { template.dataset.fields = JSON.stringify(item.fields); } if (item.images) { template.dataset.images = JSON.stringify(item.images); } if (item.status) { template.classList.add(item.status); template.dataset.status = item.status; } // Populate fields this.populateItemFields(template, item); return template; } /** * Create table row */ createTableRow(item) { const row = window.getTemplate('tableView'); row.dataset.id = item.id; // Update IDs for form handling row.querySelectorAll('[id]').forEach(element => { const label = element.nextElementSibling?.tagName === 'LABEL' ? element.nextElementSibling : element.previousElementSibling?.tagName === 'LABEL' ? element.previousElementSibling : null; element.id = `${item.id}|${element.id}`; element.name = `${item.id}|${element.name}`; // let templates = element.querySelectorAll('template'); // if (templates) { // templates.forEach(template => { // template.className = template.className+item.id; // }) // } if (label) { label.htmlFor = element.id; } }); // Set status if (item.status) { const statusRadio = row.querySelector(`[name="status"][value="${item.status}"]`); if (statusRadio) { statusRadio.checked = true; } } // Populate field values if (item.fields) { row.querySelectorAll('.field').forEach(field => { const fieldName = field.dataset.field; if (fieldName && item.fields[fieldName] !== undefined) { window.jvbForm?.populateFieldValue( field, `${item.id}|${fieldName}`, item.fields[fieldName], item.images ); } }); } return row; } /** * Populate item fields for visual views */ populateItemFields(element, item) { // Populate image const img = element.querySelector('img'); if (img && item.thumbnail) { img.src = item.thumbnail; img.alt = item.fields?.post_title || `${this.config.content} image`; } // Populate field values if (item.fields) { Object.entries(item.fields).forEach(([fieldName, value]) => { const fieldElement = element.querySelector(`.${fieldName}`); if (fieldElement) { if (fieldElement.classList.contains('images')) { window.handleGalleryField?.(fieldElement, value); } else if (fieldElement.querySelector('li')) { window.handleListField?.(fieldElement, value); } else { window.handleTextField?.(fieldElement, value); } } }); } // Set checkbox values const checkbox = element.querySelector('.select-checkbox'); if (checkbox) { checkbox.id = `select-${item.id}`; checkbox.name = `select-${item.id}`; checkbox.value = item.id; const label = checkbox.nextElementSibling; if (label) { label.htmlFor = checkbox.id; } } } /** * Handle column visibility changes */ handleColumnVisibility(event) { const columnClass = event.target.id; const table = this.elements.gridWrap.querySelector('form.table'); if (table) { table.querySelectorAll(`.${columnClass}`).forEach(cell => { cell.hidden = !event.target.checked; }); } this.saveViewSettings(); } /** * Add table-specific listeners */ addTableListeners() { // Tab navigation listener this.tabListener = (e) => { // Only handle tab in table view if (this.view !== 'table') return; // Check if we're in a table input/select/textarea const isInTable = e.target.closest('form.table tbody'); if (!isInTable) return; if (e.key === 'Tab' && this.viewSettings.tabNav) { this.handleTableTabNavigation(e); } else if ((e.ctrlKey || e.metaKey) && e.key === 'ArrowUp') { e.preventDefault(); this.toggleTableNavDirection(); } }; document.addEventListener('keydown', this.tabListener); } /** * Remove table-specific listeners */ removeTableListeners() { if (this.tabListener) { document.removeEventListener('keydown', this.tabListener); this.tabListener = null; } } /** * Handle table tab navigation */ handleTableTabNavigation(event) { const table = this.elements.gridWrap.querySelector('form.table'); if (!table) return; const currentElement = document.activeElement; const currentCell = currentElement.closest('td'); if (!currentCell) return; const currentRow = currentCell.closest('tr'); const rows = Array.from(table.querySelectorAll('tbody tr')); const currentRowIndex = rows.indexOf(currentRow); if (currentRowIndex === -1) return; // Determine next row (up or down based on shift key) const nextRowIndex = event.shiftKey ? currentRowIndex - 1 : currentRowIndex + 1; // Check bounds if (nextRowIndex < 0 || nextRowIndex >= rows.length) { // Optionally, you could wrap around or stop return; } const nextRow = rows[nextRowIndex]; if (!nextRow) return; // Find the corresponding cell in the next row const cells = Array.from(currentRow.querySelectorAll('td')); const currentCellIndex = cells.indexOf(currentCell); const nextRowCells = Array.from(nextRow.querySelectorAll('td')); const nextCell = nextRowCells[currentCellIndex - 1]; if (!nextCell) return; // Find the first focusable element in the next cell const focusable = nextCell.querySelector('input, select, textarea, button'); if (focusable) { // Smooth scroll to the next row nextRow.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); // Focus the element focusable.focus(); // If it's a text input, select all text for easy editing if (focusable.type === 'text' || focusable.type === 'number') { focusable.select(); } } } /** * Toggle table navigation direction */ toggleTableNavDirection() { this.viewSettings.tabNav = !this.viewSettings.tabNav; const message = this.viewSettings.tabNav ? 'Changed to vertical navigation' : 'Changed to horizontal navigation'; window.jvbA11y?.announce(message); this.saveViewSettings(); } /** * Save view settings to cache */ async saveViewSettings() { const settings = { view: this.view, tabNav: this.viewSettings.tabNav || false, columns: [] }; // Save column visibility for table view if (this.view === 'table') { const checkedColumns = document.querySelectorAll('.multi-select input:checked'); settings.columns = Array.from(checkedColumns).map(input => input.id); } // Save to cache if (window.jvbCache) { await window.jvbCache.setItem(`${this.config.content}_view_settings`, settings); } // Also save to localStorage for quick access localStorage.setItem(`${this.config.content}_view`, this.view); } /** * Load view settings from cache */ async loadViewSettings() { // Try cache first if (window.jvbCache) { const cached = await window.jvbCache.getItem(`${this.config.content}_view_settings`); if (cached) { this.viewSettings = cached; return cached.view || 'grid'; } } // Fallback to localStorage const savedView = localStorage.getItem(`${this.config.content}_view`); return savedView || 'grid'; } /** * Load table column preferences */ async loadTableColumns() { if (!this.viewSettings.columns || !this.viewSettings.columns.length) { return; } const table = this.elements.gridWrap.querySelector('form.table'); if (!table) return; // Apply saved column visibility document.querySelectorAll('.multi-select input[type="checkbox"]').forEach(input => { const isVisible = this.viewSettings.columns.includes(input.id); input.checked = isVisible; // Update column visibility table.querySelectorAll(`.${input.id}`).forEach(cell => { cell.hidden = !isVisible; }); }); } /** * Handle table save */ handleTableSave(changes) { this.saveData(changes); } /** * Callback for view changes */ onViewChange(viewName) { if (this.config.onViewChange) { this.config.onViewChange(viewName); } } /** * Get current view */ getCurrentView() { return this.view; } /** * Get view object */ getView(viewName) { return this.views.get(viewName); } /** * Check if current view is editable */ isEditableView() { const view = this.views.get(this.view); return view && view.type === 'editable'; } /** * Render items based on current view */ renderItems(items, append = false) { const view = this.views.get(this.view); if (view && view.render) { view.render(items, append); } } /** * Add empty row (for table view) */ addEmptyRow() { if (this.view !== 'table') { return; } const table = this.elements.gridWrap.querySelector('form.table tbody'); if (!table) return; // Remove empty state if present this.elements.container.querySelector('.empty-state')?.remove(); // Create new row const template = window.getTemplate('tableView'); const timestamp = new Date().getTime().toString(36); template.dataset.id = `new-${timestamp}`; // Update IDs for new row template.querySelectorAll('[id]').forEach(element => { const label = element.nextElementSibling?.tagName === 'LABEL' ? element.nextElementSibling : element.previousElementSibling?.tagName === 'LABEL' ? element.previousElementSibling : null; element.id = `${timestamp}|${element.id}`; element.name = `${timestamp}|${element.name}`; if (label) { label.htmlFor = element.id; } }); // Handle selectors template.querySelectorAll('.jvb-selector')?.forEach(selector => { selector.id = `${timestamp}-${selector.id}`; const toggle = selector.querySelector('.taxonomy-toggle'); if (toggle && window.jvbSelector) { window.jvbSelector.handleToggleClick(toggle, false); } }); table.appendChild(template); // Focus first input const firstInput = template.querySelector('input:not([type="checkbox"]), select, textarea'); if (firstInput) { firstInput.focus(); } } cleanup() { // Remove listeners this.removeTableListeners(); // Cleanup views this.views.forEach((view, name) => { if (view.cleanup) { view.cleanup(); } }); // Clear references this.views.clear(); this.elements = null; this.config = null; this.posts.clear(); this.selected.clear(); document.removeEventListener('click', this.clickHandler); document.removeEventListener('change', this.changeHandler); document.removeEventListener('keydown', this.keyHandler); document.removeEventListener('keydown', this.handleEscape); document.removeEventListener('keydown', this.tabListener); } } window.crud = CRUD; window.addEventListener('beforeunload', () => window.crud?.cleanup());