class CRUDManager { constructor(){ this.container = document.querySelector('.crud[data-content]:not([data-ignore])'); if (!this.container) return; this.content = this.container.dataset.content; this.endpoint = this.container.dataset.endpoint??'content'; this.singular = this.container.dataset.singular; this.plural = this.container.dataset.plural; this.queue = window.jvbQueue; this.a11y = window.jvbA11y; this.error = window.jvbError; this.cache = new window.jvbCache(this.content); this.activeItem = null; this.isTimeline = false; this.items = { list: new Map(), grid: new Map(), table: new Map() }; //DOM references this.init(); } init() { this.initElements(); this.initListeners(); let cached = this.initSettings(); this.initStore(cached); this.checkHideFilters(); this.initIntegrations(); this.initUploader(); this.initModals(); } initElements() { this.allowedFilters = ['status', 'orderby', 'order', 'search', 'date-filter', 'dateFrom', 'dateTo']; this.selectors = { buttons: { create: '.create-item', clearFilters: '[data-action="clear-filters"]' }, views: { grid: 'input[data-view="grid"]', list: 'input[data-view="list"]', table: 'input[data-view="table"]' }, modals: { create: { modal: 'dialog.create', form: 'dialog.create form', h2: 'dialog.create h2', }, edit: { modal: 'dialog.edit', form: 'dialog.edit form', h2: 'dialog.edit h2', }, bulkEdit: { modal: 'dialog.bulkEdit', selected: 'dialog.bulkEdit .selected', h2: 'dialog.bulkEdit h2 span', form: 'dialog.bulkEdit form' }, date: { modal: 'dialog.date-range', start: 'dialog.date-range .date-start', end: 'dialog.date-range .date-end', month: 'dialog.date-range .month-select', } }, grid: `.${this.content}.item-grid`, table: { nav: '#vertical', form: 'form.table', table: 'form.table table', body: 'form.table body', head: 'form.table thead', foot: 'form.table tfoot', selectedColumns: '.all-filters .multi-select', columns: 'thead th', }, bulk: { action: '.bulk-action-select', count: '.bulk-controls .selected-count', control: '.bulk-controls .bulk-actions', select: '.bulk-controls select', selectAll: '.select-all' }, filters: { container: 'details.all-filters', search: '.all-filters input[type="search"]', status: { all: '[name="status"]#all', publish: '[name="status"]#publish', draft: '[name="status"]#draft', trash: '[name="status"]#trash', }, orderby: { date: '[name="orderby"]#date', alphabetical: '[name="orderby"]#alphabetical', }, order: { asc: '[name="order"][value="asc"]', desc: '[name="order"][value="desc"]' }, date: '[data-filter="date"]' }, uploader: 'details.uploader' } this.ui = window.uiFromSelectors(this.selectors); const taxFilters = document.querySelectorAll('[data-filter="taxonomies"]'); if (taxFilters.length > 0) { this.ui.filters.taxonomies = {}; taxFilters.forEach(tax => { const taxonomy = tax.dataset.taxonomy; this.ui.filters.taxonomies[taxonomy] = tax; this.allowedFilters.push(`tax_${taxonomy}`); }); } this.isTimeline = !!document.querySelector('[data-timeline]'); } initUploader() { if (!this.ui.uploader) return; window.jvbUploads.scanFields(this.ui.uploader); window.jvbUploads.subscribe((event, data) => { if (event === 'sent-to-queue') { if (data === this.ui.uploader.dataset.uploader) { window.debouncer.schedule('crud-complete', ()=> { this.store.clearCache(); }); } } }); } initModals() { this.modals = {}; for (let [name, modal] of Object.entries(this.ui.modals)) { if (!modal.modal) continue; this.modals[name] = new window.jvbModal(modal.modal); this.modals[name].subscribe((event, data) => { switch (event) { case 'modal-close': const formId = this.ui.modals[name].form.dataset.formId; if (formId) { this.formController.cleanupForm(formId); } this.ui.modals[name].form.reset(); if (name === 'date') { this.handleCustomDateSelection() } break; case 'modal-open': break; } }) } } initStore(cached) { let filters = { ... this.defaults, ...cached }; const store = window.jvbStore.register( this.content, { storeName: this.content, keyPath: 'id', endpoint: this.endpoint??'content', //for taxonomy stores headers: { 'action_nonce': window.auth.getNonce('dash'), }, indexes: [ {name: 'id', keyPath: 'id'}, { name: 'status', keyPath: 'status'}, { name: 'date', keyPath: 'date'}, { name: 'modified', keyPath: 'modified'}, { name: 'title', keyPath: 'title'} ], filters: filters, ignore: ['content', 'user'], TTL: 60 * 60 * 1000, //1 hour cache showLoading: true, } ); this.store = store[this.content]; this.store.subscribe((event, data) => { switch (event) { case 'data-loaded': this.render(); this.selectionHandler.collectItems(); break; } }) } initIntegrations() { this.selected = new Set(); this.selectionHandler = new window.jvbHandleSelection(this.container, { selectAll: { checkbox: '.crud #select-all', label: '.bulk-select label', span: '.bulk-select label span' }, wrapper: { wrapper: '.wrap' } }); this.selectionHandler.subscribe((event, data) => { this.selected = new Set([...data.selectedItems].map(id => parseInt(id))); this.ui.bulk.control.hidden = this.selected.size === 0; this.ui.bulk.count.hidden = this.selected.size === 0; this.ui.bulk.count.textContent = `${this.selected.size} ${this.plural} selected`; }); this.formController = new window.jvbForm(); this.formController.subscribe((event, data) => { switch(event) { case 'form-submit': case 'form-autosave': this.handleFormChange(event,data); break; } }); this.queue.subscribe((event, data) => { if (['image_upload', 'video_upload', 'document_upload'].includes(data.type) && event === 'operation-status' && data.status === 'completed') { this.store.clearCache(); } }); } initSettings() { this.defaults = { content: this.content, user: window.auth.getUser(), page: 1, status: 'all', orderby: 'date', order: 'desc', search: '', } let updateFilters = {}; //current view (defaults to grid) let defaultView = this.container.dataset.view??'grid' this.view = this.cache.get('view')??defaultView; if (this.view !== defaultView) { this.ui.views[this.view].checked = true; } //current status (defaults to all) this.status = this.cache.get('status')??this.defaults.status; if (this.status !== this.defaults.status) { this.ui.filters.status[this.status].checked = true; updateFilters.status = this.status; } //orderby & order this.orderby = this.cache.get('orderby')??this.defaults.orderby; if (this.orderby !== this.defaults.orderby) { this.ui.filters.orderby[this.orderby].checked = true; updateFilters.orderBy = this.orderby; } this.order = this.cache.get('order')??this.defaults.order; if (this.order !== this.defaults.order) { this.ui.filters.order[this.order].checked = true; updateFilters.order = this.order; } if (this.ui.filters.taxonomies) { Object.entries(this.ui.filters.taxonomies).forEach(([taxonomy, element]) => { const filterKey = `tax_${taxonomy}`; const cached = this.cache.get(filterKey); if (cached) { element.value = cached; updateFilters[filterKey] = cached; } }); } let tabDirection = this.cache.get('tabNav')??'horizontal'; if (this.ui.table.nav && tabDirection === 'vertical') { this.ui.table.nav.checked = true; } //Setup details open functionality let details = { showFilters: { element: this.ui.filters.container, default: 'closed', }, showUploader: { element: this.ui.uploader, default: 'open' } }; for (let [name, conf] of Object.entries(details)) { if (conf.element) { let cached = this.cache.get(name)??conf.default; conf.element.open = cached === 'open'; conf.element.addEventListener('toggle', ()=> { this.cache.set(name, conf.element.open ? 'open' : 'closed'); }); } } return updateFilters; } /**************************************************************** EVENT LISTENERS ****************************************************************/ initListeners() { this.changeHandler = this.handleChange.bind(this); this.clickHandler = this.handleClick.bind(this); this.inputHandler = this.handleInput.bind(this); document.addEventListener('change', this.changeHandler); document.addEventListener('click', this.clickHandler); if (this.ui.filters.search) { this.ui.filters.search.addEventListener('input', this.inputHandler); } } handleChange(e) { const isSearch = window.targetCheck(e, this.selectors.filters.search); if (isSearch) { return; } const bulkAction = window.targetCheck(e, this.selectors.bulk.action); if (bulkAction) { this.handleBulkAction(bulkAction); return; } let filter = window.targetCheck(e, '[data-filter]'); if (filter) { this.handleFilterChange(filter); return; } let view = window.targetCheck(e, 'input[data-view]'); if (view) { this.handleViewChange(view); return; } if (this.view === 'table') { let target = window.targetCheck(e, '[data-id]'); if (target) { this.handleTableChange(e); return; } let multiSelect = window.targetCheck(e, 'details.multi-select'); if (multiSelect) { this.toggleColumn(e.target.id, e.target.checked); return; } let tabNav = window.targetCheck(e, this.selectors.table.nav); if (tabNav) { this.tabNav = tabNav.checked; this.cache.set('tabNav', tabNav.checked ? 'vertical' : 'horizontal'); } } } handleBulkAction(bulkAction) { if (bulkAction.value.startsWith('tax-')) { const selectedOption = bulkAction.options[bulkAction.selectedIndex]; const taxonomy = selectedOption.dataset.taxonomy; const single = selectedOption.dataset.single; const plural = selectedOption.dataset.plural; window.jvbSelector.openEmpty( taxonomy, single, plural, (result) => this.handleBulkTaxonomy(result) ); bulkAction.value = ''; return; } switch(bulkAction.value) { case 'edit': this.openBulkEditModal(); break; case 'publish': case 'trash': case 'delete': this.setBulkStatus(bulkAction.value); break; case 'draft': case 'restore': this.setBulkStatus('draft'); break; } } handleBulkTaxonomy(result) { if (!result.termIds.length || !this.selected.size) return; const changes = {}; const taxonomyField = `tax_${result.taxonomy}`; this.selected.forEach(itemID => { const item = this.store.get(parseInt(itemID)); if (!item) return; // Merge existing terms with new ones const existingTerms = item.taxonomies?.[result.taxonomy] || []; const existingIds = existingTerms.map(t => t.id); const newIds = [...new Set([...existingIds, ...result.termIds])]; changes[itemID] = { [taxonomyField]: newIds.join(','), content: this.content }; }); if (Object.keys(changes).length > 0) { this.savePosts( changes, `Adding ${result.terms.length} ${result.taxonomy} to ${this.selected.size} ${this.plural}...` ); } this.selectionHandler.clearSelection(); } handleFilterChange(target) { let filter = target.dataset.filter; if (filter === 'date' && target.value === 'custom') { target.value = ''; this.modals.date.handleOpen(); return; } if (filter === 'date' && target.value !== '') { this.setFilter('date-filter', target.value); // Clear custom range this.deleteFilter('dateFrom'); this.deleteFilter('dateTo'); this.checkHideFilters(); return; } if (filter === 'taxonomies') { filter = `tax_${target.dataset.taxonomy}`; } this.setFilter(filter, target.value); } checkHideFilters() { const filters = this.store.filters; const hasActiveFilter = Object.entries(filters).some(([key, value]) => { // Skip internal props if (['content', 'user', 'page'].includes(key)) return false; // Check if differs from default return this.defaults[key] !== value && value !== '' && value !== null; }); this.ui.buttons.clearFilters.hidden = !hasActiveFilter; } clearAllFilters() { let currentFilters = this.store.filters; this.store.clearFilters(); for (let [filter, value] of Object.entries(currentFilters)) { this.cache.remove(filter); this.deleteFilter(filter, value); } this.a11y.announce('All filters cleared'); } handleCustomDateSelection() { // Check if month select was used if (this.ui.modals.date.month && this.ui.modals.date.month.value) { const [year, month] = this.ui.modals.date.month.value.split('-'); const firstDay = `${year}-${month}-01`; const lastDay = new Date(year, parseInt(month), 0).getDate(); const lastDayFormatted = `${year}-${month}-${String(lastDay).padStart(2, '0')}`; this.setFilter('dateFrom', firstDay); this.setFilter('dateTo', lastDayFormatted); // Clear the regular date-filter this.deleteFilter('date-filter'); // Reset month select for next time this.ui.modals.date.month.value = ''; } // Otherwise check custom range else if (this.ui.modals.date.start && this.ui.modals.date.start.value && this.ui.modals.date.end && this.ui.modals.date.end.value) { this.setFilter('dateFrom', this.ui.modals.date.start.value); this.setFilter('dateTo', this.ui.modals.date.end.value); // Clear the regular date-filter this.deleteFilter('date-filter'); // Reset inputs for next time this.ui.modals.date.start.value = ''; this.ui.modals.date.end.value = ''; } this.checkHideFilters(); } handleViewChange(view) { this.view = view.dataset.view; this.cache.set('view', this.view); this.render(); } handleClick(e) { let clearSearch = window.targetCheck(e, '.clear-search'); if (clearSearch) { this.deleteFilter('search', ''); } let actionButton = window.targetCheck(e, '[data-action]'); if (actionButton) { e.preventDefault(); let itemID = actionButton.dataset.id; switch (actionButton.dataset.action) { case 'edit': this.openEditModal(itemID); break; case 'delete': if (confirm('Delete this item? This cannot be undone')) { let changes = {}; changes[itemID] = { 'post_status': 'delete', 'content': this.content }; window.fade(actionButton.closest('.item'), false); this.savePosts(changes, `Sending ${this.singular} to trash...`); this.store.delete(itemID); } break; case 'trash': let changes = {}; changes[itemID] = { 'post_status': 'trash', 'content': this.content }; window.fade(actionButton.closest('.item'), false); this.savePosts(changes, `Sending ${this.singular} to trash...`); break; case 'bulk-edit': if (this.selected.size > 0) { this.openBulkEditModal(); } break; case 'bulk-delete': if (this.selected.size > 0 && confirm(`Delete ${this.selected.size} items?`)) { this.selected.forEach(id => this.store.delete(id)); this.selectionHandler.clearSelection(); } break; case 'refresh': this.store.clearCache(); this.store.fetch(); break; case 'clear-filters': this.clearAllFilters(); break; } } const applyDate = window.targetCheck(e, '.apply-date-filter'); if (applyDate) { this.handleCustomDateSelection(); this.modals.date.handleClose(); return; } const createButton = window.targetCheck(e, this.selectors.buttons.create); if (createButton) { this.ui.modals.create.form.reset(); this.formController.registerForm(this.ui.modals.create.form); this.modals.create.handleOpen(); } } handleInput(e) { e.preventDefault(); e.stopPropagation(); let query = e.target.value.trim(); let key = `${this.content}-search`; if (query.length === 0) { this.deleteFilter('search', ''); return; } // Require minimum 2 characters // if (query.length < 2) { // return; // } window.debouncer.schedule( key, () => { this.a11y.announce(`Searching for "${query}"...`); this.store.setFilters({ search: query, page: 1 }); }, 300 ); } handleKeys(e) { if (!this.tabNav) return; if (e.key === 'Tab') { e.preventDefault(); const currentCell = e.target.closest('[data-field]'); const currentRow = e.target.closest('tr'); if (!currentCell || !currentRow) return; const fieldName = currentCell.dataset.field; const isShift = e.shiftKey; // Find next editable row let targetRow = this.findNextEditableRow(currentRow, isShift); // If no target row found, wrap around if (!targetRow) { targetRow = this.wrapToRow(currentRow, isShift); } if (targetRow) { this.focusFieldInRow(targetRow, fieldName, isShift); } } } findNextEditableRow(currentRow, goBackward = false) { let row = goBackward ? currentRow.previousElementSibling : currentRow.nextElementSibling; // For timeline tables, skip non-editable rows while (row && !this.isEditableRow(row)) { row = goBackward ? row.previousElementSibling : row.nextElementSibling; } return row; } wrapToRow(currentRow, goBackward = false) { if (this.isTimeline) { // For timeline, stay within the same tbody const tbody = currentRow.closest('tbody'); if (!tbody) return null; const rows = Array.from(tbody.querySelectorAll('tr')) .filter(row => this.isEditableRow(row)); return goBackward ? rows[rows.length - 1] : rows[0]; } else { // For regular tables, use all rows in tbody if (!this.ui.table.body) return null; const rows = Array.from(this.ui.table.body.querySelectorAll('tr')) .filter(row => this.isEditableRow(row)); return goBackward ? rows[rows.length - 1] : rows[0]; } } isEditableRow(row) { // Skip thead/tfoot if (row.closest('thead') || row.closest('tfoot')) { return false; } // For timeline, check for specific classes if (this.isTimeline) { return row.classList.contains('shared') || row.classList.contains('timeline-point'); } // For regular tables, check for data-id return !!row.dataset.id; } focusFieldInRow(row, fieldName, fromAbove = false) { const targetCell = row.querySelector(`[data-field="${fieldName}"]`); if (!targetCell) return; const input = this.findFocusableInput(targetCell); if (input) { input.focus(); // Select text if it's a text input if (input.select && input.type === 'text') { input.select(); } // Announce for accessibility const direction = fromAbove ? 'next' : 'previous'; this.a11y?.announce(`Moved to ${fieldName} in ${direction} row`); } } findFocusableInput(cell) { const selectors = [ 'input:not([type="hidden"]):not([disabled])', 'textarea:not([disabled])', 'select:not([disabled])', 'button:not([disabled])' ]; for (const selector of selectors) { const element = cell.querySelector(selector); if (element) return element; } return null; } toggleTimelineListeners(on = true) { if (!this.isTimeline || this.view !== 'table') return; // Cleanup existing instances if (this.timelineSortables) { this.timelineSortables.forEach(sortable => sortable.destroy()); this.timelineSortables = []; } if (!on) return; // Initialize sortable for each tbody this.timelineSortables = []; const tbodies = this.ui.table.form.querySelectorAll('tbody.item'); tbodies.forEach(tbody => { const sortable = new Sortable(tbody, { animation: 150, handle: '.drag-handle', draggable: 'tr.timeline-point', ghostClass: 'sortable-ghost', chosenClass: 'sortable-chosen', dragClass: 'sortable-drag', // Prevent dragging between different tbodies group: { name: `timeline-${tbody.dataset.id}`, pull: false, put: false }, onEnd: (evt) => this.handleTimelineReorder(evt) }); this.timelineSortables.push(sortable); }); } handleTimelineReorder(evt) { const { item: draggedRow, oldIndex, newIndex, from: tbody } = evt; // No change if (oldIndex === newIndex) return; const itemID = parseInt(tbody.dataset.id); const item = this.store.get(itemID); if (!item?.fields?.timeline) return; // Get current order of image IDs from DOM const newOrder = Array.from(tbody.querySelectorAll('tr.timeline-point')) .map(row => row.dataset.imageId) .filter(Boolean); // Rebuild timeline object in new order const reorderedTimeline = {}; newOrder.forEach((imgId, index) => { if (item.fields.timeline[imgId]) { reorderedTimeline[imgId] = { ...item.fields.timeline[imgId], order: index }; } }); item.fields.timeline = reorderedTimeline; // Save to store and server this.store.save(item); this.savePosts( { [itemID]: { timeline: reorderedTimeline, content: this.content } }, 'Reordering timeline...', true ); this.a11y?.announce(`Moved to position ${newIndex + 1}`); } /******************************************************************* MODALS *******************************************************************/ openEditModal(itemID) { let item = this.store.get(parseInt(itemID)); if (!item) return; this.activeItem = item.id; this.ui.modals.edit.modal.dataset.itemId = itemID; this.ui.modals.edit.modal.dataset.content = this.content; this.ui.modals.edit.h2.textContent = `Editing ${item.fields.post_title === '' ? this.singular : item.fields.post_title}`; this.ui.modals.edit.form.dataset.formId = `edit-${itemID}`; this.ui.modals.edit.form.reset(); new window.jvbPopulate(this.ui.modals.edit.form, item); this.formController.registerForm(this.ui.modals.edit.form); this.modals.edit.handleOpen(); } openBulkEditModal() { window.removeChildren(this.ui.modals.bulkEdit.selected); this.ui.modals.edit.form.reset(); this.modals.bulkEdit.handleOpen(); this.selected.forEach(itemId => { let template = window.getTemplate('bulkItem'); if (!template) return; let item = this.store.get(parseInt(itemId)); if (!item) return; let [checkbox, img, label] = [template.querySelector('input'), template.querySelector('img'), template.querySelector('label')]; if (checkbox) { checkbox.id = `bulk_${item.id}`; checkbox.value = item.id; checkbox.checked = true; checkbox.name = 'selected[]'; } let thumbnail = item.images[item.fields.post_thumbnail]??{}; if (img) { img.src = thumbnail.medium??''; img.alt = thumbnail.alt??'' } label.title = item.fields['post_title']??''; this.ui.modals.bulkEdit.selected.append(template); }); if (this.ui.modals.bulkEdit.h2) { this.ui.modals.bulkEdit.h2.textContent = this.selected.size; } this.formController.registerForm(this.ui.modals.bulkEdit.form); } /***************************************************************** FIELD HANDLING *****************************************************************/ handleFormChange(event, data) { let title = data.fullData.post_title; let changes = (Object.hasOwn(data, 'changes')) ? data.changes : data.fullData; let theChanges = {}; if (this.isTimeline) { theChanges[this.activeItem] = changes; if (changes.post_status && this.shouldRemoveItemUI(changes.post_status)) { this.removeItems([this.activeItem]); } this.savePosts(theChanges, title, event === 'form-submit'); return; } let remove = []; let el = data.config.element; if (el === this.ui.modals.edit.form) { theChanges[this.activeItem] = changes; title = `Saving ${title} Changes`; if (changes.post_status && this.shouldRemoveItemUI(changes.post_status)) { remove.push(this.activeItem); } } else if (el === this.ui.modals.bulkEdit.form) { let num = 0; el.querySelectorAll('.selected input:checked').forEach(selected => { theChanges[selected.value] = changes; if (changes.post_status && this.shouldRemoveItemUI(changes.post_status)) { remove.push(selected.value); } num++; }); title = `Updating ${num} ${this.plural} Changes`; } else if (el === this.ui.modals.create.form) { if (event === 'form-submit') { theChanges[el.dataset.formId] = changes; title = `Saving ${title} Changes`; } } if (remove.length > 0) { this.removeItems(remove); } this.selectionHandler.clearSelection(); if (Object.keys(theChanges).length === 0) { return; } this.savePosts(theChanges, title, event === 'form-submit'); } savePosts(changes, title, delay = false) { if (Object.keys(changes).length === 0) return; let validChanges = {}; for (let itemId in changes) { // Validate ID exists and is not null/undefined if (!itemId || itemId === 'null' || itemId === 'undefined') { console.warn('Skipping save for invalid ID:', itemId); continue; } if (!changes[itemId]['content']) changes[itemId]['content'] = this.content; validChanges[itemId] = changes[itemId]; } let operation = { endpoint: this.endpoint, headers: { 'action_nonce': window.auth.getNonce('dash'), }, data: { posts: validChanges, }, delay: delay, popup: `Saving changes`, title: title }; this.queue.addToQueue(operation); } handleTableChange(e) { const container = this.isTimeline ? e.target.closest('tbody[data-id]') : e.target.closest('tr[data-id]'); if (!container) return; const input = e.target; const field = input.closest('[data-field]')?.dataset.field; if (!field) return; const itemID = parseInt(container.dataset.id); const item = this.store.get(itemID); if (!item) return; const value = this.getInputValue(input); // Timeline-specific: check if it's a point or shared value if (this.isTimeline) { const timelinePoint = input.closest('tr.timeline-point'); if (timelinePoint) { const imgID = timelinePoint.dataset.imageId; item.fields.timeline ??= {}; item.fields.timeline[imgID] ??= {}; item.fields.timeline[imgID][field] = value; } else { item.fields[field] = value; } } else { item.fields[field] = value; } this.store.save(item); this.savePosts({ [itemID]: item.fields }, `Saving changes...`, true); } getInputValue(input) { if (input.type === 'checkbox') { return input.checked ? (input.value || '1') : ''; } if (input.type === 'radio') { return input.checked ? input.value : null; } return input.value; } setBulkStatus(status) { if (!['publish', 'draft', 'trash', 'delete'].includes(status)) return; let changes = {}; this.selected.forEach(itemID => { changes[itemID] = { post_status: status, content: this.content }; }); let title; switch (status) { case 'delete': title = 'Deleting'; break; default: title = window.uppercaseFirst(status)+'ing'; } if (this.shouldRemoveItemUI(status)) { this.removeItems(Object.keys(changes)); } this.selectionHandler.clearSelection(); if (Object.keys(changes).length !== 0) { this.savePosts(changes, `${title} ${Object.keys(changes).length} ${this.plural}...`); } } /*************************************************************** VIEW ***************************************************************/ render() { const items = this.store.getFiltered(); if (items.length === 0) { this.renderEmpty(); return; } switch (this.view) { case 'grid': this.renderGrid(items); break; case 'table': this.renderTable(items); break; case 'list': this.renderList(items); break; } this.updateUI(); } updateUI() { if (this.ui.bulk.action) { let options = false; let hasEdit =this.ui.bulk.action.querySelector('[value="edit"]'); if (this.status === 'trash' && hasEdit) { window.removeChildren(this.ui.bulk.action); options = window.getTemplate('trashOptions'); } else if (this.status !== 'trash' && !hasEdit) { window.removeChildren(this.ui.bulk.action); options = window.getTemplate('notTrashOptions'); } if (options) { options.querySelectorAll('option').forEach((option, index)=> { if (index === 0) option.checked = true; this.ui.bulk.action.append(option); }); } this.ui.bulk.action.value = ''; } if (this.selected.size > 0) { this.selectionHandler.updateSelectionUI(); } } renderEmpty() { this.toggleTable(false); window.removeChildren(this.ui.grid); const empty = window.getTemplate('emptyState'); if (empty) { this.ui.grid.append(empty); this.a11y.announceItems(0,false,false); } } toggleTable(on = true) { if (this.ui.table.selectedColumns) this.ui.table.selectedColumns.hidden = !on; if (on && !this.ui.table.form) { let table = window.getTemplate('contentTable'); this.container.append(table); this.ui.table = window.uiFromSelectors(this.selectors.table); this.ui.table.columns = this.container.querySelectorAll(this.selectors.table.columns); } if (this.ui.table.form) { this.ui.table.form.hidden = !on; if (on) { this.formController.registerForm(this.ui.table.form, { autosave: false, formStatus: false, isTable: true }); } else { this.formController.cleanupForm(this.ui.table.form.dataset.formId) } if (this.ui.table.body) { window.removeChildren(this.ui.table.body); } } this.keyHandler = this.handleKeys.bind(this); if (on) { document.addEventListener('keydown', this.keyHandler); } else { document.removeEventListener('keydown', this.keyHandler); } if (this.isTimeline && !on) this.toggleTimelineListeners(on); } renderGrid(items) { window.removeChildren(this.ui.grid); this.toggleTable(false); this.ui.grid.classList.remove('list-view'); this.ui.grid.classList.add('grid-view'); const fragment = document.createDocumentFragment(); items.forEach(item => { let card = this.renderGridItem(item); fragment.appendChild(card); }); this.ui.grid.appendChild(fragment); } renderList(items) { window.removeChildren(this.ui.grid); this.toggleTable(false); this.ui.grid.classList.remove('grid-view'); this.ui.grid.classList.add('list-view'); const fragment = document.createDocumentFragment(); items.forEach(item => { let row = this.renderListItem(item); fragment.append(row); }); this.ui.grid.append(fragment); } renderTable(items) { this.toggleTable(); window.removeChildren(this.ui.grid); let fragment = document.createDocumentFragment(); items.forEach(item => { let row = this.renderTableItem(item); if (row) fragment.append(row); }); if (this.ui.table.body) { this.ui.table.body.append(fragment); } else { this.ui.table.table.insertBefore(fragment, this.ui.table.foot); } if (this.isTimeline) { this.toggleTimelineListeners(true); } } /*************************************************************** RENDER HELPERS ***************************************************************/ setupItemElement(element, item, templateName) { if (this.items[this.view].has(item.id)) { return this.items[this.view].get(item.id); } element.dataset.id = item.id; if (item._pending) element.classList.add('pending'); // Setup selection checkbox/label this.setupItemSelection(element, item); // Setup thumbnail this.setupItemThumbnail(element, item); // Setup action buttons this.setupItemActions(element, item); this.items[this.view].set(item.id, element); return element; } setupItemSelection(element, item) { const checkbox = element.querySelector('.select-item'); const label = element.querySelector('.select-item + label, .select-item-label'); if (!checkbox) return; checkbox.id = `select-${item.id}`; checkbox.value = item.id; checkbox.checked = this.selected.has(parseInt(item.id)); if (label) { label.htmlFor = `select-${item.id}`; } } setupItemThumbnail(element, item) { const img = element.querySelector('img'); if (!img) return; const thumbnail = item.images?.[item.fields?.post_thumbnail] ?? {}; img.src = thumbnail.medium ?? ''; img.alt = thumbnail.alt ?? ''; } setupItemActions(element, item) { element.querySelectorAll('[data-action]').forEach(btn => { btn.dataset.id = item.id; }); } renderGridItem(item) { if (this.items.grid.has(item.id)) { return this.items.grid.get(item.id); } const card = window.getTemplate('gridView'); if (!card) return null; return this.setupItemElement(card, item, 'grid'); } renderListItem(item) { if (this.items.list.has(item.id)) { return this.items.list.get(item.id); } let row = window.getTemplate('listView'); if (!row) return null; row = this.setupItemElement(row, item, 'list'); // List-specific: populate data attributes row.querySelectorAll('[data-attr]').forEach(el => { const value = item[el.dataset.attr]; if (value && value !== '') { el.textContent = value; } else { el.remove(); } }); row.querySelectorAll('[data-field]').forEach(el => { const value = item.fields?.[el.dataset.field]; if (value && value !== '') { el.tagName === 'DIV' ? el.innerHTML = value : el.textContent = value; } else { el.remove(); } }); this.items.list.set(item.id, row); return row; } renderTableItem(item) { if (this.items.table.has(item.id)) { return this.items.table.get(item.id); } let row = window.getTemplate('tableView'); if (!row) return null; row = this.setupItemElement(row, item, 'table'); const status = row.querySelector(`input[name="post_status"][value="${item.status}"]`); if (status) status.checked = true; if (this.isTimeline) { // Timeline: populate shared row, clone points const sharedRow = row.querySelector('tr.shared'); if (sharedRow) { new window.jvbPopulate(sharedRow, item); this.cleanupTableRow(sharedRow); } const pointTemplate = row.querySelector('tr.timeline-point'); if (pointTemplate && item.fields?.timeline) { Object.entries(item.fields.timeline).forEach(([imgId, timeline], index) => { const point = pointTemplate.cloneNode(true); point.dataset.index = index; point.dataset.imageId = imgId; new window.jvbPopulate(point, { fields: timeline, images: item.images, taxonomies: item.taxonomies }); this.cleanupTableRow(point); const imgData = item.images?.[timeline.post_thumbnail]; if (imgData) { point.querySelector('.field.upload')?.setAttribute('title', imgData['image-title'] || ''); } row.insertBefore(point, pointTemplate); }); pointTemplate.remove(); } } else { // Standard table row if (this.ui.table.form?.dataset.edit !== undefined) { new window.jvbPopulate(row, item); } else { this.populateTableReadOnly(row, item); } this.cleanupTableRow(row); } this.items.table.set(item.id, row); return row; } populateTableReadOnly(row, item) { for (const [key, value] of Object.entries(item)) { const col = row.querySelector(`[data-field="${key}"]`); if (!col) continue; const p = col.querySelector('p'); if (p) { p.textContent = col.dataset.fieldType === 'date' ? window.formatTimeAgo(value) : value; } } } cleanupTableRow(row) { row.querySelectorAll('td[data-field]').forEach(field => { field.querySelectorAll('label:not(.select-item-label,.radio-option,[for*="select-item"])').forEach(label => { if (!label.closest('.radio-options')) { label.remove(); } }); //Remove toggle labels for true_false fields if (field.dataset.fieldType === 'true_false') { field.querySelector('.toggle-label')?.remove(); } //Remove field label for checkbox/radio groups if (['checkbox','radio','select'].includes(field.dataset.fieldType)) { field.querySelector('.label')?.remove(); } }); } toggleColumn(column, show) { this.ui.table.table.querySelectorAll(`.${column}`).forEach(el =>{ el.hidden = !show; }); } /*************************************************************** UTILITY ***************************************************************/ shouldRemoveItemUI(newStatus) { return (this.status === 'all' && !['publish', 'draft'].includes(newStatus)) || newStatus !== this.store.filters.status; } removeItems(items) { let delay = 0; items.forEach(itemId => { setTimeout(() => { let item = this.items[this.view].get(itemId); if (item) { window.fade(item, false); } }, delay); delay += 50; }); } setFilters(filters) { for (let [key, value] of Object.entries(filters)) { if (!this.allowedFilters.includes(key)) { delete filters[key]; continue; } this.cache.set(key, value); let el = this.findFilterEl(key); this.setElValue(el, value); } this.store.setFilters(filters); } setFilter(name, value) { if (!this.allowedFilters.includes(name)) return; this.cache.set(name, value); let el = this.findFilterEl(name, value); this.setElValue(el, value); //TODO: If we set the element to checked, does that automatically call the change listener, which then also sets the store filter and cache? this.store.setFilter(name, value); } deleteFilter(name, value) { if (!this.allowedFilters.includes(name)) return; if (Object.hasOwn(this.defaults, name)) { this.setFilter(name, this.defaults[name]); return; } let el = this.findFilterEl(name, value); this.setElValue(el, false); this.cache.remove(name); this.setFilter(name, ''); } setElValue(element, value) { if (!element) return; if (!value) { if (['SELECT','TEXTAREA'].includes(element.tagName)) element.value = ''; if (['text', 'search'].includes(element.type)) element.value = ''; if (element.type === 'radio') element.checked = false; return; } if (['SELECT','TEXTAREA'].includes(element.tagName)) element.value = value; if (['text', 'search'].includes(element.type)) element.value = value; if (element.type === 'radio') element.checked = true; } findFilterEl(name, value) { //Handle exceptions first (custom date elements) if (['date-filter', 'dateFrom', 'dateTo'].includes(name)) { switch (name) { case 'date-filter': name = 'month'; break; case 'dateFrom': name = 'start'; break; case 'dateTo': name = 'end'; break; } return this.ui.modals.date[name]; } // Handle taxonomy filters if (name.includes('tax_')) { const taxonomy = name.replace('tax_', ''); const element = this.ui.filters.taxonomies?.[taxonomy]; if (element) { return element; } console.warn('Taxonomy filter element not found:', taxonomy); return null; } if (!Object.hasOwn(this.ui.filters, name)) { console.warn('Filter el not found: ', name); return false; } let el = this.ui.filters[name]; if (typeof el === 'object') { if (!Object.hasOwn(this.ui.filters[name], value)) { return false; } el = this.ui.filters[name][value]; } return el; } /*************************************************************** CLEANUP ***************************************************************/ destroy() { if (this.timelineSortables) { this.timelineSortables.forEach(sortable => sortable.destroy()); this.timelineSortables = []; } document.removeEventListener('click', this.clickHandler); document.removeEventListener('change', this.changeHandler); if (this.ui.filters.search) { this.ui.filters.search.removeEventListener('input', this.handleInput); } for (let map of Object.values(this.items)) { map.clear(); } } } document.addEventListener('DOMContentLoaded', async function() { window.auth.subscribe((event) => { if (event === 'auth-loaded') { let container = document.querySelector('[data-content]'); if (container && !Object.hasOwn(container.dataset, 'ignore')) { window.crudManager = new CRUDManager({ content: container.dataset.content, }); } } }); });