Jake Vanderwerf
2026-01-04 a81f7043fc44382775f9afac48e4c7a651e7ac6c
assets/js/concise/View.js
@@ -3,21 +3,25 @@
 */
class ViewController {
   constructor(container, store) {
      console.log(container);
      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 = 'grid';
      this.currentView = this.container.dataset.view ?? 'grid';
      this.selectedItems = new Set();
      this.subscribers = new Set();
      this.init();
   }
@@ -26,8 +30,11 @@
      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'
         },
@@ -40,19 +47,23 @@
      }
      this.ui = window.uiFromSelectors(this.selectors, this.container);
      console.log(this.ui);
   }
   init() {
      // Subscribe to store updates
      this.store.subscribe((event, data) => {
         switch(event) {
            case 'data-fetched':
            case 'data-cached':
               this.handleDataUpdate(data);
            case 'items-saved':
               // this.handleDataUpdate(data);
               break;
            case 'items-updated':
               this.handleItemsUpdate(data.items);
            case 'data-loaded':
               this.handleItemsUpdate();
               break;
            case 'item-saved':
               // this.updateItem(data.item);
               break;
            case 'item-deleted':
               // this.deleteItem(data.item);
               break;
         }
      });
@@ -128,52 +139,48 @@
   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);
      // 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();
         });
      });
   }
   /**
    * Handle data updates from store
    */
   handleDataUpdate(data) {
      if (data.data && data.data.items) {
         this.render(data.data.items);
      const checkedView = document.querySelector('[data-view]:checked');
      if (checkedView) {
         this.currentView = checkedView.dataset.view;
      }
   }
   /**
    * 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) {
         console.log('Nothing to show');
         this.renderEmpty();
         return;
      }
      switch(this.currentView) {
         case 'grid':
            this.renderGrid(items);
@@ -189,6 +196,17 @@
      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);
@@ -200,19 +218,15 @@
      const fragment = document.createDocumentFragment();
      items.forEach(item => {
         let card;
         if (this.items.grid.has(item.id)) {
            card = this.items.grid.get(item.id);
         } else {
            card = this.store.renderOrRetrieve(item, 'grid', this.renderGridItem.bind(this));
            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;
@@ -237,8 +251,6 @@
         checkbox.id,
         checkbox.checked,
         label.htmlFor,
         img.src,
         img.alt,
         edit.dataset.id,
         trash.dataset.id
      ] = [
@@ -246,28 +258,64 @@
         `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;
   }
   toggleTable(on) {
      this.ui.table.selectedColumns.hidden = !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.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;
      if (this.ui.table.selectedColumns) {
         this.ui.table.selectedColumns.hidden = !on;
      }
   }
   toggleGrid() {
@@ -279,22 +327,29 @@
      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;
@@ -303,96 +358,166 @@
         row.querySelector('.select-item').value,
         row.querySelector('.select-item').checked,
         row.querySelector('.select-item + label').htmlFor,
         row.querySelector(`input[name="post_status"][value="${item.status}"]`).checked
      ] = [
         item.id,
         item.id,
         this.selectedItems.has(`${item.id}`),
         item.id,
         item.status
      ];
      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.fields, item.images);
      } 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;
      row.querySelectorAll('td[data-field]').forEach(field => {
         let value = item.fields[field.dataset.field];
         // field.querySelectorAll('label').forEach(label => {
         //    label.hidden = true;
         // });
      // 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);
         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').hidden = true;
               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;
      // 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) {
@@ -403,19 +528,15 @@
      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;
@@ -464,9 +585,158 @@
            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);
@@ -511,6 +781,19 @@
         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;