| | |
| | | |
| | | this.container = container; |
| | | this.initElements(); |
| | | this.settings = window.jvbUserSettings; |
| | | |
| | | this.store = store; |
| | | |
| | | this.isTimeline = !!document.querySelector('[data-timeline]'); |
| | | |
| | | this.items = { |
| | | list: new Map(), |
| | | grid: new Map(), |
| | |
| | | } |
| | | this.currentView = 'grid'; |
| | | this.selectedItems = new Set(); |
| | | this.subscribers = new Set(); |
| | | |
| | | this.init(); |
| | | } |
| | |
| | | this.selectors = { |
| | | grid: '.item-grid', |
| | | table: { |
| | | table: 'table', |
| | | table: 'form.table', |
| | | form: 'table', |
| | | body: 'table body', |
| | | header: 'table thead', |
| | | footer: 'table tfoot', |
| | | selectedColumns: '.all-filters .multi-select', |
| | | columns: 'thead th' |
| | | }, |
| | |
| | | // Subscribe to store updates |
| | | this.store.subscribe((event, data) => { |
| | | switch(event) { |
| | | case 'data-loaded': |
| | | case 'items-saved': |
| | | this.handleDataUpdate(data); |
| | | // this.handleDataUpdate(data); |
| | | break; |
| | | case 'items-updated': |
| | | this.handleItemsUpdate(data.items); |
| | | case 'data-loaded': |
| | | this.handleItemsUpdate(); |
| | | break; |
| | | case 'item-saved': |
| | | // this.updateItem(data.item); |
| | |
| | | |
| | | setupViewSwitcher() { |
| | | document.querySelectorAll('[data-view]').forEach(btn => { |
| | | this.settings.addSetting(btn); |
| | | btn.addEventListener('click', () => { |
| | | this.currentView = btn.dataset.view; |
| | | this.render(); |
| | | }); |
| | | }); |
| | | |
| | | const checkedView = document.querySelector('[data-view]:checked'); |
| | | if (checkedView) { |
| | | this.currentView = checkedView.dataset.view; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle data updates from store |
| | | */ |
| | | handleDataUpdate(data) { |
| | | if (data.data && data.data.items) { |
| | | this.render(data.data.items); |
| | | } |
| | | console.log(data); |
| | | const items = data.data?.items || data.items || []; |
| | | this.render(items); |
| | | } |
| | | |
| | | /** |
| | | * Handle items update |
| | | */ |
| | | handleItemsUpdate(items) { |
| | | this.render(items); |
| | | handleItemsUpdate() { |
| | | this.render(); |
| | | } |
| | | |
| | | render(items = null) { |
| | | render() { |
| | | if (!this.store) { |
| | | console.error('No store connected to renderer'); |
| | | return; |
| | | } |
| | | const items = this.store.getFiltered(); |
| | | |
| | | // Get items from store if not provided |
| | | if (!items) { |
| | | const currentRequest = this.store.getCurrentRequest(); |
| | | if (currentRequest && currentRequest.data && currentRequest.data.items) { |
| | | items = currentRequest.data.items; |
| | | } else { |
| | | return; |
| | | } |
| | | // Handle empty state |
| | | if (items.length === 0) { |
| | | this.renderEmpty(); |
| | | return; |
| | | } |
| | | |
| | | switch(this.currentView) { |
| | | case 'grid': |
| | | this.renderGrid(items); |
| | |
| | | this.updateSelectionUI(); |
| | | } |
| | | |
| | | renderEmpty() { |
| | | this.toggleTable(false); |
| | | window.removeChildren(this.ui.grid); |
| | | |
| | | const empty = window.getTemplate('emptyState'); |
| | | if (empty) { |
| | | this.ui.grid.appendChild(empty); |
| | | this.a11y?.announce('No items found'); |
| | | } |
| | | } |
| | | |
| | | renderGrid(items) { |
| | | this.toggleGrid(); |
| | | this.toggleTable(false); |
| | |
| | | const fragment = document.createDocumentFragment(); |
| | | |
| | | items.forEach(item => { |
| | | let card; |
| | | if (this.store.renderOrRetrieve) { |
| | | card = this.store.renderOrRetrieve(item, 'grid', this.renderGridItem.bind(this)); |
| | | } else { |
| | | // Fallback to local cache |
| | | if (this.items.grid.has(item.id)) { |
| | | card = this.items.grid.get(item.id); |
| | | } else { |
| | | card = this.renderGridItem(item); |
| | | this.items.grid.set(item.id, card); |
| | | } |
| | | } |
| | | |
| | | let card = this.renderGridItem(item); |
| | | fragment.appendChild(card); |
| | | }); |
| | | this.ui.grid.appendChild(fragment); |
| | | } |
| | | renderGridItem(item) { |
| | | if (this.items.grid.has(item.id)) { |
| | | return this.items.grid.get(item.id); |
| | | } |
| | | const card = window.getTemplate('gridView'); |
| | | card.dataset.id = item.id; |
| | | |
| | |
| | | checkbox.id, |
| | | checkbox.checked, |
| | | label.htmlFor, |
| | | img.src, |
| | | img.alt, |
| | | edit.dataset.id, |
| | | trash.dataset.id |
| | | ] = [ |
| | |
| | | `select-${item.id}`, |
| | | this.selectedItems.has(`${item.id}`), |
| | | `select-${item.id}`, |
| | | item.images[item.fields.post_thumbnail]?.medium??'', |
| | | item.images[item.fields.post_thumbnail]?.alt??'', |
| | | item.id, |
| | | item.id |
| | | ]; |
| | | // if (this.store.config.storeName === 'progress') { |
| | | // [ |
| | | // img.src, |
| | | // img.alt, |
| | | // ] = [ |
| | | // item.images[item.fields['timeline'][0].post_thumbnail]?.medium??'', |
| | | // item.images[item.fields['timeline'][0].post_thumbnail]?.alt??'', |
| | | // ]; |
| | | // } else { |
| | | [ |
| | | img.src, |
| | | img.alt, |
| | | ] = [ |
| | | item.images[item.fields.post_thumbnail]?.medium??'', |
| | | item.images[item.fields.post_thumbnail]?.alt??'', |
| | | ]; |
| | | |
| | | // } |
| | | |
| | | this.items.grid.set(item.id, card); |
| | | return card; |
| | | } |
| | | |
| | |
| | | let table = window.getTemplate('contentTable'); |
| | | this.container.append(table); |
| | | this.ui.table.table = this.container.querySelector('form.table'); |
| | | this.ui.table.body = this.ui.table.table.querySelector('tbody'); |
| | | this.ui.table.form = this.ui.table.table.querySelector('table'); |
| | | this.ui.table.header = this.ui.table.form.querySelector('thead'); |
| | | this.ui.table.footer = this.ui.table.form.querySelector('tfoot'); |
| | | this.ui.table.body = this.ui.table.form.querySelector('tbody'); |
| | | this.ui.table.columns = this.container.querySelectorAll(this.selectors.table.columns); |
| | | } |
| | | |
| | | if (this.ui.table.table) { |
| | | this.ui.table.table.hidden = !on; |
| | | window.removeChildren(this.ui.table.body); |
| | | if (on) { |
| | | this.notify('table-view', this.ui.table.table); |
| | | }else { |
| | | this.notify('not-table-view', this.ui.table.table); |
| | | } |
| | | |
| | | if (this.ui.table.body){ |
| | | window.removeChildren(this.ui.table.body); |
| | | } |
| | | } |
| | | this.ui.table.selectedColumns.hidden = !on; |
| | | } |
| | |
| | | this.toggleGrid(); |
| | | |
| | | items.forEach(item => { |
| | | let row; |
| | | if (this.items.table.has(item.id)) { |
| | | row = this.items.table.get(item.id); |
| | | |
| | | let row = (this.isTimeline) ? this.renderTimelineTableItem(item) : this.renderTableItem(item); |
| | | |
| | | if (this.ui.table.body) { |
| | | this.ui.table.body.append(row); |
| | | } else { |
| | | row = this.store.renderOrRetrieve(item, 'table', this.renderTableItem.bind(this)); |
| | | this.items.table.set(item.id, row); |
| | | if (!this.ui.table.footer) { |
| | | this.ui.table.footer = this.ui.table.table.querySelector('tfoot'); |
| | | } |
| | | this.ui.table.form.insertBefore(row, this.ui.table.footer); |
| | | } |
| | | |
| | | this.ui.table.body.append(row); |
| | | }); |
| | | |
| | | window.jvbSelector.scanExistingFields(); |
| | | |
| | | } |
| | | |
| | | renderTableItem(item) { |
| | | let empty = ['',0]; |
| | | if (this.items.table.has(item.id)) { |
| | | return this.items.table.get(item.id); |
| | | } |
| | | |
| | | const row = window.getTemplate('tableView'); |
| | | row.dataset.id = item.id; |
| | | |
| | |
| | | item.status |
| | | ]; |
| | | |
| | | // Let jvbPopulate do its thing - NO prefixing needed! |
| | | new window.jvbPopulate(row, item.fields, item.images); |
| | | |
| | | row.querySelectorAll('td[data-field]').forEach(field => { |
| | | let value = item.fields[field.dataset.field]; |
| | | // field.querySelectorAll('label').forEach(label => { |
| | | // label.hidden = true; |
| | | // }); |
| | | // Clean up after population |
| | | this.cleanupTableRow(row); |
| | | |
| | | let label = field.querySelector('label'); |
| | | let isEmpty = (empty.includes(value)); |
| | | let temp; |
| | | switch (field.dataset.fieldType) { |
| | | case 'text': |
| | | case 'number': |
| | | case 'url': |
| | | case 'tel': |
| | | case 'email': |
| | | if (!isEmpty) { |
| | | field.querySelector('input').value = value; |
| | | } |
| | | label.remove(); |
| | | break; |
| | | case 'textarea': |
| | | if (!isEmpty) { |
| | | field.querySelector('textarea').value = value; |
| | | } |
| | | label.remove(); |
| | | break; |
| | | case 'taxonomy': |
| | | label.remove(); |
| | | if (!isEmpty) { |
| | | temp = field.querySelector('input[type=hidden]'); |
| | | temp.value = value; |
| | | } |
| | | break; |
| | | case 'image': |
| | | if (!isEmpty) { |
| | | let image = window.getTemplate('uploadItem'); |
| | | let img = image.querySelector('img'); |
| | | [ |
| | | img.src, |
| | | img.alt |
| | | ] = [ |
| | | item.images[value].medium??'', |
| | | item.images[value].alt??'', |
| | | ]; |
| | | field.querySelector('.item-grid').append(image); |
| | | field.querySelector('input[type=hidden]').value = value; |
| | | } |
| | | field.querySelectorAll('.progress,label,.upload-select,.status,details').forEach(item => { |
| | | item.remove(); |
| | | }); |
| | | break; |
| | | case 'true_false': |
| | | if (!isEmpty) { |
| | | field.querySelector('input').checked = parseInt(value) === 1; |
| | | } |
| | | field.querySelector('.toggle-label')?.remove(); |
| | | break; |
| | | case 'select': |
| | | label.remove(); |
| | | case 'radio': |
| | | case 'checkbox': |
| | | field.querySelector('.label')?.remove(); |
| | | if (!isEmpty) { |
| | | value = value.split(','); |
| | | value.forEach(v => { |
| | | temp = field.querySelector(`[value="${v}"]`); |
| | | if (temp) { |
| | | temp.checked = true; |
| | | } |
| | | }); |
| | | } |
| | | break; |
| | | default: |
| | | if (!isEmpty) { |
| | | console.log(value); |
| | | } |
| | | break; |
| | | this.items.table.set(item.id, row); |
| | | return row; |
| | | } |
| | | |
| | | renderTimelineTableItem(item) { |
| | | if (this.items.table.has(item.id)) { |
| | | return this.items.table.get(item.id); |
| | | } |
| | | |
| | | const row = window.getTemplate('tableView'); |
| | | row.dataset.id = item.id; |
| | | |
| | | [ |
| | | row.querySelector('.select-item').id, |
| | | row.querySelector('.select-item').value, |
| | | row.querySelector('.select-item').checked, |
| | | row.querySelector('.select-item + label').htmlFor, |
| | | ] = [ |
| | | item.id, |
| | | item.id, |
| | | this.selectedItems.has(`${item.id}`), |
| | | item.id, |
| | | ]; |
| | | |
| | | let timelinePoint = row.querySelector('.timeline-point'); |
| | | let tbody = row; |
| | | |
| | | // Populate shared fields - NO prefixing! |
| | | let sharedRow = row.querySelector('tr.shared'); |
| | | new window.jvbPopulate(sharedRow, item.fields, item.images); |
| | | this.prefixTimelineFieldNames(sharedRow, item.id); |
| | | this.cleanupTableRow(sharedRow); |
| | | |
| | | // Handle timeline points - NO prefixing! |
| | | if (item.fields.timeline && typeof item.fields.timeline === 'object') { |
| | | const timelineArray = Object.entries(item.fields.timeline); |
| | | |
| | | timelineArray.forEach(([imgId, timeline], index) => { |
| | | |
| | | let point = timelinePoint.cloneNode(true); |
| | | point.dataset.index = index; |
| | | point.dataset.imageId = imgId; |
| | | |
| | | |
| | | new window.jvbPopulate(point, timeline, item.images); |
| | | |
| | | this.cleanupTableRow(point); |
| | | let imgdata = item.images[timeline.post_thumbnail]; |
| | | if (imgdata) { |
| | | point.querySelector('.field.upload').title = imgdata['image-title']; |
| | | } |
| | | |
| | | this.prefixTimelineFieldNames(point,timeline.id); |
| | | |
| | | tbody.insertBefore(point, timelinePoint); |
| | | }); |
| | | } |
| | | |
| | | timelinePoint.remove(); |
| | | |
| | | this.items.table.set(item.id, row); |
| | | return row; |
| | | } |
| | | |
| | | /** |
| | | * Timeline uses bracket notation: [postId]fieldName |
| | | * This matches the collectTimeline() method in FormController |
| | | */ |
| | | prefixTimelineFieldNames(row, postId) { |
| | | row.querySelectorAll('input, textarea, select').forEach(field => { |
| | | const currentName = field.name; |
| | | |
| | | if (!currentName || currentName.startsWith('[') || |
| | | currentName === 'form-id' || currentName.startsWith('_')) { |
| | | return; |
| | | } |
| | | |
| | | // Use bracket notation for timeline |
| | | |
| | | let label = field.nextElementSibling; |
| | | field.name = `[${postId}]${currentName}`; |
| | | if (label && label.tagName === 'LABEL') { |
| | | field.id = `[${postId}]${field.id}`; |
| | | label.htmlFor = field.id; |
| | | } |
| | | }); |
| | | return row; |
| | | } |
| | | |
| | | cleanupTableRow(row) { |
| | | row.querySelectorAll('td[data-field]').forEach(field => { |
| | | // Remove labels (they're in the header) |
| | | field.querySelectorAll('label:not(.select-item-label,.radio-option,[for*="select-item"])').forEach(label => { |
| | | if (!label.closest('.radio-options')) { |
| | | label.remove(); |
| | | } |
| | | }); |
| | | |
| | | // Special handling for image/upload fields |
| | | // if (field.dataset.fieldType === 'image' || field.dataset.fieldType === 'upload') { |
| | | // const itemGrid = field.querySelector('.item-grid'); |
| | | // const uploadContainer = field.querySelector('.file-upload-container'); |
| | | // |
| | | // // If grid has items (populated), just remove upload UI |
| | | // if (itemGrid && itemGrid.children.length > 0) { |
| | | // // Remove upload controls but keep the populated items |
| | | // field.querySelectorAll('.progress, .upload-select, .status, details:not(.item-grid details)').forEach(el => { |
| | | // el.remove(); |
| | | // }); |
| | | // // Keep upload container hidden if it was hidden |
| | | // if (uploadContainer && uploadContainer.hidden) { |
| | | // uploadContainer.hidden = true; |
| | | // } |
| | | // } else { |
| | | // // No items, remove all upload UI |
| | | // field.querySelectorAll('.file-upload-wrapper, .progress, .upload-select, .status, details').forEach(el => { |
| | | // el.remove(); |
| | | // }); |
| | | // } |
| | | // } |
| | | |
| | | // Remove toggle labels for true_false fields |
| | | if (field.dataset.fieldType === 'true_false') { |
| | | field.querySelector('.toggle-label')?.remove(); |
| | | } |
| | | |
| | | // Remove field labels for checkbox/radio groups |
| | | if (['checkbox', 'radio', 'select'].includes(field.dataset.fieldType)) { |
| | | field.querySelector('.label')?.remove(); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | renderList(items) { |
| | |
| | | this.ui.grid.classList.add('list-view'); |
| | | |
| | | items.forEach(item => { |
| | | let row; |
| | | if (this.items.list.has(item.id)) { |
| | | row = this.items.list.get(item.id); |
| | | } else { |
| | | row = this.store.renderOrRetrieve(item, 'list', this.renderListItem.bind(this)); |
| | | this.items.list.set(item.id, row); |
| | | } |
| | | |
| | | let row = this.renderListItem(item); |
| | | this.ui.grid.appendChild(row); |
| | | }); |
| | | } |
| | | |
| | | renderListItem(item) { |
| | | if (this.items.list.has(item.id)) { |
| | | return this.items.list.get(item.id); |
| | | } |
| | | const row = window.getTemplate('listView'); |
| | | row.dataset.id = item.id; |
| | | |
| | |
| | | item.images[item.fields.post_thumbnail]?.alt??'', |
| | | ] |
| | | } |
| | | this.items.list.set(item.id, row); |
| | | return row; |
| | | } |
| | | |
| | | setupTimelineDragHandler() { |
| | | if (!this.isTimeline || this.currentView !== 'table') return; |
| | | |
| | | // Clean up existing handler if any |
| | | if (this.timelineDragHandler) { |
| | | this.timelineDragHandler.destroy(); |
| | | } |
| | | |
| | | this.timelineDragHandler = new window.jvbDragHandler({ |
| | | draggableSelector: '.timeline-point', |
| | | dropTargetSelector: '.timeline-point', |
| | | handleSelector: '.drag-handle', |
| | | |
| | | getItemId: (element) => { |
| | | return element.dataset.imageId; |
| | | }, |
| | | |
| | | getSelectedItems: () => { |
| | | return []; |
| | | }, |
| | | |
| | | validateDrop: (itemIds, dropTarget) => { |
| | | const draggedRow = document.querySelector(`.timeline-point[data-image-id="${itemIds[0]}"]`); |
| | | if (!draggedRow) return false; |
| | | |
| | | const draggedTbody = draggedRow.closest('tbody'); |
| | | const targetTbody = dropTarget.closest('tbody'); |
| | | |
| | | return draggedTbody === targetTbody; |
| | | }, |
| | | |
| | | onDragStart: (itemIds, element) => { |
| | | element.classList.add('is-dragging'); |
| | | }, |
| | | |
| | | onDrop: (itemIds, dropTarget) => { |
| | | const draggedRow = document.querySelector(`.timeline-point[data-image-id="${itemIds[0]}"]`); |
| | | if (!draggedRow) return; |
| | | |
| | | // Remove all drop indicators |
| | | document.querySelectorAll('.drop-above, .drop-below').forEach(el => { |
| | | el.classList.remove('drop-above', 'drop-below'); |
| | | }); |
| | | |
| | | const tbody = draggedRow.closest('tbody'); |
| | | const dropPosition = dropTarget.dataset.dropPosition; |
| | | |
| | | // Insert based on drop position |
| | | if (dropPosition === 'above') { |
| | | tbody.insertBefore(draggedRow, dropTarget); |
| | | } else { |
| | | tbody.insertBefore(draggedRow, dropTarget.nextSibling); |
| | | } |
| | | |
| | | draggedRow.classList.remove('is-dragging'); |
| | | this.updateTimelineOrder(tbody); |
| | | }, |
| | | |
| | | onDragEnd: (itemIds, success) => { |
| | | // Clean up all drag classes |
| | | document.querySelectorAll('.is-dragging, .drop-above, .drop-below').forEach(el => { |
| | | el.classList.remove('is-dragging', 'drop-above', 'drop-below'); |
| | | }); |
| | | }, |
| | | |
| | | previewElement: '.drag-handle', |
| | | previewOptions: { |
| | | offset: { x: -20, y: -20 }, |
| | | showCount: false |
| | | } |
| | | }); |
| | | |
| | | // Add custom hover logic for better drop positioning |
| | | this.addTimelineDragHoverLogic(); |
| | | } |
| | | |
| | | addTimelineDragHoverLogic() { |
| | | let currentHover = null; |
| | | |
| | | document.addEventListener('pointermove', (e) => { |
| | | if (!document.querySelector('.timeline-point.is-dragging')) return; |
| | | |
| | | const target = e.target.closest('.timeline-point:not(.is-dragging)'); |
| | | if (!target) { |
| | | if (currentHover) { |
| | | currentHover.classList.remove('drop-above', 'drop-below'); |
| | | delete currentHover.dataset.dropPosition; |
| | | currentHover = null; |
| | | } |
| | | return; |
| | | } |
| | | |
| | | // Determine if we're in the top or bottom half |
| | | const rect = target.getBoundingClientRect(); |
| | | const midpoint = rect.top + (rect.height / 2); |
| | | const isTopHalf = e.clientY < midpoint; |
| | | |
| | | // Update classes |
| | | if (currentHover && currentHover !== target) { |
| | | currentHover.classList.remove('drop-above', 'drop-below'); |
| | | delete currentHover.dataset.dropPosition; |
| | | } |
| | | |
| | | target.classList.remove('drop-above', 'drop-below'); |
| | | target.classList.add(isTopHalf ? 'drop-above' : 'drop-below'); |
| | | target.dataset.dropPosition = isTopHalf ? 'above' : 'below'; |
| | | |
| | | currentHover = target; |
| | | }); |
| | | } |
| | | updateTimelineOrder(tbody) { |
| | | const postId = parseInt(tbody.dataset.id); |
| | | const rows = Array.from(tbody.querySelectorAll('.timeline-point')); |
| | | |
| | | const item = this.store.get(postId); |
| | | if (!item) return; |
| | | |
| | | let timeline = {}; |
| | | // Update menu_order for each timeline point |
| | | rows.forEach((row, index) => { |
| | | const imgID = row.dataset.imageId; |
| | | timeline[imgID] = item.fields.timeline[imgID]; |
| | | }); |
| | | item.fields.timeline = timeline; |
| | | |
| | | // Update store (triggers autosave) |
| | | this.store.save(item); |
| | | this.notify('order-changed', postId); |
| | | this.a11y?.announce(`Timeline order updated. ${rows.length} steps reordered.`); |
| | | } |
| | | |
| | | extractRowFields(row) { |
| | | const fields = {}; |
| | | row.querySelectorAll('[data-field]').forEach(cell => { |
| | | const fieldName = cell.dataset.field; |
| | | const input = cell.querySelector('input, textarea, select'); |
| | | |
| | | if (input) { |
| | | if (input.type === 'checkbox') { |
| | | fields[fieldName] = input.checked; |
| | | } else { |
| | | fields[fieldName] = input.value; |
| | | } |
| | | } |
| | | }); |
| | | return fields; |
| | | } |
| | | |
| | | toggleSelection(id) { |
| | | if (this.selectedItems.has(id)) { |
| | | this.selectedItems.delete(id); |
| | |
| | | this.ui.bulk.count.textContent = count === 0 ? '' : `${count} ${item} selected`; |
| | | } |
| | | } |
| | | |
| | | |
| | | /** |
| | | * Event system |
| | | */ |
| | | subscribe(callback) { |
| | | this.subscribers.add(callback); |
| | | return () => this.subscribers.delete(callback); |
| | | } |
| | | |
| | | notify(event, data) { |
| | | this.subscribers.forEach(cb => cb(event, data)); |
| | | } |
| | | } |
| | | |
| | | window.jvbViews = ViewController; |