From 474109a5df0a06f5343ab184838fe2d80e3872a8 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Sun, 11 Jan 2026 19:23:20 +0000
Subject: [PATCH] =Fixed timeline CRUD.js issue where this.activeItem was set null when we still needed it

---
 assets/js/concise/CRUD.js | 1890 +++++++++++++++++++++++++++++++++++++++++-----------------
 1 files changed, 1,326 insertions(+), 564 deletions(-)

diff --git a/assets/js/concise/CRUD.js b/assets/js/concise/CRUD.js
index 082f2a0..5815706 100644
--- a/assets/js/concise/CRUD.js
+++ b/assets/js/concise/CRUD.js
@@ -1,29 +1,181 @@
-/**
- * Main CRUD Manager - Coordinates everything
- */
 class CRUDManager {
-	constructor(config) {
+	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.config = config;
-		this.content = config.content || false;
-		this.settings = window.jvbUserSettings;
 		this.a11y = window.jvbA11y;
-		if (!this.content) {
-			return;
-		}
+		this.error = window.jvbError;
+		this.cache = new window.jvbCache(this.content);
+
+		this.activeItem = null;
 		this.isTimeline = false;
-		this.currentItemID = null;
+		this.items = {
+			list: new Map(),
+			grid: new Map(),
+			table: new Map()
+		}; //DOM references
 
+		this.init();
+	}
+
+	init() {
 		this.initElements();
-		this.updateBulkOptions();
+		this.initListeners();
+		let cached = this.initSettings();
+		this.initStore(cached);
+		this.checkHideFilters();
+		this.initIntegrations();
+		this.initUploader();
+		this.initModals();
+	}
 
-		// Initialize components
+	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: 'content',
+				endpoint: this.endpoint??'content',	//for taxonomy stores
 				headers: {
 					'action_nonce': window.auth.getNonce('dash'),
 				},
@@ -34,52 +186,43 @@
 					{ name: 'modified', keyPath: 'modified'},
 					{ name: 'title', keyPath: 'title'}
 				],
-				filters: {
-					content: this.content,
-					user: window.auth.getUser(),
-					page: 1,
-					status: 'all',
-					orderby: 'modified', //or title
-					order: 'desc'
-				},
-				TTL: 30 * 60 * 1000,	//30 minutes cache
+				filters: filters,
+				ignore: ['content', 'user'],
+				TTL: 60 * 60 * 1000, 		//1 hour cache
 				showLoading: true,
-			});
+			}
+		);
 		this.store = store[this.content];
 
