/**
|
* 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;
|