/** * Manages view states: grid, list, or table */ class ViewController { constructor(container, store) { this.a11y = window.jvbA11y; this.error = window.jvbError; 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(), table: new Map(), } this.currentView = this.container.dataset.view ?? 'grid'; this.selectedItems = new Set(); this.subscribers = new Set(); this.init(); } initElements() { this.selectors = { grid: '.item-grid', table: { table: 'form.table', form: 'table', body: 'table body', header: 'table thead', footer: 'table tfoot', selectedColumns: '.all-filters .multi-select', columns: 'thead th' }, bulk: { count: '.bulk-controls .selected-count', control: '.bulk-controls .bulk-actions', select: '.bulk-controls select', selectAll: '.select-all' } } this.ui = window.uiFromSelectors(this.selectors, this.container); } init() { // Subscribe to store updates this.store.subscribe((event, data) => { switch(event) { case 'items-saved': // this.handleDataUpdate(data); break; case 'data-loaded': this.handleItemsUpdate(); break; case 'item-saved': // this.updateItem(data.item); break; case 'item-deleted': // this.deleteItem(data.item); break; } }); // Set up view switcher this.setupViewSwitcher(); this.changeHandler = this.handleChange.bind(this); this.clickHandler = this.handleClick.bind(this); this.lastSelected = null; document.addEventListener('change', this.changeHandler); document.addEventListener('click', this.clickHandler); } handleClick(e) { let select = e.target.closest('.select-item-label'); if (select) { if (e.shiftKey) { e.preventDefault(); this.handleRangeSelection(e.target); } else { this.lastSelected = e.target.closest('.item'); } } } handleRangeSelection(target) { if (!this.lastSelected) { this.lastSelected = target.closest('.item'); return; } const current = target.closest('.item'); const all = Array.from(this.container.querySelectorAll('.item')); const lastIndex = all.indexOf(this.lastSelected); const currentIndex = all.indexOf(current); if (lastIndex === -1 || currentIndex === -1) { this.lastSelected = current; return; } const start = Math.min(lastIndex, currentIndex); const end = Math.max(lastIndex, currentIndex); let newSelections = 0; for (let i = start; i <= end; i++) { let item = all[i]; this.selectedItems.add(item.dataset.id); let checkbox = item.querySelector('.select-item'); if (checkbox && !checkbox.checked) { checkbox.checked = true; newSelections++; } } this.updateSelectionUI(); window.jvbA11y.announce(`Selected ${newSelections} items in range.`); } handleChange(e) { if (e.target.closest('.select-all')) { this.selectAll(e.target.checked); } else if (e.target.closest('.select-item')) { this.toggleSelection(e.target.closest('.item').dataset.id); } else if (e.target.closest('details.multi-select')) { this.toggleColumns(e.target.id, e.target.checked); } } toggleColumns(column, show) { let theColumn = this.ui.table.columns.filter(col => col.className === column); // console.log(theColumn); // console.log('Toggle Columns'); // console.log(column, show); // console.log(this.ui.table.columns); } 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 items update */ handleItemsUpdate() { this.render(); } render() { if (!this.store) { console.error('No store connected to renderer'); return; } const items = this.store.getFiltered(); // Handle empty state if (items.length === 0) { console.log('Nothing to show'); this.renderEmpty(); return; } switch(this.currentView) { case 'grid': this.renderGrid(items); break; case 'table': this.renderTable(items); break; case 'list': this.renderList(items); break; } 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); 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); } 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; if (item._pending) card.classList.add('pending'); let [ checkbox, label, img, edit, trash ] = [ card.querySelector('input'), card.querySelector('label'), card.querySelector('img'), card.querySelector('[data-action="edit"]'), card.querySelector('[data-action="trash"]'), ]; [ checkbox.value, checkbox.id, checkbox.checked, label.htmlFor, edit.dataset.id, trash.dataset.id ] = [ item.id, `select-${item.id}`, this.selectedItems.has(`${item.id}`), `select-${item.id}`, 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; } toggleTable(on) { if (this.ui.table.selectedColumns) { this.ui.table.selectedColumns.hidden = !on; } if (on && !this.ui.table.table) { let table = window.getTemplate('contentTable'); this.container.append(table); this.ui.table.table = this.container.querySelector('form.table'); 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; 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); } } if (this.ui.table.selectedColumns) { this.ui.table.selectedColumns.hidden = !on; } } toggleGrid() { window.removeChildren(this.ui.grid); } renderTable(items) { this.toggleTable(true); this.toggleGrid(); items.forEach(item => { let row = (this.isTimeline) ? this.renderTimelineTableItem(item) : this.renderTableItem(item); if (this.ui.table.body) { this.ui.table.body.append(row); } else { 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); } }); window.jvbSelector.scanExistingFields(); } renderTableItem(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 status = row.querySelector(`input[name="post_status"][value="${item.status}"]`); if (status) { status.checked = true; } if (Object.hasOwn(this.ui.table.table.dataset, 'edit')) { new window.jvbPopulate(row, item); } else { for (let [key, value] of Object.entries(item)) { let col = row.querySelector(`[data-field="${key}"]`); if (col) { let p = col.querySelector('p'); if (col.dataset.fieldType === 'date') { value = window.formatTimeAgo(value); } p.textContent = value; } } } // Clean up after population this.cleanupTableRow(row); 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 let sharedRow = row.querySelector('tr.shared'); new window.jvbPopulate(sharedRow, item); this.prefixTimelineFieldNames(sharedRow, item.id); this.cleanupTableRow(sharedRow); // Handle timeline points 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: Create item-like structure for timeline point const timelineItem = { fields: timeline, images: item.images, taxonomies: {} // Timeline points don't have taxonomies }; new window.jvbPopulate(point, timelineItem); 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; } }); } 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.toggleGrid(); this.toggleTable(false); this.ui.grid.classList.remove('grid-view'); this.ui.grid.classList.add('list-view'); items.forEach(item => { 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; if (item._pending) row.classList.add('pending'); let select = row.querySelector('.select-item'); let label = row.querySelector('.select-item + label'); [ select.id, select.value, select.checked, label.htmlFor ] = [ item.id, item.id, this.selectedItems.has(`${item.id}`), item.id, ]; row.querySelectorAll('[data-attr]').forEach(attr => { if (item[attr.dataset['attr']] !== ''){ attr.textContent = item[attr.dataset['attr']]; } else { attr.remove(); } }); row.querySelectorAll('[data-field]').forEach(field => { let value = item.fields[field.dataset['field']]; if (value !== '') { if (field.tagName === 'DIV') { field.innerHTML = value; } else { field.textContent = value; } } else { field.remove(); } }); let img = row.querySelector('img'); if (img) { [ img.src, img.alt ] = [ item.images[item.fields.post_thumbnail]?.medium??'', 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); } else { this.selectedItems.add(id); } this.updateSelectionUI(); } selectAll(check) { const items = this.container.querySelectorAll('.item'); if (!check) { this.selectedItems.clear(); this.ui.bulk.selectAll.checked = false; this.ui.bulk.select.value = ''; } items.forEach(item => { if (check) { this.selectedItems.add(item.dataset.id) } item.querySelector('.select-item').checked = check; }); this.updateSelectionUI(); } clearSelection() { this.selectAll(false); this.ui.bulk.select.value = ''; } updateSelectionUI() { const count = this.selectedItems.size; if (this.ui.bulk.control) { this.ui.bulk.control.hidden = count === 0; } if (this.ui.bulk.count) { let item = count === 1 ? 'item' : 'items'; this.ui.bulk.count.hidden = count === 0; 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;