-		this.status = 'all';
-		this.filterTimeout = null;
-
-		this.viewController = new window.jvbViews(this.ui.container, this.store);
-		this.tableForm = null;
-		this.tableChanges = new Map();
-
-
-		this.formController = (this.isTimeline) ? new window.jvbForm({collectFormData: () => this.collectTimelineData.bind(this)}) : new window.jvbForm();
-		this.viewController.subscribe((event, form) => {
-			if (event === 'table-view' && !this.tableForm) {
-				if (!this.tableForm) {
-					this.tableForm = this.formController.registerForm(form, {
-						autosave: false,
-						formStatus: false,
-						isTable: true,
-					});
-				}
-
-			} else if (event === 'not-table-view') {
-				if (this.tableForm) {
-
-				}
-			} else if (event === 'order-changed') {
-				let data = this.store.get(form);
-				if (!data) {
-					return;
-				}
-				let changes = {};
-				changes[form] = data;
-				this.savePosts(changes, `Updating progression order`);
+		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) {
@@ -91,405 +234,740 @@
 		});
 
 		this.queue.subscribe((event, data) => {
-			if (!Object.hasOwn(data, 'endpoint') || !['content', 'uploads/groups'].includes(data.endpoint)) return;
-			if (event === 'operation-completed') {
-				this.handleQueueSuccess(event, data);
-			} else if (event === 'operation-failed-permanent') {
-				this.handleQueueFailure(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;
 
-		// Track initialization
-		this.initialized = false;
+		// Save to store and server
+		this.store.save(item);
+		this.savePosts(
+			{ [itemID]: { timeline: reorderedTimeline, content: this.content } },
+			'Reordering timeline...',
+			true
+		);
 
-		this.init();
+		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.currentItemID] = changes;
-			this.savePosts(theChanges, title);
+			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 itemsToRemove = [];
-		console.log(data);
-		switch (true) {
-			case data.config.element === this.ui.forms.edit:
-				theChanges[this.currentItemID] = changes;
-				title = `Saving ${title} Changes`;
-				// Check if status change requires removal
-				if (changes.post_status && this.shouldRemoveItem(changes.post_status)) {
-					itemsToRemove.push(this.currentItemID);
+		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);
 				}
-				break;
-			case data.config.element === this.ui.forms.bulkEdit:
-				let selected = data.config.element.querySelectorAll('.selected input:checked');
-				selected.forEach(sel => {
-					theChanges[sel.value] = changes;
-					// Check if status change requires removal
-					if (changes.post_status && this.shouldRemoveItem(changes.post_status)) {
-						itemsToRemove.push(sel.value);
-					}
-				});
-
-				title = `Updating ${selected.length} ${this.config.plural??'posts'} Changes`;
-				break;
-			case data.config.element === this.ui.forms.create:
-				if (event === 'form-submit') {
-					theChanges[data.config.data['form-id']] = changes;
-					title = `Saving ${title} Changes`;
-				}
-				break;
-		}
-
-		// Handle visual removal with stagger effect
-		if (itemsToRemove.length > 0) {
-			let delay = 0;
-			itemsToRemove.forEach(itemId => {
-				setTimeout(() => {
-					const element = document.querySelector(`.item[data-id="${itemId}"]`);
-					if (element) {
-						window.fade(element, false);
-					}
-				}, delay);
-				delay += 50; // Stagger by 50ms
+				num++;
 			});
-
-			// Clear selection after bulk edit with staggered removal
-			if (data.config.element === this.ui.forms.bulkEdit) {
-				setTimeout(() => {
-					this.viewController.clearSelection();
-				}, delay + 100);
+			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);
+		this.savePosts(theChanges, title, event === 'form-submit');
 	}
+	savePosts(changes, title, delay = false) {
+		if (Object.keys(changes).length === 0) return;
 
-	shouldRemoveItem(newStatus) {
-		return (this.status === 'all' && !['publish', 'draft'].includes(newStatus)) ||
-			(newStatus !== this.status);
-	}
-
-	savePosts(changes, title) {
-		if (Object.keys(changes).length === 0) {
-			return;
-		}
-
-		//ensure content is in each post
-		for (let postId in changes) {
-			if (!changes[postId]['content']) {
-				changes[postId]['content'] = this.content;
+		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: 'content',
+			endpoint: this.endpoint,
 			headers: {
 				'action_nonce': window.auth.getNonce('dash'),
 			},
 			data: {
-				posts: changes,
+				posts: validChanges,
 			},
+			delay: delay,
 			popup: `Saving changes`,
 			title: title
 		};
-
 		this.queue.addToQueue(operation);
-
-	}
-	async handleQueueSuccess(event, data) {
-		this.store.clearCache();
-		this.store.fetch();
-	}
-	handleQueueFailure(event, data) {
-		console.error('Operation failed permanently:', data);
-		// Optionally show error notification to user
-		this.a11y?.announce(`Operation failed: ${data.error_message || 'Unknown error'}`);
 	}
 
-	initElements() {
-		this.elements = {
-			modals: {
-				create: 'dialog.create',
-				edit: 'dialog.edit',
-				bulkEdit: 'dialog.bulkEdit'
-			},
-			container: '.crud[data-content]',
-			grid: '.item-grid',
-			bulkSelectActions: '.bulk-action-select',
-			forms: {
-				create: 'dialog.create form',
-				edit: 'dialog.edit form',
-				bulkEdit: 'dialog.bulkEdit form'
-			},
-			uploader: 'details.uploader'
-		};
-		this.ui = window.uiFromSelectors(this.elements);
-		if (this.ui.uploader) {
-			window.jvbUploads.scanFields(document.querySelector(this.elements.uploader));
-
-			window.jvbUploads.subscribe((event, data) => {
-				if (event === 'sent-to-queue') {
-					console.log(data);
-					if (data === this.ui.uploader.querySelector('[data-uploader]')?.dataset.uploader) {
-						window.debouncer.schedule('crud-complete', ()=> {
-							this.store.clearHttpHeaders();
-						});
-					}
-				}
-			});
-		}
-		this.isTimeline = !!document.querySelector('[data-timeline]');
-	}
-	init() {
-		if (this.ui.uploader){
-			this.settings.addSetting(this.ui.uploader, 'open');
-			this.ui.uploader.addEventListener('toggle', (e) =>{
-				this.settings.saveSetting('open', this.ui.uploader.open ? 'on' : 'off');
-			});
-		}
-
-		// Set up filter controls
-		this.filterHandler = this.handleFilterChange.bind(this);
-		this.changeHandler = this.handleChange.bind(this);
-
-
-
-		this.modals = {};
-		for (let [name, modal] of Object.entries(this.ui.modals)) {
-			this.modals[name] = new window.jvbModal(modal);
-
-			this.modals[name].subscribe((event, data) => {
-				switch (event) {
-					case 'modal-close':
-						this.currentItemID = null;
-						this.formController.cleanupForm(this.modals[name].modal.querySelector('form').dataset.formId);
-						//double check we have finished saving
-						break;
-					case 'modal-open':
-						//probably not needed in this class
-						break;
-				}
-			});
-		}
-
-		// Set up global event delegation
-		this.setupEventDelegation();
-
-		this.setupFilters();
-
-		this.initialized = true;
-	}
-
-	setupEventDelegation() {
-		document.addEventListener('change', this.changeHandler);
-		// Single event listener for all CRUD actions
-		document.addEventListener('click', (e) => {
-			// Check for action buttons
-			const actionBtn = e.target.closest('[data-action]');
-			if (actionBtn) {
-				e.preventDefault();
-				const action = actionBtn.dataset.action;
-				const id = actionBtn.dataset.id;
-
-				switch(action) {
-					case 'edit':
-						this.populateEditForm(id);
-						this.modals.edit.handleOpen();
-						break;
-
-					case 'delete':
-						if (confirm('Delete this item?')) {
-							let changes = {};
-							changes[actionBtn.dataset.id] = {
-								'post_status': 'delete',
-								'content': this.content
-							};
-							window.fade(actionBtn.closest('.item'), false);
-							this.savePosts(changes, `Sending ${this.singular} to trash...`);
-							this.store.delete(id);
-						}
-						break;
-					case 'trash':
-						let changes = {};
-						changes[actionBtn.dataset.id] = {
-							'post_status': 'trash',
-							'content': this.content
-						};
-						window.fade(actionBtn.closest('.item'), false);
-						this.savePosts(changes, `Sending ${this.singular} to trash...`);
-						break;
-
-					case 'create':
-						this.modals.create.dataset.itemId = 'new';
-						this.modals.create.dataset.content = this.content;
-						this.modals.create.handleOpen();
-						break;
-
-					case 'bulk-edit':
-						const selected = Array.from(this.viewController.selectedItems);
-						if (selected.length > 0) {
-
-							this.modals.bulkEdit.handleOpen();
-						}
-						break;
-
-					case 'bulk-delete':
-						const toDelete = Array.from(this.viewController.selectedItems);
-						if (toDelete.length > 0 && confirm(`Delete ${toDelete.length} items?`)) {
-							toDelete.forEach(id => this.store.delete(id));
-							this.viewController.clearSelection();
-						}
-						break;
-
-					case 'sync':
-						// this.store.syncQueue();
-						break;
-
-					case 'refresh':
-						this.store.fetch();
-						break;
-				}
-			}
-
-			let createButton = e.target.closest('.create-item');
-			if (createButton) {
-				this.formController.registerForm(this.ui.forms.create);
-				this.modals.create.handleOpen();
-			}
-
-			let clearSelection = e.target.closest('.cancel-bulk');
-			if (clearSelection) {
-				this.viewController.selectAll(false);
-			}
-		});
-
-		// Keyboard shortcuts
-		document.addEventListener('keydown', (e) => {
-			// Ctrl/Cmd + A to select all
-			if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
-				if (this.ui.container && this.ui.container.contains(document.activeElement)) {
-					e.preventDefault();
-					this.viewController.selectAll();
-				}
-			}
-
-			// ESC to clear selection
-			if (e.key === 'Escape' && this.viewController?.selectedItems.size > 0 && window.jvbModal.getAllModals().length === 0) {
-				this.viewController.clearSelection();
-			}
-		});
-	}
-	handleChange(e) {
-		if (e.target.closest('[data-id]')) {
-			if (this.isTimeline) {
-				this.handleTimelineTableChange(e);
-			} else {
-				this.handleTableChange(e);
-			}
-			return;
-		}
-		if (e.target.classList.contains('bulk-action-select')) {
-			if (e.target.value.startsWith('tax-')) {
-				const taxonomy = e.target.value.replace('tax-', '');
-				this.openTaxonomyModal(taxonomy);
-				e.target.value = '';
-				return;
-			}
-
-			switch (e.target.value) {
-				case 'edit':
-					this.populateBulkEdit();
-					this.modals.bulkEdit.handleOpen();
-					break;
-				case 'publish':
-					this.setBulkStatus('publish');
-					break;
-				case 'draft':
-					this.setBulkStatus('draft');
-					break;
-				case 'trash':
-					this.setBulkStatus('trash');
-					break;
-				case 'restore':
-					this.setBulkStatus('draft');
-					break;
-				case 'delete':
-					this.setBulkStatus('delete');
-					break;
-			}
-		}
-		if (window.targetCheck(e, 'select[data-filter]')) {
-			this.handleFilterChange(e);
-		}
-	}
 	handleTableChange(e) {
-		const row = e.target.closest('tr[data-id]');
-		if (!row) return;
+		const container = this.isTimeline
+			? e.target.closest('tbody[data-id]')
+			: e.target.closest('tr[data-id]');
+		if (!container) return;
 
 		const input = e.target;
-		const postID = parseInt(row.dataset.id);
-		const fieldName = input.closest(['data-field'])?.dataset.field;
-		if (!fieldName) return;
+		const field = input.closest('[data-field]')?.dataset.field;
+		if (!field) return;
 
-		const item = this.store.get(postID);
-		if (!item) return;
-
-		item.fields[fieldName] = this.getInputValue(input);
-
-		this.store.save(item);
-
-		let post = {};
-		post[postID] = item.fields;
-		this.savePosts(post, `Saving changes to ${this.content}`);
-	}
-	handleTimelineTableChange(e) {
-		const tbody = e.target.closest('tbody[data-id]');
-		if (!tbody) return;
-
-		const input = e.target;
-		const fieldName = input.closest('[data-field]')?.dataset.field;
-
-		if (!fieldName) return;
-
-		const parentID = parseInt(tbody.dataset.id);
-		const timelinePoint = input.closest('tr.timeline-point');
-
-		const item = this.store.get(parentID);
+		const itemID = parseInt(container.dataset.id);
+		const item = this.store.get(itemID);
 		if (!item) return;
 
 		const value = this.getInputValue(input);
 
-		// Check if this is a specific point, or a shared value
-		if (timelinePoint) {
-			const imgID = timelinePoint.dataset.imageId;
-			if (!item.fields.timeline) {
-				item.fields.timeline = {};
+		// 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;
 			}
-			if (!item.fields.timeline[imgID]) {
-				item.fields.timeline[imgID] = {};
-			}
-			item.fields.timeline[imgID][fieldName] = value;
 		} else {
-			item.fields[fieldName] = value;
+			item.fields[field] = value;
 		}
 
-		//Update store directly
 		this.store.save(item);
-
-		let changes = {};
-		changes[parentID] = item.fields;
-		this.savePosts(changes, 'Updating progress post');
+		this.savePosts({ [itemID]: item.fields }, `Saving changes...`, true);
 	}
+
 	getInputValue(input) {
 		if (input.type === 'checkbox') {
 			return input.checked ? (input.value || '1') : '';
@@ -499,54 +977,16 @@
 		}
 		return input.value;
 	}
-
-	openTaxonomyModal(taxonomy) {
-		// Check if jvbSelector exists
-		if (!window.jvbSelector) {
-			console.error('TaxonomySelector not initialized');
-			return;
-		}
-
-		// Open the selector in filter mode
-		window.jvbSelector.openForFilter(
-			taxonomy,
-			(selectedIds, taxonomy) => this.handleBulkTaxonomy(selectedIds, taxonomy)
-		);
-	}
-	handleBulkTaxonomy(selectedIds, taxonomy) {
-		// Callback when terms are selected
-		if (selectedIds.length > 0) {
-			selectedIds = selectedIds.join(',');
-			let changes = {};
-			let selected = Array.from(this.viewController.selectedItems);
-
-
-			selected.forEach(sel => {
-				changes[sel] = {
-					content: this.content
-				};
-				changes[sel][taxonomy] = selectedIds;
-			});
-
-
-			let title = `Adding ${selected.length} ${this.config.plural??'posts'} to ${selectedIds.length} ${jvbSettings.labels[taxonomy].plural}`;
-			this.viewController.clearSelection();
-			this.savePosts(changes, title);
-		}
-	}
-
 	setBulkStatus(status) {
-		if (!['publish', 'draft', 'trash', 'delete'].includes(status)){
-			return;
-		}
+		if (!['publish', 'draft', 'trash', 'delete'].includes(status)) return;
 
 		let changes = {};
-		for (let selected of this.viewController.selectedItems) {
-			changes[selected] = {
+		this.selected.forEach(itemID => {
+			changes[itemID] = {
 				post_status: status,
 				content: this.content
 			};
-		}
+		});
 		let title;
 		switch (status) {
 			case 'delete':
@@ -555,150 +995,472 @@
 			default:
 				title = window.uppercaseFirst(status)+'ing';
 		}
-
-		if ((this.status === 'all' && !['publish', 'draft'].includes(status)) || status !== this.status) {
-			let delay = 0;
-			for (let selected of this.viewController.selectedItems) {
-				setTimeout(() => {
-					const element = document.querySelector(`.item[data-id="${selected}"]`);
-					if (element) {
-						window.fade(element, false);
-					}
-				}, delay);
-				delay += 50; // Increment delay for staggered effect
-			}
+		if (this.shouldRemoveItemUI(status)) {
+			this.removeItems(Object.keys(changes));
 		}
-		// Clear selection even if items aren't being removed
-		this.viewController.clearSelection();
-
-
+		this.selectionHandler.clearSelection();
 		if (Object.keys(changes).length !== 0) {
-			this.savePosts(changes, `${title} ${this.viewController.selectedItems.size} ${this.plural}...`);
+			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();
 		}
 	}
 
-	handleFilterChange(e) {
-		let target = e.target;
-		let filter = target.dataset.filter;
-		if (filter === 'taxonomies') {
-			let taxonomy = target.dataset.taxonomy;
-			this.store.setFilter(`tax_${taxonomy}`, target.value);
-		} else {
-			this[target.dataset.filter] = target.value;
-			this.store.setFilter(target.dataset.filter, target.value);
-			if (target.dataset.filter === 'status') {
-				this.updateBulkOptions(target.value);
-			}
+	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);
 		}
 	}
-	updateBulkOptions(status = 'all') {
-		if (status === 'trash') {
-			if (this.ui.bulkSelectActions?.querySelector('[value="edit"]')) {
-				window.removeChildren(this.ui.bulkSelectActions);
-				let options = window.getTemplate('trashOptions');
-				options.querySelectorAll('option').forEach((option, index) => {
-					if (index === 0) {
-						option.checked = true;
+
+	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'] || '');
 					}
-					this.ui.bulkSelectActions.append(option);
+
+					row.insertBefore(point, pointTemplate);
 				});
+				pointTemplate.remove();
 			}
 		} else {
-			if (this.ui.bulkSelectActions && !this.ui.bulkSelectActions.querySelector('[value="edit"]')) {
-				window.removeChildren(this.ui.bulkSelectActions);
-
-				let options = window.getTemplate('notTrashOptions');
-				options.querySelectorAll('option').forEach((option, index) => {
-					this.ui.bulkSelectActions.append(option);
-				});
+			// Standard table row
+			if (this.ui.table.form?.dataset.edit !== undefined) {
+				new window.jvbPopulate(row, item);
+			} else {
+				this.populateTableReadOnly(row, item);
 			}
+			this.cleanupTableRow(row);
 		}
-		if (this.ui.bulkSelectActions) {
-			this.ui.bulkSelectActions.value = '';
+
+		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;
+			}
 		}
 	}
 
-	populateBulkEdit() {
-		const container = this.modals.bulkEdit.modal.querySelector('form .selected');
-		if (!container) return;
-
-		window.removeChildren(container);
-		for (let selected of this.viewController.selectedItems) {
-			let item = this.store.get(selected);
-
-			const img = window.getTemplate('bulkItem');
-			if (!img) return;
-
-			const checkbox = img.querySelector('input[type=checkbox]');
-			const image = img.querySelector('img');
-
-			if (checkbox) {
-				checkbox.id = `bulk_${item.id}`;
-				checkbox.value = item.id;
-				checkbox.checked = true;
-			}
-
-			if (image && item.thumbnail) {
-				image.src = item.thumbnail;
-				image.alt = item.alt || '';
-			}
-
-			container.append(img);
-		}
-		let modal = this.modals.bulkEdit.modal;
-		[
-			modal.querySelector('h2 span').textContent
-		] = [
-			this.viewController.selectedItems.size
-		];
-
-		this.formController.registerForm(this.ui.forms.bulkEdit);
-	}
-
-	populateEditForm(itemID) {
-		this.currentItemID = itemID;
-
-		let item = this.store.get(parseInt(itemID));
-		if (item) {
-			this.ui.modals.edit.dataset.itemId = itemID;
-			this.ui.modals.edit.dataset.content = this.content;
-
-			let form = this.ui.modals.edit.querySelector('form');
-			this.ui.modals.edit.querySelector('h2').textContent = `Editing ${item.fields.post_title}`;
-			form.dataset.formId = `edit-${itemID}`;
-
-			new window.jvbPopulate(form, item);
-
-			this.formController.registerForm(this.ui.forms.edit);
-		}
-	}
-
-	setupFilters() {
-		// Search
-		const searchInput = document.querySelector('.all-filters input[type="search"]');
-		if (searchInput) {
-			let searchTimeout;
-			searchInput.addEventListener('input', () => {
-				if (searchInput.value.length > 3) {
-					clearTimeout(searchTimeout);
-					searchTimeout = setTimeout(() => {
-						this.store.setFilter('search', searchInput.value);
-					}, 300);
-				} else if (searchInput.value.length === 0) {
-					this.store.removeFilter('search');
+	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() {
-		document.querySelectorAll('[data-filter]').forEach(filter => {
-			filter.removeEventListener('change', this.filterHandler);
-		});
+		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();
+		}
 	}
 }
 
-// Initialize when ready
 document.addEventListener('DOMContentLoaded', async function()  {
 	window.auth.subscribe((event) => {
 		if (event === 'auth-loaded') {

--
Gitblit v1.10.0