From 9f86429a1252b45c95b7c62fbaa1b82de3723997 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Mon, 05 Jan 2026 18:16:07 +0000
Subject: [PATCH] =Complete TaxonomySelector.js and TaxonomyCreator.js refactor
---
inc/forms/TaxonomySelector.php | 16
assets/js/concise/TaxonomyCreator.js | 370 --
/dev/null | 3141 ---------------------------
assets/js/min/selector.min.js | 2
assets/js/concise/TaxonomySelector.js | 1646 ++++++-------
inc/meta/MetaForm.php | 3
assets/js/concise/TaxonomySelectorOld.js | 1561 +++++++++++++
assets/js/min/creator.min.js | 2
8 files changed, 2,434 insertions(+), 4,307 deletions(-)
diff --git a/assets/js/concise/TaxonomyCreator.js b/assets/js/concise/TaxonomyCreator.js
index 2ea387b..b9adaf7 100644
--- a/assets/js/concise/TaxonomyCreator.js
+++ b/assets/js/concise/TaxonomyCreator.js
@@ -1,25 +1,32 @@
/**
* TaxonomyCreator - Handles term creation for TaxonomySelector
- * Simplified to focus only on creation logic
*/
class TaxonomyCreator {
constructor(selector) {
this.selector = selector;
+ this.queue = window.jvbQueue;
- // Only initialize modal elements if modal exists
- if (selector.modal) {
- this.createNew = selector.modal.querySelector('.create-new-term');
- this.toggle = selector.modal.querySelector('.new-term-toggle');
- this.form = this.createNew?.querySelector('.create-new-term-section');
- }
-
+ this.initElements();
this.initListeners();
+ }
- // Only init term creation UI if we have modal elements
- if (this.form) {
- this.initTermCreation();
- }
+ initElements() {
+ this.selectors = {
+ details: 'details.create-new',
+ parent: '#select_parent',
+ summary: '.create-new summary',
+ suggestion: '.term-suggestions',
+ name: '#term_name',
+ button: '.submit-term',
+ form: 'form.create-term',
+ label: {
+ name: '[for="term_name"]',
+ parent: '[for="select_parent"]'
+ },
+ loading: '.loading-message.create-term'
+ };
+ this.ui = window.uiFromSelectors(this.selectors, this.selector.container);
}
/**
@@ -28,6 +35,13 @@
initListeners() {
this.clickHandler = this.handleClick.bind(this);
document.addEventListener('click', this.clickHandler);
+
+ if (this.ui.form) {
+ this.ui.form.addEventListener('change', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ })
+ }
}
/**
@@ -35,55 +49,58 @@
*/
handleClick(e) {
// Handle opening create term form
- if (window.targetCheck(e, '.create-new-term summary')) {
- if (this.createNew.open) {
- this.createNew.querySelector('input[name="term_name"]').focus();
+ if (window.targetCheck(e, this.selectors.summary)) {
+ if (this.ui.details.open) {
+ this.ui.name?.focus();
}
this.resetParentOptions();
}
// Handle term creation submission
- if (window.targetCheck(e, '.submit-term')) {
+ if (window.targetCheck(e, this.selectors.button)) {
this.handleTermCreation(e).then(() => {});
}
-
- // Handle autocomplete create button
- if (window.targetCheck(e, '.create-term')) {
- this.handleAutocompleteCreate(e).then(() => {});
- }
}
/**
* Handle term creation from modal form
*/
async handleTermCreation(e) {
- const taxonomy = this.selector.currentConfig?.taxonomy;
+ let field = this.selector.currentField();
+ const taxonomy = field.taxonomy;
if (!taxonomy) return;
- const termName = this.form.querySelector('input[name="term_name"]').value.trim();
- const parentId = parseInt(this.form.querySelector('input#select_parent')?.value) || 0;
-
- if (!termName) return;
-
- const submitButton = this.form.querySelector('button');
+ let data = {
+ parent: 0,
+ taxonomy: taxonomy
+ };
+ // If it's open, we can get the parent element and everything
+ if (this.selector.container.open) {
+ data.name = this.ui.name.value.trim();
+ data.parent = parseInt(this.ui.parent?.value??0);
+ } else if (this.selector.activeField) {
+ //It's autocomplete, so just the term name
+ data.name = this.selector.store.query;
+ }
+ if (!data.name || data.name.length < 2) return;
try {
- if (submitButton) {
- submitButton.disabled = true;
+ if (this.ui.button) {
+ this.ui.button.disabled = true;
}
- const response = await this.createTerm(termName, parentId, taxonomy);
+ const response = await this.createTerm(data);
if (response.success && response.term) {
- await this.handleSuccessfulCreation(response.term, taxonomy, parentId);
+ await this.handleSuccessfulCreation(response.term, data);
this.clearForm();
}
} catch (error) {
console.error('Error creating term:', error);
this.selector.handleError(error, 'handleTermCreation');
} finally {
- if (submitButton) {
- submitButton.disabled = false;
+ if (this.ui.button) {
+ this.ui.button.disabled = false;
}
}
}
@@ -91,11 +108,10 @@
/**
* Handle successful term creation
*/
- async handleSuccessfulCreation(term, taxonomy, parentId) {
+ async handleSuccessfulCreation(term, data) {
const termPath = term.path || term.name;
-
// Close create form
- this.createNew.open = false;
+ this.ui.details.open = false;
// Clear cache to ensure fresh data
await this.selector.store.clearCache();
@@ -105,161 +121,65 @@
id: term.id,
name: term.name,
path: termPath,
- taxonomy: taxonomy,
- parent: parentId,
- count: 0,
- hasChildren: false,
- slug: term.slug || termName.toLowerCase().replace(/\s+/g, '-')
- });
-
- // Add to modal selection
- this.selector.addSelectedTermToModal(term.id, term.name, termPath);
-
- // Refresh current view if we're viewing the same parent
- const currentParent = this.selector.store.filters.parent || 0;
- if (currentParent === parentId) {
- await this.selector.store.setFilters({
- taxonomy,
- parent: parentId,
- page: 1,
- search: ''
- });
- }
- }
-
- /**
- * Handle autocomplete create button
- */
- async handleAutocompleteCreate(e) {
- const button = e.target.closest('.create-term');
- const fieldId = this.selector.getFieldId(button);
- const field = this.selector.fields.get(fieldId);
-
- if (!field) return;
-
- const input = field.container.querySelector('input[data-autocomplete]');
- const termName = input?.value.trim() || button.dataset.query;
-
- if (!termName) return;
-
- const originalHTML = button.innerHTML;
-
- try {
- button.disabled = true;
- button.textContent = 'Creating...';
-
- const response = await this.createTerm(termName, 0, field.taxonomy);
-
- if (response.success && response.term) {
- await this.handleAutocompleteSuccess(response.term, field, input);
- } else if (response.reason === 'exists' && response.term) {
- this.handleExistingTerm(response.term, field, input);
- }
- } catch (error) {
- console.error('Error creating term:', error);
- this.selector.handleError(error, 'handleAutocompleteCreate');
- } finally {
- button.innerHTML = originalHTML;
- button.disabled = false;
- }
- }
-
- /**
- * Handle successful autocomplete creation
- */
- async handleAutocompleteSuccess(term, field, input) {
- const termPath = term.path || term.name;
-
- // Add to field
- field.selectedTerms.add(parseInt(term.id));
-
- // Add to DataStore
- this.selector.store.data.set(term.id, {
- id: term.id,
- name: term.name,
- path: termPath,
- taxonomy: field.taxonomy,
- parent: 0,
+ taxonomy: data.taxonomy,
+ parent: data.parent,
count: 0,
hasChildren: false,
slug: term.slug || term.name.toLowerCase().replace(/\s+/g, '-')
});
- // Update display
- this.selector.addTermDisplay(term.id, term.name, termPath, 'field', field.id);
+ // Add to modal selection
+ this.selector.addSelected(term.id,this.selector.activeField);
- // Update input and trigger change
- field.input.value = Array.from(field.selectedTerms).join(',');
- field.input.dispatchEvent(new Event('change', { bubbles: true }));
-
- // Clear and hide dropdown
- field.autocompleteDropdown.hidden = true;
- if (input) input.value = '';
-
- // Clear cache for this taxonomy
- await this.selector.store.clearCache();
- }
-
- /**
- * Handle selecting existing term from autocomplete
- */
- handleExistingTerm(term, field, input) {
- field.selectedTerms.add(parseInt(term.id));
- this.selector.addTermDisplay(term.id, term.name, term.path || term.name, 'field', field.id);
-
- field.input.value = Array.from(field.selectedTerms).join(',');
- field.input.dispatchEvent(new Event('change', { bubbles: true }));
-
- field.autocompleteDropdown.hidden = true;
- if (input) input.value = '';
- }
-
- /**
- * Initialize term creation form
- */
- initTermCreation() {
- if (!this.form) return;
-
- this.form.addEventListener('change', (e) => {
- e.preventDefault();
- e.stopPropagation();
- });
+ // Refresh current view if we're viewing the same parent
+ if (this.selector.container.open) {
+ const currentParent = this.selector.store.filters.parent || 0;
+ if (currentParent === data.parent) {
+ await this.selector.store.setFilters({
+ taxonomy: data.taxonomy,
+ parent: data.parent,
+ page: 1,
+ search: ''
+ });
+ }
+ }
}
/**
* Reset parent options in create form
*/
resetParentOptions() {
- const taxonomy = this.selector.currentConfig?.taxonomy;
+ const field = this.selector.currentField();
+ if (!field) return;
+ const taxonomy = field.taxonomy;
if (!taxonomy) return;
- let select = this.createNew.querySelector('#select_parent');
- if (!select) return;
+ if (!this.ui.parent) return;
- let defaultOption = select.querySelector('option');
+ let defaultOption = this.ui.parent.querySelector('option');
if (!defaultOption) return;
// Clear existing options
- window.removeChildren(select);
- select.append(defaultOption.cloneNode(true));
+ window.removeChildren(this.ui.parent);
+ this.ui.parent.append(defaultOption.cloneNode(true));
// Get current parent from store filters
const currentParent = this.selector.store.filters.parent || 0;
// If we're in a sub-category, add the current parent as an option
if (currentParent !== 0) {
- const parentTerm = this.selector.store.data.get(currentParent);
+ const parentTerm = this.selector.store.get(currentParent);
if (parentTerm) {
let parentOption = defaultOption.cloneNode(true);
parentOption.value = parentTerm.id;
parentOption.textContent = parentTerm.name;
- select.append(parentOption);
+ this.ui.parent.append(parentOption);
}
}
// Add all terms currently visible in the taxonomy
const visibleTerms = [];
- this.selector.store.data.forEach(term => {
+ this.selector.store.getFiltered().forEach(term => {
if (term.taxonomy === taxonomy && term.parent === currentParent) {
visibleTerms.push(term);
}
@@ -274,53 +194,24 @@
option.id = `select-parent-${term.id}`;
option.value = term.id;
option.textContent = ' — ' + term.name;
- select.append(option);
+ this.ui.parent.append(option);
});
}
/**
* Create a new term
*/
- async createTerm(name, parent = 0, taxonomy) {
+ async createTerm(data) {
+ if (!data.name || data.parent === undefined || !data.taxonomy) return;
try {
- // Search to check for duplicates
- await this.selector.store.setFilters({
- taxonomy: taxonomy,
- search: name,
- page: 1,
- parent: 0
- });
- // Wait for data to load
- await new Promise(resolve => setTimeout(resolve, 100));
-
- // Check if exact match exists
- const exactMatch = Array.from(this.selector.store.data.values())
- .find(term =>
- term.taxonomy === taxonomy &&
- term.name.toLowerCase() === name.toLowerCase()
- );
-
- if (exactMatch) {
- // For modal context, show suggestions
- if (this.createNew) {
- this.showTermSuggestions([exactMatch], true);
- }
- return { success: false, reason: 'exists', term: exactMatch };
- }
-
- // Term doesn't exist, create it
const response = await fetch(`${jvbSettings.api}terms`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': window.auth.getNonce()
},
- body: JSON.stringify({
- taxonomy: taxonomy,
- name: name,
- parent: parent
- })
+ body: JSON.stringify(data)
});
if (!response.ok) {
@@ -336,91 +227,11 @@
}
/**
- * Show term suggestions when similar terms exist
- */
- showTermSuggestions(suggestions, isExact = false) {
- const suggestionContainer = this.createNew.querySelector('.term-suggestions') ||
- this.createSuggestionContainer();
-
- // Clear existing suggestions
- window.removeChildren(suggestionContainer);
-
- // Add heading
- const heading = document.createElement('h4');
- heading.textContent = isExact ?
- 'This term already exists:' :
- 'Similar terms already exist:';
- suggestionContainer.appendChild(heading);
-
- // Create list of suggestions
- const list = document.createElement('ul');
- list.className = 'term-suggestion-list';
-
- suggestions.forEach(term => {
- const item = document.createElement('li');
- const button = this.createSuggestionButton(term);
- item.appendChild(button);
- list.appendChild(item);
- });
-
- suggestionContainer.appendChild(list);
- suggestionContainer.hidden = false;
- }
-
- /**
- * Create suggestion button
- */
- createSuggestionButton(term) {
- const button = document.createElement('button');
- button.type = 'button';
- button.className = 'use-existing-term';
- button.dataset.id = term.id;
- button.textContent = term.path || term.name;
-
- button.addEventListener('click', () => {
- // Add this term to modal selection
- this.selector.addSelectedTermToModal(term.id, term.name, term.path || term.name);
-
- // Close the create new section
- this.createNew.open = false;
-
- // Clear suggestions and form
- const suggestionContainer = this.createNew.querySelector('.term-suggestions');
- if (suggestionContainer) {
- suggestionContainer.hidden = true;
- }
-
- this.clearForm();
- });
-
- return button;
- }
-
- /**
- * Create container for term suggestions
- */
- createSuggestionContainer() {
- const container = document.createElement('div');
- container.className = 'term-suggestions';
- container.hidden = true;
-
- // Insert after the form
- this.createNew.querySelector('form').after(container);
- return container;
- }
-
- /**
* Clear the creation form
*/
clearForm() {
- const nameInput = this.form.querySelector('input[name="term_name"]');
- if (nameInput) {
- nameInput.value = '';
- }
-
- const suggestionContainer = this.createNew.querySelector('.term-suggestions');
- if (suggestionContainer) {
- suggestionContainer.hidden = true;
+ if (this.ui.name) {
+ this.ui.name.value = '';
}
}
@@ -434,15 +245,8 @@
}
// Clear any pending operations
- const loadingMessage = this.createNew?.querySelector('.loading-message.create-term');
- if (loadingMessage) {
- loadingMessage.hidden = true;
- }
-
- // Clear suggestions
- const suggestionContainer = this.createNew?.querySelector('.term-suggestions');
- if (suggestionContainer) {
- suggestionContainer.hidden = true;
+ if (this.ui.loading) {
+ this.ui.loading.hidden = true;
}
}
}
diff --git a/assets/js/concise/TaxonomySelector.js b/assets/js/concise/TaxonomySelector.js
index b411d11..3cdaf4d 100644
--- a/assets/js/concise/TaxonomySelector.js
+++ b/assets/js/concise/TaxonomySelector.js
@@ -1,501 +1,253 @@
-/**
- * TaxonomySelector - Streamlined version
- * Manages taxonomy selection fields with DataStore integration
- */
class TaxonomySelector {
constructor() {
+ this.container = document.querySelector('dialog#jvb-selector');
+ if (!this.container) return;
+
this.a11y = window.jvbA11y;
this.error = window.jvbError;
- this.index = -1;
- this.isInitializing = true;
- this.taxonomiesToFetch = new Set();
this.subscribers = new Set();
-
- // Register DataStore
- const store = window.jvbStore.register('taxonomies', {
- storeName: 'terms',
- keyPath: 'id',
- showLoading: false,
- indexes: [
- {name: 'taxonomy', keyPath: 'taxonomy'},
- {name: 'parent', keyPath: 'parent'},
- {name: 'slug', keyPath: 'slug', unique: true},
- {name: 'count', keyPath: 'count'},
- ],
- endpoint: 'terms',
- TTL: 2 * 60 * 1000,
- filters: {
- taxonomy: '',
- page: 1,
- search: '',
- parent: 0
- },
- required: 'taxonomy',
- delayFetch: true,
- });
- this.store = store.terms;
-
- // Field management
this.fields = new Map();
- this.selectedTerms = new Map(); // Current modal selection
+ this.selectedTerms = new Map(); // a map of fieldId => Set of selected term Ids
+ this.loadedTaxonomies = new Set(); // a set of taxonomies, to know whether we should preload a newly registered field
+ this.batchFetch = new Set();
- // Modal context
this.activeField = null;
- this.currentConfig = null;
- this.disabled = false;
-
- // Search contexts
- this.searchContexts = new Map();
-
+ this.isInitializing = true;
this.init();
}
init() {
+ this.initStore();
+ this.initElements();
this.initModal();
this.scanExistingFields();
- this.initGlobalListeners();
+ this.initListeners();
- // Initialize creator if needed
if (this.needsCreator() && window.jvbTaxCreator) {
this.creator = new window.jvbTaxCreator(this);
}
+ this.isInitializing = false
+ this.batchFetchTaxonomies().then(()=> {});
+ }
+
+ initStore() {
+ const store = window.jvbStore.register(
+ 'taxonomies',
+ {
+ storeName: 'terms',
+ keyPath: 'id',
+ showLoading: false,
+ indexes: [
+ {name: 'taxonomy', keyPath: 'taxonomy'},
+ {name: 'parent', keyPath: 'parent'},
+ {name: 'slug', keyPath: 'slug', unique: true},
+ {name: 'count', keyPath: 'count'},
+ ],
+ endpoint: 'terms',
+ TTL: 2 * 60 * 1000,
+ filters: {
+ taxonomy: '',
+ page: 1,
+ search: '',
+ parent: 0
+ },
+ required: 'taxonomy',
+ delayFetch: true,
+ }
+ );
+ this.store = store.terms;
this.store.subscribe(this.handleStoreEvent.bind(this));
-
- this.isInitializing = false;
- this.batchFetchTaxonomies();
}
- needsCreator() {
- return Array.from(this.fields.values()).some(field =>
- field.canCreate || field.hasAutocomplete
- );
- }
-
- /***********************************************************************
- * DATASTORE EVENT HANDLING
- ***********************************************************************/
-
- handleStoreEvent(event, data) {
- const handlers = {
- 'data-loaded': () => this.handleDataLoaded(data),
- 'filters-changed': () => this.handleFiltersChanged(data),
- 'fetch-error': () => this.handleFetchError(data.error),
- };
-
- handlers[event]?.();
- }
-
- handleDataLoaded(data) {
- const taxonomy = this.store.filters.taxonomy;
-
- // Update field states for affected taxonomies
- if (taxonomy) {
- const taxonomies = taxonomy.includes(',')
- ? taxonomy.split(',').map(t => t.trim())
- : [taxonomy];
-
- taxonomies.forEach(tax => this.updateFieldsForTaxonomy(tax));
- }
-
- // Initialize displays on first load
- if (this.isInitializing) {
- this.fields.forEach((config, fieldId) => {
- if (config.selectedTerms.size > 0) {
- this.initFieldDisplay(fieldId);
- }
- });
- }
-
- // Render based on context
- this.renderSearchResults(data);
- }
-
- renderSearchResults(data) {
- const context = this.getActiveSearchContext();
-
- if (context === 'modal') {
- this.renderModalResults(data);
- } else if (context === 'autocomplete') {
- this.renderAutocompleteResults(data);
- }
- }
-
- getActiveSearchContext() {
- if (this.modal?.open) return 'modal';
- if (this.activeField && this.searchContexts.has(this.activeField)) {
- return this.searchContexts.get(this.activeField);
- }
- return null;
- }
-
- renderModalResults(data) {
- this.hideLoading();
- const terms = this.store.getFiltered();
- const response = this.store.lastResponse?.page || {};
- const isSearch = data.filters?.search?.length > 0;
- const append = response.page > 1;
-
- this.notify('terms-loaded', { terms, filters: data.filters });
-
- if (terms.length === 0) {
- if (!append) {
- this.showEmptyState(isSearch ? 'No results found.' : 'No items available.');
- }
- this.observer.unobserve(this.ui.sentinel);
- } else {
- this.renderTerms(terms, append, isSearch);
-
- if (response.has_more) {
- this.observer.observe(this.ui.sentinel);
- } else {
- this.observer.unobserve(this.ui.sentinel);
- }
- }
-
- this.a11y?.announce(terms.length, append);
- }
-
- renderAutocompleteResults(data) {
- const field = this.fields.get(this.activeField);
- if (!field?.autocompleteDropdown) return;
-
- const terms = this.store.getFiltered();
- const query = data.filters?.search || '';
-
- this.showAutocompleteResults(field, terms, query);
- this.searchContexts.delete(this.activeField);
- }
-
- handleFiltersChanged(data) {
- if (this.modal?.open) {
- this.showLoading();
- }
- }
-
- handleFetchError(error) {
- this.hideLoading();
-
- const context = this.getActiveSearchContext();
-
- if (context === 'autocomplete') {
- this.showAutocompleteError(this.activeField);
- this.searchContexts.delete(this.activeField);
- } else {
- this.handleError(error, 'fetch');
- }
- }
-
- /***********************************************************************
- * FIELD MANAGEMENT
- ***********************************************************************/
-
- updateFieldsForTaxonomy(taxonomy) {
- this.getFieldsForTaxonomy(taxonomy).forEach(field => {
- this.updateFieldButtonState(field.id);
- });
- }
-
- updateFieldButtonState(fieldId) {
- const field = this.fields.get(fieldId);
- if (!field) return;
-
- const hasTerms = Array.from(this.store.data.values())
- .some(term => term.taxonomy === field.taxonomy);
-
- if (field.toggle) {
- field.toggle.disabled = !hasTerms && !field.canCreate;
- field.toggle.title = !hasTerms
- ? `No ${this.getLabel(field.taxonomy, 'single')} available`
- : `Select ${this.getLabel(field.taxonomy, 'plural')}`;
- }
- }
-
- getFieldsForTaxonomy(taxonomy) {
- return Array.from(this.fields.values())
- .filter(field => field.taxonomy === taxonomy);
- }
-
- scanExistingFields(container = document.body) {
- container.querySelectorAll('.field.taxonomy, .field.post').forEach(selector => {
- try {
- this.registerField(selector);
- } catch (error) {
- this.handleError(error, 'scanExistingFields', selector.dataset.name);
- }
- });
- }
-
- registerField(field) {
- const input = field.querySelector('input[type=hidden]');
- if (!input) return false;
-
- const fieldId = this.createFieldId(field);
- field.dataset.fieldId = fieldId;
-
- const button = field.querySelector('button.taxonomy-toggle');
- const config = {
- id: fieldId,
- input: input,
- container: field,
- taxonomy: button.dataset.taxonomy,
- name: field.dataset.field,
- maxSelection: parseInt(button.dataset.max) || 0,
- canSearch: 'search' in button.dataset,
- hasAutocomplete: 'autocomplete' in button.dataset,
- autocompleteDropdown: field.querySelector('.autocomplete-dropdown') || null,
- canCreate: 'creatable' in button.dataset,
- isRequired: 'required' in button.dataset,
- selectedTerms: new Set(),
- toggle: button,
- selectedContainer: field.querySelector('.selected-items'),
- };
-
- // Parse initial values
- const value = input.value.trim();
- if (value) {
- value.split(',')
- .map(id => parseInt(id.trim()))
- .filter(id => !isNaN(id))
- .forEach(id => config.selectedTerms.add(id));
- }
-
- this.fields.set(fieldId, config);
-
- // Queue for batch fetch
- if (this.isInitializing) {
- this.taxonomiesToFetch.add(config.taxonomy);
- }
-
- // Initialize display
- if (config.selectedTerms.size > 0) {
- this.initFieldDisplay(fieldId);
- }
-
- return fieldId;
- }
-
- createFieldId(field) {
- this.index++;
- return 'selector-' + this.index;
- }
-
- async initFieldDisplay(fieldId) {
- const field = this.fields.get(fieldId);
- if (!field || field.selectedTerms.size === 0) return;
-
- Array.from(field.selectedTerms).forEach(termId => {
- const term = this.store.get(termId);
- if (term) {
- this.addTermDisplay(termId, term.name, term.path, 'field', fieldId);
- }
- });
- }
-
- /***********************************************************************
- * MODAL INITIALIZATION
- ***********************************************************************/
-
- initModal() {
- this.modal = document.querySelector('dialog#jvb-selector');
- if (!this.modal) {
- console.warn('Taxonomy selector modal not found');
- return;
- }
-
- this.initModalElements();
-
- this.modalInstance = new window.jvbModal(this.modal, {
- handleForm: false
- });
-
- this.modalInstance.subscribe((event) => {
- if (event === 'modal-open') this.openModal();
- if (event === 'modal-close') this.closeModal();
- });
- }
-
- initModalElements() {
- const selectors = {
+ /******************************************************************
+ ELEMENTS
+ ******************************************************************/
+ initElements() {
+ this.selectors = {
search: {
input: '[type=search]',
- container: '.search-wrapper'
+ clear: '.clear-search',
+ container: '.search-wrapper',
+ results: '.search-results'
},
- termsList: '.items-container',
- termsWrap: '.items-wrap',
- breadcrumbs: {
+ terms: {
+ list: '.items-container',
+ wrap: '.items-wrap',
+ sentinel: '.scroll-sentinel',
+ },
+ nav: {
nav: 'nav.term-navigation',
back: '.back-to-parent',
+ child: '.toggle-children',
+ pathLevel: '.path-level',
},
loading: {
loading: '.loading',
- text: '.loading span'
+ text: '.loading span',
},
- selectedTerms: '.selected-items',
- sentinel: '.scroll-sentinel',
+ selected: '.selected-items',
modal: {
title: '#modal-title',
+ content: '.modal-content',
+ count: '.selection-count'
},
- create: {
- details: '.create-new-term',
- summary: '.create-new-term summary',
- label: {
- name: '[for=term_name]',
- parent: '[for=select_parent]'
- }
+ favourites: '.favourite-terms',
+ field: {
+ toggle: 'button.taxonomy-toggle',
+ value: 'input[type="hidden"]',
+ selected: '.selected-items',
+ dropdown: '.search-results',
+ search: '[data-autocomplete]',
}
- };
+ }
- this.ui = window.uiFromSelectors(selectors);
+ this.ui = window.uiFromSelectors(this.selectors);
+ }
- // Initialize infinite scroll observer
+ initListeners() {
this.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
- this.loadMoreTerms();
+ this.nextPage();
}
});
}, {
- root: this.ui.termsWrap,
+ root: this.ui.terms.sentinel,
threshold: 0.5
});
- }
- /***********************************************************************
- * GLOBAL EVENT LISTENERS
- ***********************************************************************/
+ this.clickHandler = this.handleClick.bind(this);
+ this.changeHandler = this.handleChange.bind(this);
+ this.inputHandler = this.handleInput.bind(this);
+ this.focusHandler = this.handleFocus.bind(this);
+ this.blurHandler = this.handleBlur.bind(this);
- initGlobalListeners() {
- document.addEventListener('click', this.handleClick.bind(this));
- document.addEventListener('change', this.handleChange.bind(this));
- document.addEventListener('input', this.handleInput.bind(this));
- document.addEventListener('focus', this.handleFocus.bind(this), true);
- document.addEventListener('blur', this.handleBlur.bind(this), true);
+ document.addEventListener('click', this.clickHandler);
+ document.addEventListener('change', this.changeHandler);
+ document.addEventListener('input', this.inputHandler);
+ document.addEventListener('focus', this.focusHandler, true);
+ document.addEventListener('blur', this.blurHandler, true);
}
handleClick(e) {
- // Toggle button
- if (window.targetCheck(e, '.taxonomy-toggle')) {
- e.preventDefault();
- const fieldId = this.getFieldId(e.target);
- const field = this.fields.get(fieldId);
- if (field) this.setActiveField(fieldId, true);
- return;
- }
-
- // Remove selected term
- const removeButton = window.targetCheck(e, 'button.remove-item');
- if (removeButton && e.target.closest('.jvb-selector')) {
- const fieldId = this.getFieldId(removeButton);
- const termId = removeButton.closest('.selected-item').dataset.id;
- this.removeSelectedTerm(fieldId, termId);
- return;
- }
-
- // Modal close
- if (e.target.matches('.modal-close')) {
- this.modalInstance?.handleClose();
- return;
- }
-
- // Modal clicks
- if (this.modal?.contains(e.target)) {
- this.handleModalClick(e);
- }
- }
-
- handleChange(e) {
- // Hidden input changes
- const taxonomyField = window.targetCheck(e, '.taxonomy.field, .post.field');
- if (taxonomyField && e.target.type === 'hidden') {
- const fieldId = this.getFieldId(e.target);
- this.updateFieldFromInput(fieldId);
- return;
- }
-
- // Modal checkboxes
- if (this.modal?.contains(e.target)) {
- this.handleModalChange(e);
- }
- }
-
- handleInput(e) {
- // Modal search
- if (this.modal?.contains(e.target) && e.target.type === 'search') {
- this.performSearch(e.target.value.trim(), 'modal');
- return;
- }
-
- // Autocomplete
- if ('autocomplete' in e.target.dataset) {
- const fieldId = this.getFieldId(e.target);
- const field = this.fields.get(fieldId);
- if (field?.hasAutocomplete) {
- this.performSearch(e.target.value.trim(), 'autocomplete', fieldId);
- }
- }
- }
-
- handleFocus(e) {
- if (!('autocomplete' in e.target.dataset)) return;
-
const fieldId = this.getFieldId(e.target);
const field = this.fields.get(fieldId);
+ if (!fieldId || !field) return;
- if (field?.hasAutocomplete) {
- this.preloadTaxonomy(field.taxonomy);
- }
- }
-
- handleBlur(e) {
- if (!('autocomplete' in e.target.dataset)) return;
-
- setTimeout(() => {
- const fieldId = this.getFieldId(e.target);
- const field = this.fields.get(fieldId);
-
- if (field?.autocompleteDropdown) {
- field.autocompleteDropdown.hidden = true;
+ const autoComplete = window.targetCheck(e, '[data-autocomplete-select]');
+ if (autoComplete) {
+ let termId = parseInt(autoComplete.dataset.id);
+ this.addSelected(termId, fieldId);
+ if (field.ui.dropdown) {
+ field.ui.dropdown.hidden = true;
}
- this.searchContexts.delete(fieldId);
- }, 200);
+ if (field.ui.search) {
+ field.ui.search.value = '';
+ }
+ }
+
+ const toggleButton = window.targetCheck(e, field.ui.toggle);
+ if (toggleButton) {
+ e.preventDefault();
+ this.openModal(fieldId);
+ return;
+ }
+
+ const removeButton = window.targetCheck(e, 'button.remove-item');
+ if (removeButton) {
+ const fieldId = this.getFieldId(removeButton);
+ const termId = removeButton.closest('.selected-item').dataset.id??false;
+ if (fieldId && termId) {
+ this.removeSelected(termId, fieldId);
+ }
+ return;
+ }
+
+ if (e.target.matches('.modal-close')) {
+ this.modal?.handleClose();
+ return;
+ }
+
+ const backToParent = window.targetCheck(e, this.selectors.nav.back);
+ if (backToParent) {
+ this.navigateToParent();
+ return;
+ }
+
+ const toChild = window.targetCheck(e, this.selectors.nav.child);
+ if (toChild) {
+ const termItem = e.target.closest('li');
+ const termId = parseInt(termItem.dataset.id);
+
+ if (termId) {
+ this.navigateTo(termId);
+ }
+ return;
+ }
+
+ const pathLevel = window.targetCheck(e, this.selectors.nav.pathLevel);
+ if (pathLevel) {
+ const termId = parseInt(pathLevel.dataset.id)??0;
+ this.navigateTo(termId);
+ }
+
+ const dropdown = window.targetCheck(e, field.selectors.dropdown);
+ if (dropdown) {
+ // reset the timer for hiding the dropdown
+ this.scheduleHideDropdown(fieldId);
+ return;
+ }
+
+ const clearSearch = window.targetCheck(e, this.selectors.search.clear);
+ if (clearSearch) {
+ const field = this.currentField();
+ if (field && field.ui.search) {
+ field.ui.search.value = '';
+ this.store.setFilters({
+ search: '',
+ page: 1,
+ parent: this.store.filters.parent || 0
+ });
+ }
+ if (this.ui.search.input) {
+ this.ui.search.input.value = '';
+ }
+ }
+
}
+ handleChange(e) {
+ if (!this.container.contains(e.target)) {
+ return;
+ }
+ if (e.target.type !== 'checkbox') return;
+ e.preventDefault();
+ e.stopPropagation();
- /***********************************************************************
- * UNIFIED SEARCH
- ***********************************************************************/
-
- performSearch(query, context = 'modal', fieldId = null) {
- const field = context === 'autocomplete'
- ? this.fields.get(fieldId)
- : this.currentConfig;
-
+ const termId = parseInt(e.target.dataset.id);
+ let fieldId = this.getFieldId(e.target);
+ if (e.target.checked) {
+ this.addSelected(termId, fieldId);
+ } else {
+ this.removeSelected(termId, fieldId);
+ }
+ }
+ //For search in modal or field autocomplete
+ handleInput(e) {
+ let fieldId = this.getFieldId(e.target)??this.activeField;
+ if (!fieldId) return;
+ const field = this.fields.get(fieldId);
if (!field) return;
- // Autocomplete validation
- if (context === 'autocomplete') {
- field.currentAutocompleteQuery = query;
-
- if (query.length < 2) {
- if (field.autocompleteDropdown) {
- field.autocompleteDropdown.hidden = true;
- }
- return;
- }
-
- this.searchContexts.set(fieldId, 'autocomplete');
+ if (!this.container.open) {
this.activeField = fieldId;
-
- if (field.autocompleteDropdown) {
- field.autocompleteDropdown.hidden = false;
- }
}
- // Debounced search
+ const query = e.target.value.trim();
window.debouncer.schedule(
- `taxonomy-search-${context}-${fieldId || 'modal'}`,
+ `${fieldId}-search`,
async () => {
await this.store.setFilters({
taxonomy: field.taxonomy,
@@ -503,507 +255,537 @@
page: 1,
parent: query ? 0 : (this.store.filters.parent || 0)
});
-
- if (context === 'modal') {
- window.removeChildren(this.ui.termsList);
+ if (this.container.open) {
+ window.removeChildren(this.ui.terms.list);
}
},
- 300
+ 100
);
}
- /***********************************************************************
- * MODAL OPERATIONS
- ***********************************************************************/
+ handleFocus(e) {
+ const fieldId = this.getFieldId(e.target);
+ const field = this.fields.get(fieldId);
+ if (!fieldId || !field) return;
+ if (!field.hasAutocomplete && !field.hasSearch) return;
- setActiveField(fieldId, openModal = false) {
- this.activeField = fieldId;
- this.currentConfig = this.fields.get(fieldId);
+ window.debouncer.cancel(`${fieldId}-search-results`);
- if (openModal) {
- this.modalInstance.handleOpen();
+ if (!this.container.open){
+ this.activeField = fieldId;
+ this.preloadTaxonomy(field.taxonomy);
}
+ }
- this.store.setFilter('taxonomy', this.currentConfig.taxonomy);
+ //Hide autocomplete dropdown on blur
+ handleBlur(e) {
+ const fieldId = this.getFieldId(e.target);
+ const field = this.fields.get(fieldId);
+ if (!fieldId || ! field) return;
+ if (!field.hasAutocomplete) return;
- // Reset modal selection state
- this.selectedTerms.clear();
+ this.scheduleHideDropdown(fieldId);
+ }
- // Copy field selections to modal
- this.currentConfig.selectedTerms.forEach(termId => {
- const term = this.store.get(termId);
- if (term) {
- this.selectedTerms.set(termId, {
- id: termId,
- name: term.name,
- path: term.path
- });
+ scheduleHideDropdown(fieldId){
+ const field = this.fields.get(fieldId);
+ if (!field) return;
+
+ window.debouncer.schedule(
+ `${fieldId}-search-results`,
+ () => {
+ this.activeField = null;
+ field.ui.dropdown.hidden = true;
+ },
+ 1500
+ );
+ }
+
+ /******************************************************************
+ MODAL
+ ******************************************************************/
+ initModal() {
+ this.modalID = 'dialog#jvb-selector';
+ this.container = document.querySelector(this.modalID);
+
+ this.modal = new window.jvbModal(
+ this.container,
+ {
+ handleForm: false,
+ save: null,
+ open: null
+ }
+ );
+ this.modal.subscribe((event, data) => {
+ switch (event) {
+
}
});
}
- handleModalClick(e) {
- if (window.targetCheck(e, '.remove-item')) {
- const selectedItem = window.targetCheck(e, '.selected-item');
- if (selectedItem) {
- this.removeSelectedTermFromModal(selectedItem.dataset.id);
- }
- } else if (window.targetCheck(e, '.back-to-parent')) {
- this.navigateToParent();
- } else if (window.targetCheck(e, '.toggle-children')) {
- const termItem = e.target.closest('li');
- this.navigateToChild(
- parseInt(termItem.dataset.id),
- termItem.querySelector('.term-name').textContent
- );
- } else if (window.targetCheck(e, '.path-level')) {
- const pathLevel = window.targetCheck(e, '.path-level');
- this.navigateToPath(parseInt(pathLevel.dataset.id) || 0);
- }
- }
+ toggleModal(fieldId, open = true) {
+ const field = this.fields.get(fieldId);
+ if (!field) return;
- handleModalChange(e) {
- if (e.target.type !== 'checkbox') return;
-
- e.preventDefault();
- e.stopPropagation();
-
- const termId = parseInt(e.target.closest('li').dataset.id);
- const label = e.target.closest('li').querySelector('label');
-
- if (e.target.checked) {
- this.addSelectedTermToModal(termId, label.title, label.dataset.path);
+ if (open) {
+ this.openModal(fieldId);
} else {
- this.removeSelectedTermFromModal(termId);
+ this.closeModal();
}
}
- openModal() {
- if (!this.currentConfig) {
- console.error('No active field set');
- return;
- }
+ openModal(fieldId) {
+ const field = this.fields.get(fieldId);
+ if (!field) return;
- this.updateModalUI();
- this.updateModalSelections();
-
- window.removeChildren(this.ui.termsList);
- this.showLoading();
- }
-
- closeModal() {
- this.observer.unobserve(this.ui.sentinel);
- window.removeChildren(this.ui.termsList);
-
- this.notify('selected-terms', {
- terms: this.selectedTerms,
- taxonomy: this.currentConfig.taxonomy
- });
-
- if (this.activeField) {
- this.saveSelectionsToField(this.activeField);
- }
-
- this.activeField = null;
- this.currentConfig = null;
- }
-
- updateModalUI() {
- const singular = this.getLabel(this.currentConfig.taxonomy, 'single');
- const plural = this.getLabel(this.currentConfig.taxonomy, 'plural');
-
- this.ui.modal.title.textContent = `Select ${plural}`;
-
+ this.activeField = fieldId;
+ this.ui.modal.title.textContent = `Select ${field.plural}`;
if (this.ui.search.container) {
- this.ui.search.container.style.display = this.currentConfig.canSearch ? 'block' : 'none';
+ this.ui.search.container.hidden = !field.canSearch;
}
-
if (this.ui.create.details) {
- this.ui.create.details.style.display = this.currentConfig.canCreate ? 'block' : 'none';
- this.ui.create.details.hidden = !this.currentConfig.canCreate;
+ this.ui.create.details.hidden = !field.canCreate;
if (this.ui.create.summary) {
- this.ui.create.summary.textContent = `Add new ${singular}`;
+ this.ui.create.summary.textContent = `Add new ${field.singular}`;
}
-
if (this.ui.create.label.name) {
- this.ui.create.label.name.textContent = `Name this ${singular}`;
+ this.ui.create.label.name.textContent = `Name this ${field.singular}`;
}
if (this.ui.create.label.parent) {
this.ui.create.label.parent.textContent = `Nest it under`;
}
}
+ let message = `Opened ${field.singular} selection. Choose from checkboxes, or search to filter results.`;
- this.a11y?.announce(`Opened ${singular} selection. Choose from checkboxes or search to filter results.`);
- }
+ window.removeChildren(this.ui.terms.list);
+ this.modal.handleOpen();
+ this.setLoading();
- updateModalSelections() {
- window.removeChildren(this.ui.selectedTerms);
-
- this.selectedTerms.forEach((termData, id) => {
- this.addTermDisplay(id, termData.name, termData.path, 'modal');
+ this.store.setFilters({
+ taxonomy: field.taxonomy,
+ page: 1,
+ search: '',
+ parent: 0,
});
- this.checkSelectionLimits();
+ this.a11y.announce(message);
}
- addSelectedTermToModal(id, name, path) {
- this.selectedTerms.set(id, { id, name, path });
-
- this.addTermDisplay(id, name, path, 'modal');
- this.checkSelectionLimits();
-
- const checkbox = this.ui.termsList.querySelector(`input[value="${id}"]`);
- if (checkbox) checkbox.checked = true;
- }
-
- removeSelectedTermFromModal(id) {
- this.selectedTerms.delete(parseInt(id));
-
- const selectedItem = this.ui.selectedTerms.querySelector(`[data-id="${id}"]`);
- if (selectedItem) selectedItem.remove();
-
- const checkbox = this.ui.termsList.querySelector(`input[value="${id}"]`);
- if (checkbox) checkbox.checked = false;
-
- this.checkSelectionLimits();
- }
-
- checkSelectionLimits() {
- if (!this.currentConfig || this.currentConfig.maxSelection === 0) {
- return;
- }
-
- this.disabled = this.selectedTerms.size >= this.currentConfig.maxSelection;
-
- this.ui.termsList.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
- if (!checkbox.checked) {
- checkbox.disabled = this.disabled;
- }
- });
- }
-
- saveSelectionsToField(fieldId) {
- const field = this.fields.get(fieldId);
+ closeModal() {
+ this.modal.handleClose();
+ const field = this.fields.get(this.activeField);
if (!field) return;
+ this.observer.unobserve(this.ui.terms.sentinel);
+ window.removeChildren(this.ui.terms.list);
- field.selectedTerms.clear();
- window.removeChildren(field.selectedContainer);
-
- this.selectedTerms.forEach((termData, id) => {
- field.selectedTerms.add(id);
- this.addTermDisplay(id, termData.name, termData.path, 'field', fieldId);
+ this.notify('selected-terms', {
+ terms: this.selectedTerms.get(this.activeField),
+ taxonomy: field.taxonomy
});
- field.input.value = Array.from(field.selectedTerms).join(',');
- field.input.dispatchEvent(new Event('change', { bubbles: true }));
+ this.activeField = null;
+
+ let message = `Closed ${field.singular} selector.`;
+ this.a11y.announce(message);
}
- /***********************************************************************
- * TERM DISPLAY
- ***********************************************************************/
+ navigateToParent() {
+ const current = this.store.filters.parent;
+ if (current === 0) return;
+ let term = this.store.get(parseInt(current));
+ if (!term) return;
+ let parent = term.parent;
+ this.navigateTo(parseInt(parent));
+ }
+ navigateTo(termId = 0) {
+ termId = parseInt(termId)??0;
+ this.store.setFilters({parent: termId, page: 1});
+ window.removeChildren(this.ui.terms.list);
+ this.updateBreadcrumbs(termId);
+ }
- addTermDisplay(termId, termName, termPath, context = 'field', fieldId = null) {
- const config = context === 'field'
- ? this.fields.get(fieldId)
- : this.currentConfig;
+ nextPage() {
+ let current = this.store.filters.page;
+ let page = Math.min(current++, this.store.lastResponse.total);
+ this.store.setFilters({page:page});
+ }
+ prevPage() {
+ let current = this.store.filters.page;
+ let page = Math.max(current - 1, 1);
+ this.store.setFilters({page:page});
+ }
- const container = context === 'field'
- ? config.selectedContainer
- : this.ui.selectedTerms;
-
- if (container.querySelector(`[data-id="${termId}"]`)) return;
+ addTermToModal(termId) {
+ const term = this.store.get(termId);
+ if (!term) return;
const item = window.getTemplate('selectedTerm');
item.dataset.id = termId;
- item.dataset.path = termPath;
- item.dataset.name = termName;
- item.dataset.taxonomy = config.taxonomy;
- item.querySelector('.item-name').textContent = termPath;
- item.querySelector('button').title = `Remove ${termName}`;
+ item.querySelector('span').textContent = term.path;
+ item.querySelector('button').title = `Remove ${name}`;
- container.appendChild(item);
-
- if (context === 'modal') {
- const checkbox = this.ui.termsList.querySelector(`input[value="${termId}"]`);
- if (checkbox) checkbox.checked = true;
- }
+ this.ui.selected.append(item);
}
-
- removeSelectedTerm(fieldId, termId) {
- const field = this.fields.get(fieldId);
- if (!field) return;
-
- field.selectedTerms.delete(parseInt(termId));
-
- const selectedItem = field.selectedContainer.querySelector(`[data-id="${termId}"]`);
- if (selectedItem) selectedItem.remove();
-
- field.input.value = Array.from(field.selectedTerms).join(',');
- field.input.dispatchEvent(new Event('change', { bubbles: true }));
- }
-
- updateFieldFromInput(fieldId) {
- const field = this.fields.get(fieldId);
- if (!field) return;
-
- const value = field.input.value.trim();
- field.selectedTerms.clear();
- window.removeChildren(field.selectedContainer);
-
- if (value) {
- value.split(',')
- .map(id => parseInt(id.trim()))
- .filter(id => !isNaN(id))
- .forEach(id => field.selectedTerms.add(id));
-
- this.initFieldDisplay(fieldId);
- }
- }
-
- /***********************************************************************
- * NAVIGATION
- ***********************************************************************/
-
- navigateToParent() {
- this.store.setFilters({ parent: 0, page: 1 });
- window.removeChildren(this.ui.termsList);
- this.ui.breadcrumbs.back.hidden = true;
- }
-
- navigateToChild(termId, termName) {
- this.store.setFilters({ parent: termId, page: 1 });
- window.removeChildren(this.ui.termsList);
- this.updateBreadcrumbs(termId, termName);
- this.ui.breadcrumbs.back.hidden = false;
- }
-
- navigateToPath(parentId) {
- this.store.setFilters({ parent: parentId, page: 1 });
- window.removeChildren(this.ui.termsList);
- this.ui.breadcrumbs.back.hidden = parentId === 0;
- }
-
- loadMoreTerms() {
- const currentPage = this.store.filters.page || 1;
- this.store.setFilter('page', currentPage + 1);
- }
-
- updateBreadcrumbs(termId, termName) {
- const breadcrumb = window.getTemplate('termBreadcrumb');
- breadcrumb.dataset.id = termId;
- breadcrumb.textContent = termName;
- breadcrumb.title = termName;
-
- const existingCrumb = this.ui.breadcrumbs.nav.querySelector(`[data-id="${termId}"]`);
- if (existingCrumb) {
- while (existingCrumb.nextElementSibling) {
- existingCrumb.nextElementSibling.remove();
+ /******************************************************************
+ FIELDS
+ ******************************************************************/
+ scanExistingFields(container = document.body) {
+ container.querySelectorAll('[data-type="selector"]').forEach(
+ selector => {
+ try {
+ this.registerField(selector);
+ } catch (error) {
+ this.error.log(error, {
+ component: 'TaxonomySelector',
+ action: 'scanExistingFields',
+ container: selector.dataset.name
+ });
+ }
}
- } else {
- this.ui.breadcrumbs.nav.appendChild(breadcrumb);
- }
+ );
}
- /***********************************************************************
- * RENDERING
- ***********************************************************************/
-
- renderTerms(terms = null, append = false, showPath = false) {
- if (!terms) terms = this.store.getFiltered();
-
- if (!append) window.removeChildren(this.ui.termsList);
-
- if (terms.length === 0) {
- if (!append) this.showEmptyState();
+ registerField(element, options = {}) {
+ let input = element.querySelector('input[type="hidden"]');
+ if (!input) {
+ console.warn('TaxonomySelector: No hidden input found for field', element);
return;
}
- const currentParent = this.store.filters.parent || 0;
- this.ui.breadcrumbs.back.hidden = currentParent === 0;
+ if (!('fieldId' in element.dataset)) {
+ element.dataset.fieldId = window.generateID('selector');
+ }
+ const fieldId = element.dataset.fieldId;
+
+
+ let selectors = this.selectors.field;
+ let button = element.querySelector('button.taxonomy-toggle');
+ if (options.size === 0){
+ if (!button) return;
+ options = button.dataset;
+ if (options.size === 0) return;
+ } else if (Object.hasOwn(options, 'toggle')) {
+ button = document.querySelector(options.toggle);
+ selectors.toggle = options.toggle;
+ }
+
+ const config = {
+ id: fieldId,
+ value: input,
+ element: element,
+ taxonomy: options.taxonomy??false,
+ singular: options.single??'',
+ plural: options.plural??'',
+ name: element.dataset.field,
+ canSearch: Object.hasOwn(options, 'search'),
+ limit: options.limit??0,
+ hasAutocomplete: Object.hasOwn(options, 'autocomplete'),
+ canCreate: Object.hasOwn(options, 'creatable'),
+ isRequired: Object.hasOwn(options, 'required'),
+ toggle: button,
+ selectors: selectors,
+ ui: window.uiFromSelectors(selectors, element),
+ checked: false,
+ };
+ if (!config.taxonomy) return;
+ this.fields.set(fieldId, config);
+
+ //Check for stored selected terms in hidden input
+ let selected = new Set();
+ input.value.value.trim()
+ .split(',')
+ .map(id => parseInt(id.trim()))
+ .filter(id => !isNaN(id))
+ .forEach(id => selected.add(id));
+ this.selectedTerms.set(fieldId, selected);
+
+ if (this.isInitializing) {
+ this.batchFetch.add(config.taxonomy);
+ }
+ this.updateFieldUI(fieldId);
+
+ return fieldId;
+ }
+
+ addSelected(termId, fieldId = null) {
+ if (!fieldId) fieldId = this.activeField;
+
+ const field = this.fields.get(fieldId);
+ const term = this.store.get(termId);
+ if (!field || !term) return;
+
+ const selected = this.selectedTerms.get(fieldId);
+ if (field.limit !== 0 && selected.size >= field.limit) return;
+
+ selected.add(parseInt(termId));
+ this.addTermToDisplay(termId, fieldId);
+ this.updateFieldValue(fieldId);
+ this.checkLimits(fieldId);
+ }
+ removeSelected(termId, fieldId = null) {
+ if (!fieldId) fieldId = this.activeField;
+ const field = this.fields.get(fieldId);
+ const term = this.store.get(termId);
+ if (!field || !term) return;
+ this.selectedTerms.get(fieldId).delete(parseInt(termId));
+
+ const selectedItem = field.ui.selected.querySelector(`[data-i"${termId}"]`);
+ if (selectedItem) selectedItem.remove();
+ if (this.container.open) {
+ let item = this.ui.selected.querySelector(`[data-id="${termId}"]`);
+ if (item) item.remove();
+ }
+ this.updateFieldValue(fieldId);
+ this.checkLimits(fieldId);
+ }
+ updateFieldValue(fieldId) {
+ const field = this.fields.get(fieldId);
+ if (!field) return;
+ let selected = Array.from(this.selectedTerms.get(fieldId));
+ field.ui.value = selected.join(',');
+ }
+
+ checkLimits(fieldId) {
+ if (!this.container.open) return;
+ const field = this.fields.get(fieldId);
+ if (!field || field.limit === 0) return;
+ const disabled = this.selectedTerms.get(fieldId).size >= field.limit;
+ this.setCheckboxes(disabled);
+ }
+
+ updateFieldUI(fieldId) {
+ const field = this.fields.get(fieldId);
+ let selected = this.selectedTerms.get(fieldId);
+ if (!field || selected.size === 0) return;
+
+ Array.from(selected).forEach(termId => {
+ this.addTermToDisplay(termId, fieldId);
+ });
+ }
+
+ updateFieldsForTaxonomy(taxonomy) {
+ let fields = Array.from(this.fields.values())
+ .filter(field => !field.checked && field.taxonomy === taxonomy);
+ const hasItems = Array.from(this.store.data.values())
+ .some(term=>term.taxonomy === taxonomy);
+
+ fields.forEach(field => {
+ field.ui.toggle.disabled = !hasItems && !field.canCreate;
+ field.ui.toggle.title = !hasItems
+ ? `No ${field.singular} available`
+ : `Select ${field.plural}`;
+
+ field.checked = true;
+ });
+ }
+
+ showModalTerms(append = true, showPath = false) {
+ const terms = this.store.getFiltered();
+ if (terms.size === 0) return;
+ if (!append) {
+ window.removeChildren(this.ui.terms.list);
+ }
+
+ const currentParent = this.store.filters.parent??0;
+ this.ui.nav.back.hidden = currentParent === 0;
const fragment = document.createDocumentFragment();
terms.forEach(term => {
const element = this.createTermElement({
- id: parseInt(term.id),
- name: term.name,
- hasChildren: term.hasChildren,
- path: term.path || null,
- show: showPath
+ show: showPath,
+ ... term
});
-
- if (element) fragment.appendChild(element);
+ if (element) {
+ fragment.appendChild(element);
+ }
});
- this.ui.termsList.appendChild(fragment);
+ this.ui.terms.list.append(fragment);
}
+ createTermElement(term) {
+ if (!term || !term.name) return null;
- createTermElement(termData) {
- if (!termData?.name) return null;
+ const item = window.getTemplate('termListItem');
+ item.dataset.id = term.id;
- const listItem = window.getTemplate('termListItem');
- listItem.dataset.id = termData.id;
+ const isSelected = this.selectedTerms.get(this.activeField).has(term.id);
+ let [
+ checkbox,
+ label,
+ nameSpan
+ ] = [
+ item.querySelector('input'),
+ item.querySelector('label'),
+ item.querySelector('span, .term-name')
+ ];
- const isSelected = this.selectedTerms.has(termData.id);
- const checkbox = listItem.querySelector('input');
- const label = listItem.querySelector('label');
- const nameSpan = listItem.querySelector('.term-name');
-
- checkbox.id = `${this.currentConfig.container.id}${termData.id}`;
- checkbox.name = `${this.currentConfig.container.id}${this.currentConfig.taxonomy}-select`;
- checkbox.value = termData.id;
- checkbox.disabled = !isSelected && this.disabled;
- checkbox.checked = isSelected;
-
- label.htmlFor = checkbox.id;
- label.title = termData.path || termData.name;
- label.dataset.path = termData.path;
-
- nameSpan.textContent = termData.show ? termData.path : termData.name;
-
- if (termData.hasChildren) {
- const childrenToggle = window.getTemplate('termChildrenToggle');
- childrenToggle.ariaLabel = `View sub-terms of ${termData.name}`;
- listItem.appendChild(childrenToggle);
+ let field = this.currentField();
+ let limitReached = field.limit > 0 && this.selectedTerms.get(this.activeField).size >= field.limit;
+ if (checkbox && label && nameSpan) {
+ [
+ checkbox.id,
+ checkbox.name,
+ checkbox.value,
+ checkbox.disabled,
+ checkbox.checked,
+ label.htmlFor,
+ label.title,
+ label.dataset.path,
+ nameSpan.textContent
+ ] = [
+ `${field.element.id}-${term.id}`,
+ `${field.container.id}-${field.taxonomy}-select`,
+ term.id,
+ !isSelected && limitReached,
+ isSelected,
+ `${field.element.id}-${term.id}`,
+ term.path??term.name,
+ term.path,
+ term.show ? term.path : term.name
+ ];
+ if (term.hasChildren) {
+ const toggle = window.getTemplate('termChildrenToggle');
+ if (toggle) {
+ toggle.ariaLabel = `View ${field.plural} nested under ${term.name}`;
+ item.append(toggle);
+ }
+ }
}
- return listItem;
+ return item;
}
- /***********************************************************************
- * AUTOCOMPLETE
- ***********************************************************************/
+ showAutocompleteTerms() {
+ const field = this.currentField();
+ const terms = this.currentTerms();
+ if (!field || terms.size ===0) return;
- showAutocompleteResults(field, terms, query) {
- if (!field?.autocompleteDropdown) return;
-
- const dropdown = field.autocompleteDropdown;
+ const dropdown = field.ui.dropdown;
window.removeChildren(dropdown);
-
if (terms.length === 0) {
- this.showEmptyState('No items found.', dropdown);
+ this.showEmptyState(`No ${field.plural} found.`, dropdown);
} else {
- const fragment = document.createDocumentFragment();
-
terms.forEach(term => {
- const item = this.createAutocompleteItem(field, term);
- if (item) fragment.appendChild(item);
- });
-
- dropdown.appendChild(fragment);
+ const item = this.createAutocompleteTerm(term);
+ if (item) {
+ dropdown.append(item);
+ }
+ })
}
- // Create button if allowed and no exact match
- const currentQuery = field.currentAutocompleteQuery || query;
- if (field.canCreate && currentQuery) {
- const exactMatch = terms.find(term =>
- term.name.toLowerCase() === currentQuery.toLowerCase()
- );
-
- if (!exactMatch) {
- dropdown.appendChild(this.createAutocompleteCreateButton(currentQuery));
+ const query = field.ui.search?.value;
+ if (field.canCreate && query.length >= 2 && this.creator) {
+ const createButton = this.createTermButton(query);
+ if (createButton) {
+ dropdown.append(createButton);
}
}
dropdown.hidden = false;
}
+ createAutocompleteTerm(term) {
+ const item = window.getTemplate('autocompleteItem');
+ if (!item) return;
- createAutocompleteItem(field, term) {
- const button = document.createElement('button');
- button.type = 'button';
- button.className = 'autocomplete-item';
- button.dataset.id = term.id;
- button.dataset.name = term.name;
- button.dataset.path = term.path || term.name;
- button.textContent = term.path || term.name;
-
- button.addEventListener('click', () => {
- field.selectedTerms.add(parseInt(term.id));
- this.addTermDisplay(term.id, term.name, term.path, 'field', field.id);
-
- field.input.value = Array.from(field.selectedTerms).join(',');
- field.input.dispatchEvent(new Event('change', { bubbles: true }));
-
- field.autocompleteDropdown.hidden = true;
- const input = field.container.querySelector('input[data-autocomplete]');
- if (input) input.value = '';
- });
-
- return button;
+ item.dataset.id = term.id;
+ item.textContent = term.path || term.name;
+ return item;
}
-
- createAutocompleteCreateButton(query) {
- const button = document.createElement('button');
- button.type = 'button';
- button.className = 'autocomplete-item create-term';
- button.dataset.query = query;
-
- const strong = document.createElement('strong');
- strong.textContent = 'Create: ';
-
- button.appendChild(strong);
- button.appendChild(document.createTextNode(`"${query}"`));
-
- return button;
- }
-
- showAutocompleteError(fieldId) {
+ /******************************************************************
+ UI
+ ******************************************************************/
+ addTermToDisplay(termId, fieldId) {
+ const term = this.store.get(termId);
const field = this.fields.get(fieldId);
- if (!field?.autocompleteDropdown) return;
+ if (!term || !field) return;
+ //if the term already exists in the selected items, bail early
+ if (field.ui.selected.querySelector(`[data-id="${termId}"]`)) return;
- window.removeChildren(field.autocompleteDropdown);
- this.showEmptyState('Hmmm... something went wrong', field.autocompleteDropdown);
+ const item = window.getTemplate('selectedTerm');
+ if (!item) return;
+
+ item.dataset.id = termId;
+ item.dataset.taxonomy = field.taxonomy;
+ item.querySelector('.item-name').textContent = term.path;
+ item.querySelector('button').title = `Remove ${term.name}`;
+
+ field.ui.selected.append(item);
+
+ if (this.container.open) {
+ this.addTermToModal(termId);
+ const checkbox = this.ui.terms.list.querySelector(`input[value="${termId}"]`);
+ if (checkbox) checkbox.checked = true;
+ }
+ }
+ createTermButton(query) {
+ const button = window.getTemplate('autocompleteButton');
+ if(!button) return;
+
+ let queryEl = button.querySelector('span');
+ queryEl.textContent = `"${query}"`;
+
+ return button;
}
- /***********************************************************************
- * UI STATES
- ***********************************************************************/
+ updateBreadcrumbs(termId) {
+ const nav = this.ui.nav.nav;
+ if (!nav) return;
+ const existingCrumb = Array.from(nav.children)
+ .find(crumb => parseInt(crumb.dataset.id) === termId);
- showLoading() {
- this.ui.loading.loading.hidden = false;
- this.modal.classList.add('loading');
-
- const searchQuery = this.store.filters.search || '';
- const currentParent = this.store.filters.parent || 0;
-
- const message = searchQuery
- ? `searching for "${searchQuery}" items`
- : currentParent === 0
- ? 'loading items'
- : 'loading child items';
-
- if (window.typeLoop) {
- this.stopTyping = window.typeLoop(this.ui.loading.text, message);
+ if (existingCrumb) {
+ // Remove all siblings after this crumb
+ let nextSibling = existingCrumb.nextElementSibling;
+ while (nextSibling) {
+ const toRemove = nextSibling;
+ nextSibling = nextSibling.nextElementSibling;
+ toRemove.remove();
+ }
} else {
- this.ui.loading.text.textContent = message;
+ // Add new breadcrumb
+ const term = this.store.get(termId);
+ if (!term) return;
+
+ const crumb = window.getTemplate('termBreadcrumb');
+ if (!crumb) return;
+
+ crumb.dataset.id = termId;
+ crumb.textContent = term.name;
+ crumb.title = term.name;
+
+ nav.append(crumb);
}
}
- hideLoading() {
- this.ui.loading.loading.hidden = true;
- this.modal.classList.remove('loading');
+ updateSelectionCount() {
+ if (!this.container.open) return;
+ const field = this.fields.get(this.activeField);
+ if (!field) return;
- if (this.stopTyping) {
- this.stopTyping();
- }
- }
+ if (this.ui.modal.count) {
+ const total = this.selectedTerms.get(this.activeField).size;
- showEmptyState(message = 'No items found.', container = null) {
- if (!container) container = this.ui.termsList;
-
- const emptyElement = window.getTemplate('noResults');
- const messageSpan = emptyElement.querySelector('span');
-
- if (message && messageSpan) {
- messageSpan.textContent = message;
+ this.ui.modal.count.textContent = field.limit > 0
+ ? `${total} of ${field.limit} ${field.plural} selected`
+ : `${total} ${field.plural} selected`;
}
- container.appendChild(emptyElement);
}
-
- /***********************************************************************
- * UTILITIES
- ***********************************************************************/
+ /******************************************************************
+ UTILITY
+ ******************************************************************/
+ currentField() {
+ return this.fields.get(this.activeField)??false;
+ }
+ currentTerms() {
+ return this.store.getFiltered();
+ }
+ needsCreator() {
+ return Array.from(this.fields.values()).some(field =>
+ field.canCreate || field.hasAutocomplete
+ );
+ }
getFieldId(element) {
if (element.dataset.fieldId) return element.dataset.fieldId;
@@ -1012,55 +794,170 @@
return fieldContainer?.dataset.fieldId || null;
}
- getLabel(taxonomy, type = 'single') {
- return jvbSettings.labels[taxonomy]?.[type] || taxonomy;
- }
-
- async batchFetchTaxonomies() {
- if (this.taxonomiesToFetch.size === 0) return;
-
- const taxonomies = Array.from(this.taxonomiesToFetch);
- this.taxonomiesToFetch.clear();
-
- this.store.setFilters({
- taxonomy: taxonomies.join(','),
- page: 1,
- search: '',
- parent: 0
+ /**
+ * Sets all checkbox disabled (or not)
+ * @param {Boolean} disabled
+ */
+ setCheckboxes(disabled) {
+ this.ui.terms.list.querySelectorAll('input[type=checkbox]').forEach(checkbox => {
+ if (!checkbox.checked) {
+ checkbox.disabled = disabled;
+ }
});
}
- async preloadTaxonomy(taxonomy) {
- await this.store.setFilters({
+ /******************************************************************
+ DATASTORE HELPERS
+ ******************************************************************/
+ handleStoreEvent(event, data) {
+ const handlers = {
+ 'data-loaded': () => this.handleDataLoaded(),
+ 'filters-changed': () => this.handleFiltersChanged(),
+ 'fetch-error': () => this.handleFetchError()
+ };
+
+ handlers[event]?.();
+ }
+ handleDataLoaded() {
+ const taxonomy = this.store.filters.taxonomy;
+ if (taxonomy?.includes(',')) {
+ const taxonomies = taxonomy.split(',').map(t => t.trim());
+ taxonomies.forEach(tax => this.updateFieldsForTaxonomy(tax));
+ }
+
+ if (this.container.open) {
+ this.showResults();
+ return;
+ }
+ if (this.activeField) {
+ this.showResults(true);
+ }
+ }
+
+ showResults(isAutoComplete = false) {
+ this.setLoading(false);
+ const terms = this.store.getFiltered();
+ const filters = this.store.filters;
+ const response = this.store.lastResponse?.page || {};
+ const isSearch = filters.search && filters.search.length > 0;
+ const append = filters.page > 1;
+ const field = this.currentField();
+
+ this.notify('terms-loaded', {
+ terms,
+ filters
+ });
+
+ if (terms.length === 0) {
+ if (!append) {
+ this.showEmptyState(isSearch ? `No matching ${field.plural}.` : `No ${field.plural} available.`);
+ }
+ this.observer.unobserve(this.ui.terms.sentinel);
+ } else {
+ if (!isAutoComplete) {
+ this.showModalTerms(append, isSearch);
+
+ if (response.has_more) {
+ this.observer.observe(this.ui.terms.sentinel);
+ } else {
+ this.observer.unobserve(this.ui.terms.sentinel);
+ }
+ } else {
+ this.showAutocompleteTerms()
+ }
+ }
+
+ this.a11y.announce(terms.length, append);
+ }
+ handleFiltersChanged() {
+ // if (this.modal?.open) {
+ // this.setLoading();
+ // }
+ }
+
+ handleFetchError(error) {
+ this.setLoading(false);
+ }
+ async batchFetchTaxonomies() {
+ if (this.batchFetch.size === 0) return;
+
+ const taxonomies = Array.from(this.batchFetch);
+ taxonomies.forEach(tax => this.loadedTaxonomies.add(tax));
+ this.batchFetch.clear();
+
+ try {
+ taxonomies.forEach(tax => this.loadedTaxonomies.add(tax));
+
+ await this.store.setFilters({
+ taxonomy: taxonomies.join(','),
+ page: 1,
+ search: '',
+ parent: 0
+ });
+ } catch (error) {
+ console.error('Failed to batch fetch taxonomies:', error);
+ }
+ }
+
+ preloadTaxonomy(taxonomy) {
+ if (this.loadedTaxonomies.has(taxonomy)) return;
+
+ this.store.setFilters( {
taxonomy: taxonomy,
page: 1,
search: '',
parent: 0
});
+
+ this.loadedTaxonomies.add(taxonomy);
}
- handleError(error, context, detail = null) {
- console.error(`Taxonomy ${context} error:`, error, detail);
+ /**************************************************
+ LOADING
+ **************************************************/
+ setLoading(on = true) {
+ this.ui.loading.loading.hidden = on;
+ this.modal.classList.toggle('loading', on);
- if (this.error?.log) {
- this.error.log(error, {
- component: 'TaxonomySelector',
- action: context,
- detail: detail
- });
- }
+ if (on) {
+ let searchQuery = this.store.filters.search || '';
+ searchQuery = searchQuery === '' ? false : searchQuery;
+ const currentParent = this.store.filters.parent || 0;
+ const message = searchQuery
+ ? `Searching for "${searchQuery} items` :
+ currentParent === 0
+ ? 'loading items'
+ : 'loading child items';
- if (this.modal?.open) {
- this.showEmptyState('Error loading. Please try again.');
+ if (window.typeLoop && this.ui.loading.text) {
+ this.stopTyping = window.typeLoop(this.ui.loading.text, message);
+ } else {
+ this.ui.loading.text.textContenet = message;
+ }
+ } else {
+ if (this.stopTyping) {
+ this.stopTyping();
+ this.stopTyping = null;
+ }
}
}
-
+ showEmptyState(message = 'No items found.', container = null) {
+ if (!container) container = this.ui.terms.list;
+ const emptyElement = window.getTemplate('noTermResults');
+ const span = emptyElement.querySelector('span');
+ if (message && span) {
+ span.textContent = message;
+ }
+ container.append(emptyElement);
+ }
+ /**************************************************
+ SUBSCRIBERS
+ **************************************************/
subscribe(callback) {
this.subscribers.add(callback);
return () => this.subscribers.delete(callback);
}
-
- notify(event, data = {}) {
+ notify(event, data={}) {
this.subscribers.forEach(callback => {
try {
callback(event, data);
@@ -1069,25 +966,24 @@
}
});
}
-
+ /******************************************************
+ CLEANUP
+ ******************************************************/
destroy() {
- document.removeEventListener('click', this.handleClick);
- document.removeEventListener('change', this.handleChange);
- document.removeEventListener('input', this.handleInput);
- document.removeEventListener('focus', this.handleFocus);
- document.removeEventListener('blur', this.handleBlur);
+ document.removeEventListener('click', this.clickHandler);
+ document.removeEventListener('change', this.changeHandler);
+ document.removeEventListener('input', this.inputHandler);
+ document.removeEventListener('focus', this.focusHandler);
+ document.removeEventListener('blur', this.blurHandler);
this.observer?.disconnect();
- this.store.destroy();
this.subscribers.clear();
this.fields.clear();
this.selectedTerms.clear();
- this.searchContexts.clear();
}
}
-// Initialize on auth ready
-document.addEventListener('DOMContentLoaded', () => {
+document.addEventListener('DOMContentLoaded', function() {
window.auth.subscribe((event) => {
if (event === 'auth-loaded') {
window.jvbSelector = new TaxonomySelector();
diff --git a/assets/js/concise/TaxonomySelectorOld.js b/assets/js/concise/TaxonomySelectorOld.js
new file mode 100644
index 0000000..711dc4e
--- /dev/null
+++ b/assets/js/concise/TaxonomySelectorOld.js
@@ -0,0 +1,1561 @@
+/**
+ * Centralized Taxonomy Selector with DataStore Integration
+ * Handles all taxonomy selection fields using DataStore for state management
+ */
+class TaxonomySelectorOld {
+ constructor() {
+ this.a11y = window.jvbA11y;
+ this.error = window.jvbError;
+ this.index = -1;
+
+ this.hasAutocomplete = false;
+ this.isInitializing = true;
+ this.taxonomiesToFetch = new Set();
+
+ this.triggers = new Set(['.taxonomy-toggle']);
+
+ this.subscribers = new Set();
+
+ const store = window.jvbStore.register(
+ 'taxonomies',
+ {
+ storeName: `terms`,
+ keyPath: 'id',
+ showLoading: false,
+ indexes: [
+ {name: 'taxonomy', keyPath: 'taxonomy'},
+ {name: 'parent', keyPath: 'parent'},
+ {name: 'slug', keyPath: 'slug', unique: true},
+ {name: 'count', keyPath: 'count'},
+ ],
+ endpoint: 'terms',
+ TTL: 2 * 60 * 1000, //2 hours
+ filters: {
+ taxonomy: '',
+ page: 1,
+ search: '',
+ parent: 0
+ },
+ required: 'taxonomy',
+ delayFetch: true,
+ });
+ this.store = store.terms;
+
+ // Central field management
+ this.fields = new Map();
+ this.selectedTerms = new Map(); // Current modal selection
+
+ // Current modal context
+ this.activeField = null;
+ this.currentConfig = null;
+ this.currentSingular = null;
+ this.currentPlural = null;
+
+ // Modal state
+ this.disabled = false;
+
+ // Search debouncing
+ this.searchHandler = null;
+ this.autocompleteHandler = null;
+ this.isAutocompleteActive = false;
+
+ this.init();
+ }
+
+ /**
+ * Initialize the selector
+ */
+ init() {
+ this.initModal();
+ this.scanExistingFields();
+ this.initGlobalListeners();
+
+ if (this.hasAutocomplete && window.jvbTaxCreator) {
+ this.creator = new window.jvbTaxCreator(this);
+
+ }
+ this.store.subscribe(this.handleStoreEvent.bind(this));
+ // Complete initialization
+ this.isInitializing = false;
+ this.batchFetchTaxonomies();
+ }
+
+ /**
+ * Handle DataStore events
+ */
+ handleStoreEvent(event, data) {
+ switch (event) {
+ case 'data-loaded':
+ const taxonomy = this.store.filters.taxonomy;
+ // Handle batch taxonomy loading (comma-separated)
+ if (taxonomy?.includes(',')) {
+ this.handleBatchDataLoaded(taxonomy, data);
+ }
+ // Update button states for this taxonomy (or taxonomies)
+ if (taxonomy) {
+ // Handle comma-separated taxonomies from batch fetch
+ const taxonomies = taxonomy.includes(',')
+ ? taxonomy.split(',').map(t => t.trim())
+ : [taxonomy];
+
+ taxonomies.forEach(tax => {
+ this.updateFieldsForTaxonomy(tax);
+ });
+ }
+
+ // Only render if modal is open OR autocomplete active
+ if (this.modal?.open) {
+ this.handleTermsLoaded(data);
+ }
+
+ if (this.isAutocompleteActive && this.activeField) {
+ const field = this.fields.get(this.activeField);
+ const terms = data.data?.items || [];
+ const query = data.filters?.search || '';
+ this.showAutocompleteResults(field, terms, query);
+ this.isAutocompleteActive = false;
+ }
+ break;
+
+ case 'filters-changed':
+ if (this.modal?.open) {
+ this.showLoading();
+ }
+ break;
+
+ case 'fetch-error':
+ if (this.isAutocompleteActive && this.activeField) {
+ this.showAutocompleteError(this.activeField);
+ this.isAutocompleteActive = false;
+ }
+ this.handleFetchError(data.error);
+ break;
+ }
+ }
+
+ /**
+ * Handle loaded terms from DataStore
+ */
+ handleTermsLoaded(data) {
+ this.hideLoading();
+ const terms = this.store.getFiltered(); // Use getFiltered() instead of getFilteredItems()
+ const response = this.store.lastResponse?.page || {};
+ const isSearch = data.filters?.search && data.filters.search.length > 0;
+ const append = response.page > 1;
+
+ this.notify('terms-loaded', { terms, filters: data.filters });
+
+ if (terms.length === 0) {
+ if (!append) {
+ this.showEmptyState(isSearch ? 'No results found.' : 'No items available.');
+ }
+ this.observer.unobserve(this.ui.sentinel);
+ } else {
+ this.renderTerms();
+
+ // Handle pagination
+ if (response.has_more) {
+ this.observer.observe(this.ui.sentinel);
+ } else {
+ this.observer.unobserve(this.ui.sentinel);
+ }
+ }
+
+ // Announce to screen readers
+ this.a11y?.announce(terms.length, append);
+ }
+
+ /**
+ * Handle fetch errors
+ */
+ handleFetchError(error) {
+ console.error('Taxonomy fetch error:', error);
+ this.hideLoading();
+
+ if (this.error?.log) {
+ this.error.log(error, {
+ component: 'TaxonomySelector',
+ action: 'fetchTerms'
+ }, () => this.fetchCurrentTerms());
+ } else {
+ this.showEmptyState('Error loading terms. Please try again.');
+ }
+ }
+
+
+ /**
+ * Check if taxonomy has terms and update button states
+ */
+ updateFieldButtonState(fieldId) {
+ const field = this.fields.get(fieldId);
+ if (!field) return;
+
+ // Check store for items of this specific taxonomy
+ const hasTerms = Array.from(this.store.data.values())
+ .some(term => term.taxonomy === field.taxonomy);
+
+ if (field.toggle) {
+ field.toggle.disabled = !hasTerms && !field.canCreate;
+ field.toggle.title = !hasTerms
+ ? `No ${this.getSingular(field.taxonomy)} available`
+ : `Select ${this.getPlural(field.taxonomy)}`;
+ }
+ }
+ /**
+ * Update fields when taxonomy items are updated
+ */
+ updateFieldsForTaxonomy(taxonomy) {
+ this.getFieldsForTaxonomy(taxonomy).forEach(field => {
+ this.updateFieldButtonState(field.id);
+ });
+ }
+
+ /**
+ * Get fields for a specific taxonomy
+ */
+ getFieldsForTaxonomy(taxonomy) {
+ return Array.from(this.fields.values())
+ .filter(field => field.taxonomy === taxonomy);
+ }
+
+
+
+ /**
+ * Scan page for existing taxonomy fields and register them
+ */
+ scanExistingFields(container = null) {
+ if (!container) {
+ container = document.body;
+ }
+ const selectors = container.querySelectorAll('.field.taxonomy, .field.post');
+
+ selectors.forEach(selector => {
+ try {
+ this.registerField(selector);
+ } catch (error) {
+ this.error.log(error, {
+ component: 'TaxonomySelector',
+ action: 'scanExistingFields',
+ container: selector.dataset.name
+ });
+ }
+ });
+ }
+
+ /**
+ * Register a taxonomy field
+ */
+ registerField(field, options = {}) {
+ let input = field.querySelector('input[type=hidden]');
+ if (!input) {
+ return false;
+ }
+ if (!('fieldId' in field.dataset)) {
+ field.dataset.fieldId = this.createFieldId(field);
+ }
+ let fieldId = field.dataset.fieldId;
+
+ let button = (Object.hasOwn(options, 'button')) ? options.button : field.querySelector('button.taxonomy-toggle');
+
+ if (Object.hasOwn(options, 'buttonSelector')) {
+ this.triggers.add(options.buttonSelector);
+ }
+
+ let config = {
+ id: fieldId,
+ input: input,
+ container: field,
+ taxonomy: button.dataset.taxonomy,
+ name: field.dataset.field,
+ maxSelection: parseInt(button.dataset.max) || 0,
+ canSearch: 'search' in button.dataset,
+ hasAutocomplete: 'autocomplete' in button.dataset,
+ autocompleteDropdown: field.querySelector('.autocomplete-dropdown')??false,
+ canCreate: 'creatable' in button.dataset,
+ isRequired: 'required' in button.dataset,
+ selectedTerms: new Set(),
+ toggle: button,
+ selectedContainer: (Object.hasOwn(options, 'selected')) ? options.selected : field.querySelector('.selected-items'),
+ ...options
+ };
+
+ if (!this.hasAutocomplete && config.hasAutocomplete) {
+ this.hasAutocomplete = true;
+ this.initAutocomplete();
+ }
+
+ // Parse initial selected values
+ const value = input.value.trim();
+ if (value !== '') {
+ const selectedIds = value.split(',')
+ .map(id => parseInt(id.trim()))
+ .filter(id => !isNaN(id));
+ selectedIds.forEach(id => config.selectedTerms.add(id));
+ }
+
+ if (Object.hasOwn(options, 'selectedItems')) {
+ options.selectedItems.forEach(id => {
+ config.selectedTerms.add(id);
+ });
+ }
+
+ this.fields.set(fieldId, config);
+
+ // Ensure store exists for this taxonomy
+ if (this.isInitializing) {
+ this.taxonomiesToFetch.add(config.taxonomy);
+ } else {
+ // this.store.setFilter('taxonomy', config.taxonomy);
+ }
+
+ // Initialize display for any pre-selected values
+ if (config.selectedTerms.size > 0) {
+ this.initFieldDisplay(fieldId);
+ }
+
+ return fieldId;
+ }
+
+ /**
+ * Register a filter button (simplified registration for feed blocks)
+ */
+ registerFilterButton(button, options = {}) {
+ const fieldId = this.createFieldId(button);
+ button.dataset.fieldId = fieldId;
+
+ if (options.buttonSelector) {
+ this.triggers.add(options.buttonSelector);
+ }
+
+ const config = {
+ id: fieldId,
+ input: null,
+ container: options.container || button.closest('.filters') || button.parentElement,
+ taxonomy: button.dataset.taxonomy,
+ name: `filter_${button.dataset.taxonomy}`,
+ maxSelection: parseInt(button.dataset.max) || 0,
+ canSearch: 'search' in button.dataset,
+ hasAutocomplete: false,
+ canCreate: false,
+ isRequired: false,
+ selectedTerms: new Set(options.selectedItems || []),
+ toggle: button,
+ selectedContainer: options.selected || null,
+ isFilterMode: true,
+ ...options
+ };
+
+ this.fields.set(fieldId, config);
+
+ if (this.isInitializing) {
+ this.taxonomiesToFetch.add(config.taxonomy);
+ } else {
+ this.store.setFilter('taxonomy', config.taxonomy);
+ }
+
+ return fieldId;
+ }
+
+ /**
+ * Create unique field ID
+ */
+ createFieldId(field) {
+ this.index++;
+ return 'selector-' + this.index;
+ }
+
+ /**
+ * Initialize display for a field with existing values
+ */
+ async initFieldDisplay(fieldId) {
+ const field = this.fields.get(fieldId);
+ if (!field || field.selectedTerms.size === 0) return;
+
+ const selectedIds = Array.from(field.selectedTerms);
+
+ selectedIds.forEach(termId => {
+ const term = this.store.get(termId); // Changed from getItem
+ if (term) {
+ this.addTermToDisplay(fieldId, term.id, term.name, term.path);
+ }
+ });
+ }
+
+ /**
+ * Initialize modal elements
+ */
+ initModal() {
+ this.modalID = 'dialog#jvb-selector';
+ this.modal = document.querySelector(this.modalID);
+
+ if (!this.modal) {
+ console.warn('Taxonomy selector modal not found');
+ return;
+ }
+
+ this.initModalElements();
+
+ // Initialize modal instance
+ this.modalInstance = new window.jvbModal(this.modal, {
+ handleForm: false,
+ save: null,
+ open: null
+ });
+ this.modalInstance.subscribe((event, data) => {
+ switch (event) {
+ case 'modal-open':
+ this.openModal(data);
+ break;
+ case 'modal-close':
+ this.closeModal(data);
+ break;
+ }
+ });
+ }
+
+ /**
+ * Initialize modal element references
+ */
+ initModalElements() {
+ this.selectors = {
+ search: {
+ input: '[type=search]',
+ clear: '.clear-search',
+ container: '.search-wrapper'
+ },
+ termsList: '.items-container',
+ termsWrap: '.items-wrap',
+ breadcrumbs: {
+ nav: 'nav.term-navigation',
+ back: '.back-to-parent',
+ },
+ loading: {
+ loading: '.loading',
+ text: '.loading span'
+ },
+ selectedTerms: '.selected-items',
+ sentinel: '.scroll-sentinel',
+ modal: {
+ title: '#modal-title',
+ content: '.modal-content'
+ },
+ create: {
+ details: '.create-new-term',
+ parent: '#select_parent',
+ summary: '.create-new-term summary',
+ name: '#term_name',
+ button: '.submit-term',
+ label: {
+ name: '[for=term_name]',
+ parent: '[for=select_parent]'
+ }
+ },
+ favouriteTerms: '.favourite-terms'
+ }
+
+ this.ui = window.uiFromSelectors(this.selectors);
+
+ // Initialize intersection observer for infinite scroll
+ this.observer = new IntersectionObserver((entries) => {
+ entries.forEach(entry => {
+ if (entry.isIntersecting) {
+ this.loadMoreTerms();
+ }
+ });
+ }, {
+ root: this.ui.termsWrap,
+ threshold: 0.5
+ });
+ }
+
+ /**
+ * Set up global event delegation
+ */
+ initGlobalListeners() {
+ document.addEventListener('click', this.handleClick.bind(this));
+ document.addEventListener('change', this.handleChange.bind(this));
+ if (this.hasAutocomplete) {
+ this.initAutocomplete();
+ }
+ }
+
+ initAutocomplete()
+ {
+ this.autocompleteHandler = (e) => {
+ window.debouncer.schedule(
+ 'taxonomy-autocomplete',
+ () => this.handleAutocomplete(e),
+ 300
+ );
+ };
+ document.addEventListener('input', this.autocompleteHandler);
+ document.addEventListener('blur', this.cleanupAutocomplete.bind(this));
+ // Preload taxonomy data on focus
+ document.addEventListener('focus', (e) => {
+ if (!('autocomplete' in e.target.dataset)) {
+ return;
+ }
+
+ const fieldId = this.getFieldId(e.target);
+ const field = this.fields.get(fieldId);
+
+ if (!field) return;
+
+ // Preload this taxonomy's data
+ this.preloadTaxonomy(field.taxonomy);
+ }, true); // Use capture phase
+ }
+
+ /**
+ * Handle global click events
+ */
+ handleClick(e) {
+ // Handle taxonomy toggle buttons
+ const toggleButton = window.targetCheck(e, Array.from(this.triggers));
+
+ if (toggleButton) {
+ e.preventDefault();
+ this.handleToggleClick(toggleButton);
+ return;
+ }
+
+ // Handle remove selected term buttons
+ const removeButton = window.targetCheck(e, 'button.remove-item');
+ if (removeButton && e.target.closest('.jvb-selector')) {
+ const fieldId = this.getFieldId(removeButton);
+ const termId = removeButton.closest('.selected-item').dataset.id;
+ this.removeSelectedTerm(fieldId, termId);
+ return;
+ }
+
+ // Handle modal close button
+ if (e.target.matches('.modal-close')) {
+ if (this.modalInstance) {
+ this.modalInstance.handleClose();
+ }
+ return;
+ }
+
+ // Handle clicks within the modal
+ if (this.modal && this.modal.contains(e.target)) {
+ this.handleModalClick(e);
+ }
+ }
+
+ /**
+ * Handle global change events
+ */
+ handleChange(e) {
+ // Handle hidden input changes for taxonomy fields
+ const taxonomyField = window.targetCheck(e, '.taxonomy.field, .post.field');
+ if (taxonomyField && e.target.type === 'hidden') {
+ const fieldId = this.getFieldId(e.target);
+ this.updateFieldFromInput(fieldId);
+ return;
+ }
+
+ // Handle modal changes
+ if (this.modal && this.modal.contains(e.target)) {
+ this.handleModalChange(e);
+ }
+ }
+
+ /**
+ * Handle toggle button click
+ */
+ handleToggleClick(toggle) {
+ try {
+ const fieldId = this.getFieldId(toggle);
+ const field = this.fields.get(fieldId);
+
+ if (!field) {
+ console.error('Field not found for toggle:', fieldId);
+ return;
+ }
+
+
+ this.setActiveField(fieldId, true);
+
+ } catch (error) {
+ console.error('Error handling toggle click:', error);
+ if (this.error?.log) {
+ this.error.log(error, {
+ component: 'TaxonomySelector',
+ action: 'handleToggleClick'
+ });
+ }
+ }
+ }
+
+ /**
+ * Set the active field for modal operations
+ */
+ setActiveField(fieldId, openModal = false) {
+ this.activeField = fieldId;
+ this.currentConfig = this.fields.get(fieldId);
+
+ this.currentSingular = this.getSingular(this.currentConfig.taxonomy);
+ this.currentPlural = this.getPlural(this.currentConfig.taxonomy);
+
+ if (openModal) {
+ this.modalInstance.handleOpen();
+ }
+
+ // Set taxonomy filter - store handles the rest
+ this.store.setFilter('taxonomy', this.currentConfig.taxonomy);
+
+ // Clear modal selection state
+ this.selectedTerms.clear();
+
+ // Copy field's current selections to modal state
+ this.currentConfig.selectedTerms.forEach(termId => {
+ const term = this.store.get(termId);
+ if (term) {
+ this.selectedTerms.set(termId, {
+ id: termId,
+ name: term.name,
+ path: term.path
+ });
+ }
+ });
+ }
+
+
+ /**
+ * Handle clicks within modal
+ */
+ handleModalClick(e) {
+ if (window.targetCheck(e, '.remove-item')) {
+ let selectedItem = window.targetCheck(e, '.selected-item');
+ if (selectedItem) {
+ this.removeSelectedTermFromModal(selectedItem.dataset.id);
+ }
+ } else if (window.targetCheck(e, '.back-to-parent')) {
+ this.navigateToParent();
+ } else if (window.targetCheck(e, '.toggle-children')) {
+ let termItem = e.target.closest('li');
+ this.navigateToChild(
+ parseInt(termItem.dataset.id),
+ termItem.querySelector('.term-name').textContent
+ );
+ } else if (window.targetCheck(e, '.path-level')) {
+ let pathLevel = window.targetCheck(e, '.path-level');
+ this.navigateToPath(pathLevel);
+ }
+ }
+
+ /**
+ * Handle changes within modal (checkboxes)
+ */
+ handleModalChange(e) {
+ if (window.targetCheck(e, this.modalID) && e.target.type === 'checkbox') {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const termId = parseInt(e.target.closest('li').dataset.id);
+ const label = e.target.closest('li').querySelector('label');
+
+ if (e.target.checked) {
+ this.addSelectedTermToModal(termId, label.title, label.dataset.path);
+ } else {
+ this.removeSelectedTermFromModal(termId);
+ }
+ }
+ }
+
+ /**
+ * Open modal for filtering (without a field)
+ * @param {string} taxonomy - The taxonomy to filter by
+ * @param {Function} callback - Callback when terms are selected
+ * @param {Array} preselected - Array of term IDs already selected
+ */
+ openForFilter(taxonomy, callback, preselected = []) {
+ // Create a temporary virtual field config
+ const virtualFieldId = `filter-${taxonomy}-${Date.now()}`;
+
+ this.fields.set(virtualFieldId, {
+ id: virtualFieldId,
+ input: null, // No input for filter mode
+ container: null,
+ taxonomy: taxonomy,
+ name: `filter_${taxonomy}`,
+ maxSelection: 0, // No limit for filters
+ canSearch: true,
+ hasAutocomplete: false,
+ autocompleteDropdown: document.querySelector('.autocomplete-dropdown')??false,
+ canCreate: false, // Disable creation for filters
+ isRequired: false,
+ selectedTerms: new Set(preselected),
+ toggle: null,
+ selectedContainer: null,
+ isFilterMode: true, // Flag for filter mode
+ filterCallback: callback // Store the callback
+ });
+
+ this.setActiveField(virtualFieldId, true);
+ this.modalInstance.handleOpen();
+ }
+
+ /**
+ * Open modal and initialize
+ */
+ openModal() {
+ if (!this.currentConfig) {
+ console.error('No active field set');
+ return;
+ }
+
+ // Initialize creator if available
+ if (!this.creator && this.currentConfig.canCreate && 'jvbTaxCreator' in window) {
+ this.creator = new window.jvbTaxCreator(this);
+ }
+
+ // Update modal UI
+ this.updateModalForTaxonomy();
+
+ // Load selected terms display
+ this.updateModalSelections();
+ this.updateSelectionCount();
+
+ // Clear terms list and show loading
+ window.removeChildren(this.ui.termsList);
+ this.showLoading();
+ }
+
+ /**
+ * Update selection count display in modal
+ */
+ updateSelectionCount() {
+ if (!this.currentConfig) return;
+
+ const count = this.selectedTerms.size;
+ const max = this.currentConfig.maxSelection;
+
+ // Update any count display elements
+ const countElement = this.modal?.querySelector('.selection-count');
+ if (countElement) {
+ if (max > 0) {
+ countElement.textContent = `${count} of ${max} selected`;
+ } else {
+ countElement.textContent = `${count} selected`;
+ }
+ }
+ }
+
+
+
+ /**
+ * Get singular label for taxonomy
+ */
+ getSingular(taxonomy) {
+ return jvbSettings.labels[taxonomy]?.single || taxonomy;
+ }
+
+ /**
+ * Get plural label for taxonomy
+ */
+ getPlural(taxonomy) {
+ return jvbSettings.labels[taxonomy]?.plural || taxonomy;
+ }
+
+ /**
+ * Close modal and save selections
+ */
+ closeModal() {
+ this.observer.unobserve(this.ui.sentinel);
+ window.removeChildren(this.ui.termsList);
+
+ this.notify('selected-terms', {
+ terms: this.selectedTerms,
+ taxonomy: this.currentConfig.taxonomy
+ });
+
+ if (this.currentConfig?.isFilterMode) {
+ if (this.currentConfig.filterCallback) {
+ const selectedIds = Array.from(this.selectedTerms.keys());
+ this.currentConfig.filterCallback(selectedIds, this.currentConfig.taxonomy);
+ }
+ // this.fields.delete(this.activeField);
+ } else if (this.activeField) {
+ this.saveSelectionsToField(this.activeField);
+ }
+
+ // Cleanup
+ if (this.currentConfig?.canSearch && this.searchHandler) {
+ this.ui.search.input.removeEventListener('input', this.searchHandler);
+ }
+
+ if (!this.hasAutocomplete && this.creator) {
+ delete this.creator;
+ }
+
+ // Remove: this.activeStore = null;
+ this.activeField = null;
+ this.currentConfig = null;
+ }
+
+ /**
+ * Reset modal state
+ */
+ resetModalState() {
+ this.disabled = false;
+
+ window.removeChildren(this.ui.termsList);
+ window.removeChildren(this.ui.selectedTerms);
+ this.ui.search.input.value = '';
+
+ // Clear navigation breadcrumbs
+ window.removeChildren(this.ui.breadcrumbs.nav);
+ this.ui.breadcrumbs.nav.appendChild(this.ui.breadcrumbs.back);
+ this.ui.breadcrumbs.back.hidden = true;
+ }
+
+ /**
+ * Update modal content for current taxonomy
+ */
+ updateModalForTaxonomy() {
+ if (!this.currentConfig) return;
+
+ this.ui.modal.title.textContent = `Select ${this.currentPlural}`;
+
+ if (this.ui.search.container) {
+ this.ui.search.container.style.display = this.currentConfig.canSearch ? 'block' : 'none';
+ }
+
+ if (this.ui.create.details) {
+ this.ui.create.details.style.display = this.currentConfig.canCreate ? 'block' : 'none';
+ this.ui.create.details.hidden = !this.currentConfig.canCreate;
+
+ if (this.ui.create.summary) {
+ this.ui.create.summary.textContent = `Add new ${this.currentSingular}`;
+ }
+
+ if (this.ui.create.label.name) {
+ this.ui.create.label.name.textContent = `Name this ${this.currentSingular}`;
+ }
+ if (this.ui.create.label.parent) {
+ this.ui.create.label.parent.textContent = `Nest it under`;
+ }
+
+ if (this.ui.create.parent) {
+
+ }
+ }
+
+ const openMessage = `Opened ${this.currentSingular} selection. Choose from checkboxes or search to filter results.`;
+ this.a11y?.announce(openMessage);
+ }
+
+ /**
+ * Update modal selections display
+ */
+ updateModalSelections() {
+ window.removeChildren(this.ui.selectedTerms);
+
+ this.selectedTerms.forEach((termData, id) => {
+ this.addTermToModalDisplay(id, termData.name, termData.path);
+ });
+
+ this.checkSelectionLimits();
+ }
+
+ /**
+ * Add selected term to modal
+ */
+ addSelectedTermToModal(id, name, path) {
+ this.selectedTerms.set(id, {
+ id: id,
+ name: name,
+ path: path
+ });
+
+ this.addTermToModalDisplay(id, name, path);
+ this.checkSelectionLimits();
+
+ // Check the corresponding checkbox
+ const checkbox = this.ui.termsList.querySelector(`input[value="${id}"]`);
+ if (checkbox) {
+ checkbox.checked = true;
+ }
+ }
+
+ /**
+ * Remove selected term from modal
+ */
+ removeSelectedTermFromModal(id) {
+ this.selectedTerms.delete(parseInt(id));
+
+ // Remove from modal display
+ const selectedItem = this.ui.selectedTerms.querySelector(`[data-id="${id}"]`);
+ if (selectedItem) {
+ selectedItem.remove();
+ }
+
+ // Uncheck the corresponding checkbox
+ const checkbox = this.ui.termsList.querySelector(`input[value="${id}"]`);
+ if (checkbox) {
+ checkbox.checked = false;
+ }
+
+ this.checkSelectionLimits();
+ }
+
+ /**
+ * Add term to modal display
+ */
+ addTermToModalDisplay(id, name, path) {
+ const item = window.getTemplate('selectedTerm').cloneNode(true);
+ item.dataset.id = id;
+ item.dataset.path = path;
+ item.dataset.name = name;
+ item.dataset.taxonomy = this.currentConfig.taxonomy;
+ item.querySelector('span').textContent = path;
+ item.querySelector('button').title = `Remove ${name}`;
+
+ this.ui.selectedTerms.appendChild(item);
+ }
+
+ /**
+ * Check selection limits and disable/enable checkboxes
+ */
+ checkSelectionLimits() {
+ if (!this.currentConfig || this.currentConfig.maxSelection === 0) {
+ return;
+ }
+
+ this.disabled = this.selectedTerms.size >= this.currentConfig.maxSelection;
+ this.setCheckboxes(this.disabled);
+ }
+
+ /**
+ * Set checkbox disabled state
+ */
+ setCheckboxes(disabled) {
+ this.ui.termsList.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
+ if (!checkbox.checked) {
+ checkbox.disabled = disabled;
+ }
+ });
+ }
+
+ /**
+ * Save modal selections to field
+ */
+ saveSelectionsToField(fieldId) {
+ const field = this.fields.get(fieldId);
+ if (!field) return;
+
+ // Clear current field selections
+ field.selectedTerms.clear();
+ window.removeChildren(field.selectedContainer);
+
+ // Add modal selections to field
+ this.selectedTerms.forEach((termData, id) => {
+ field.selectedTerms.add(id);
+ this.addTermToDisplay(fieldId, id, termData.name, termData.path);
+ });
+
+ // Update hidden input
+ const selectedIds = Array.from(field.selectedTerms);
+ field.input.value = selectedIds.join(',');
+ field.input.dispatchEvent(new Event('change', { bubbles: true }));
+ }
+
+ /**
+ * Remove selected term from field
+ */
+ removeSelectedTerm(fieldId, termId) {
+ const field = this.fields.get(fieldId);
+ if (!field) return;
+
+ const id = parseInt(termId);
+ field.selectedTerms.delete(id);
+
+ // Remove from display
+ const selectedItem = field.selectedContainer.querySelector(`[data-id="${id}"]`);
+ if (selectedItem) {
+ selectedItem.remove();
+ }
+
+ // Update hidden input
+ const selectedIds = Array.from(field.selectedTerms);
+ field.input.value = selectedIds.join(',');
+ field.input.dispatchEvent(new Event('change', { bubbles: true }));
+ }
+
+ /**
+ * Add term to field display
+ */
+ addTermToDisplay(fieldId, id, name, path) {
+ const field = this.fields.get(fieldId);
+ if (!field || field.selectedContainer.querySelector(`[data-id="${id}"]`)) {
+ return; // Already displayed
+ }
+
+ const item = window.getTemplate('selectedTerm').cloneNode(true);
+ item.dataset.id = id;
+ item.dataset.path = path;
+ item.dataset.name = name;
+ item.dataset.taxonomy = field.taxonomy;
+ item.querySelector('span').textContent = path;
+ item.querySelector('button').title = `Remove ${name}`;
+
+ field.selectedContainer.appendChild(item);
+ }
+
+ /**
+ * Update field from hidden input value
+ */
+ updateFieldFromInput(fieldId) {
+ const field = this.fields.get(fieldId);
+ if (!field) return;
+
+ const value = field.input.value.trim();
+ field.selectedTerms.clear();
+ window.removeChildren(field.selectedContainer);
+
+ if (value !== '') {
+ const selectedIds = value.split(',')
+ .map(id => parseInt(id.trim()))
+ .filter(id => !isNaN(id));
+
+ selectedIds.forEach(id => field.selectedTerms.add(id));
+ this.initFieldDisplay(fieldId);
+ }
+ }
+
+ /**
+ * Handle search input
+ */
+ handleSearch(e) {
+ const query = e.target.value.trim();
+
+ // Clear existing debounce
+ if (this.searchHandler) {
+ clearTimeout(this.searchHandler);
+ }
+
+ this.searchHandler = setTimeout(() => {
+ // Single call - auto-fetches
+ this.store.setFilters({
+ search: query,
+ page: 1,
+ parent: query ? 0 : (this.store.filters.parent || 0)
+ });
+
+ window.removeChildren(this.ui.termsList);
+ }, 300);
+ }
+
+ async handleAutocomplete(e) {
+ if (!('autocomplete' in e.target.dataset)) {
+ return;
+ }
+
+ const fieldId = this.getFieldId(e.target);
+ const field = this.fields.get(fieldId);
+
+ if (!field) return;
+
+ // Store current value immediately (fixes fast typing issue)
+ const query = e.target.value.trim();
+ field.currentAutocompleteQuery = query;
+
+ if (query.length < 2) {
+ if (field.autocompleteDropdown) {
+ field.autocompleteDropdown.hidden = true;
+ }
+ this.isAutocompleteActive = false;
+ return;
+ }
+
+ this.activeField = fieldId;
+ this.isAutocompleteActive = true;
+
+ if (field.autocompleteDropdown) {
+ field.autocompleteDropdown.hidden = false;
+ }
+
+ this.store.setFilters({
+ taxonomy: field.taxonomy,
+ search: query,
+ page: 1
+ });
+ }
+
+ cleanupAutocomplete(e) {
+ if (!('autocomplete' in e.target.dataset)) {
+ return;
+ }
+
+ const fieldId = this.getFieldId(e.target);
+ const field = this.fields.get(fieldId);
+
+ if (!field) return;
+
+ if (this.creator) {
+ delete this.creator;
+ }
+ }
+
+ showAutocompleteError(fieldId) {
+
+ const field = this.fields.get(fieldId);
+ if (!field) {
+ return;
+ }
+ if (!field.config.autocompleteDropdown) {
+ field.config.autocompleteDropdown = field.element.querySelector('.autocomplete-dropdown');
+ }
+ const dropdown = field.config.autocompleteDropdown;
+ if (dropdown) {
+ window.removeChildren(dropdown);
+ this.showEmptyState('Hmmm... something went wrong', dropdown);
+ }
+ }
+
+ showAutocompleteResults(field, terms, query) {
+ if (!field || !field.autocompleteDropdown) {
+ return;
+ }
+
+ const dropdown = field.autocompleteDropdown;
+ window.removeChildren(dropdown);
+
+ if (terms.length === 0) {
+ this.showEmptyState('No items found.', dropdown);
+ } else {
+ terms.forEach(term => {
+ const element = this.createAutocompleteTermElement(field, term);
+ if (element) {
+ dropdown.appendChild(element);
+ }
+ });
+ }
+
+ // Use stored current query instead of debounced one
+ const currentQuery = field.currentAutocompleteQuery || query;
+ if (field.canCreate && currentQuery && window.jvbTaxCreator) {
+ const createOption = this.createNewTermOption(currentQuery);
+ dropdown.appendChild(createOption);
+ }
+
+ dropdown.hidden = false;
+ }
+
+ createNewTermOption(query) {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'autocomplete-item create-term';
+ button.dataset.query = query;
+ button.innerHTML = `<strong>Create:</strong> "${query}"`;
+
+ return button;
+ }
+
+ createAutocompleteTermElement(field, term) {
+ const item = document.createElement('button');
+ item.type = 'button';
+ item.className = 'autocomplete-item';
+ item.dataset.id = term.id;
+ item.dataset.name = term.name;
+ item.dataset.path = term.path || term.name;
+ item.textContent = term.path || term.name;
+
+ item.addEventListener('click', () => {
+ // Add term to field
+ field.selectedTerms.add(parseInt(term.id));
+ this.addTermToDisplay(field.id, term.id, term.name, term.path);
+
+ // Update input
+ field.input.value = Array.from(field.selectedTerms).join(',');
+ field.input.dispatchEvent(new Event('change', { bubbles: true }));
+
+ // Clear and hide dropdown
+ field.autocompleteDropdown.hidden = true;
+ const input = field.container.querySelector('input[data-autocomplete]');
+ if (input) input.value = '';
+ });
+
+ return item;
+ }
+
+ /**
+ * Navigate to parent term
+ */
+ navigateToParent() {
+ // Store handles fetch automatically
+ this.store.setFilters({
+ parent: 0,
+ page: 1
+ });
+
+ window.removeChildren(this.ui.termsList);
+ this.ui.breadcrumbs.back.hidden = true;
+ }
+
+ /**
+ * Navigate to child term
+ */
+ navigateToChild(termId, termName) {
+ // Store handles fetch automatically
+ this.store.setFilters({
+ parent: termId,
+ page: 1
+ });
+
+ window.removeChildren(this.ui.termsList);
+ this.updateBreadcrumbs(termId, termName);
+ this.ui.breadcrumbs.back.hidden = false;
+ }
+
+ /**
+ * Navigate to specific path level
+ */
+ navigateToPath(pathLevel) {
+ const parentId = parseInt(pathLevel.dataset.id) || 0;
+
+ // Store handles fetch automatically
+ this.store.setFilters({
+ parent: parentId,
+ page: 1
+ });
+
+ window.removeChildren(this.ui.termsList);
+ this.ui.breadcrumbs.back.hidden = parentId === 0;
+ }
+
+ /**
+ * Load more terms (pagination)
+ */
+ loadMoreTerms() {
+ const currentPage = this.store.filters.page || 1;
+ this.store.setFilter('page', currentPage + 1);
+ }
+
+ /**
+ * Render terms list
+ */
+ renderTerms(terms = null, append = false, showPath = false) {
+ // If no terms provided, get from store
+ if (!terms) {
+ terms = this.store.getFiltered();
+ }
+
+ if (!append) {
+ window.removeChildren(this.ui.termsList);
+ }
+
+ if (terms.length === 0) {
+ if (!append) {
+ this.showEmptyState();
+ }
+ return;
+ }
+
+ const currentParent = this.store.filters.parent || 0;
+ this.ui.breadcrumbs.back.hidden = currentParent === 0;
+
+ const fragment = document.createDocumentFragment();
+ terms.forEach(term => {
+ const element = this.createTermElement({
+ id: parseInt(term.id),
+ name: term.name,
+ hasChildren: term.hasChildren,
+ path: term.path || null,
+ show: showPath
+ });
+
+ if (element) {
+ fragment.appendChild(element);
+ }
+ });
+
+ this.ui.termsList.appendChild(fragment);
+ }
+
+ /**
+ * Create individual term element
+ */
+ createTermElement(termData) {
+ if (!termData || !termData.name) return null;
+
+ const listItem = window.getTemplate('termListItem').cloneNode(true);
+ listItem.dataset.id = termData.id;
+
+ const isSelected = this.selectedTerms.has(termData.id);
+ const checkbox = listItem.querySelector('input');
+ const label = listItem.querySelector('label');
+ const nameSpan = listItem.querySelector('span, .term-name');
+
+ if (checkbox && label && nameSpan) {
+ checkbox.id = `${this.currentConfig.container.id}${termData.id}`;
+ checkbox.name = `${this.currentConfig.container.id}${this.currentConfig.taxonomy}-select`;
+ checkbox.value = termData.id;
+ checkbox.disabled = !isSelected && this.disabled;
+ checkbox.checked = isSelected;
+
+ label.htmlFor = checkbox.id;
+ label.title = termData.path || termData.name;
+ label.dataset.path = termData.path;
+
+ nameSpan.textContent = termData.show ? termData.path : termData.name;
+ }
+
+ if (termData.hasChildren) {
+ const childrenToggle = window.getTemplate ?
+ window.getTemplate('termChildrenToggle') :
+ this.createChildrenToggle();
+
+ if (childrenToggle) {
+ childrenToggle.ariaLabel = `View sub-terms of ${termData.name}`;
+ listItem.appendChild(childrenToggle);
+ }
+ }
+
+ return listItem;
+ }
+
+ /**
+ * Create children toggle button
+ */
+ createChildrenToggle() {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'toggle-children';
+ button.innerHTML = '→';
+ return button;
+ }
+
+ /**
+ * Update breadcrumb navigation
+ */
+ updateBreadcrumbs(termId, termName) {
+ // This is a simplified version - you'd want to maintain a proper breadcrumb trail
+ const breadcrumb = window.getTemplate('termBreadcrumb').cloneNode(true);
+ breadcrumb.dataset.id = termId;
+ breadcrumb.textContent = termName;
+ breadcrumb.title = termName;
+
+ // Remove any existing breadcrumbs after this level
+ const existingCrumb = this.ui.breadcrumbs.nav.querySelector(`[data-id="${termId}"]`);
+ if (existingCrumb) {
+ // Remove all breadcrumbs after this one
+ while (existingCrumb.nextElementSibling) {
+ existingCrumb.nextElementSibling.remove();
+ }
+ } else {
+ this.ui.breadcrumbs.nav.appendChild(breadcrumb);
+ }
+ }
+
+ /**
+ * Show loading state
+ */
+ showLoading() {
+ this.ui.loading.loading.hidden = false;
+ this.modal.classList.add('loading');
+
+ const searchQuery = this.store?.filters?.search || '';
+ const currentParent = this.store?.filters?.parent || 0;
+
+ let message = searchQuery !== '' ?
+ `searching for "${searchQuery}" items` :
+ currentParent === 0 ?
+ 'loading items' :
+ `loading child items`;
+
+ if (window.typeLoop) {
+ this.stopTyping = window.typeLoop(this.ui.loading.text, message);
+ } else {
+ this.ui.loading.text.textContent = message;
+ }
+ }
+
+ /**
+ * Hide loading state
+ */
+ hideLoading() {
+ this.ui.loading.loading.hidden = true;
+ this.modal.classList.remove('loading');
+
+ if (this.stopTyping) {
+ this.stopTyping();
+ }
+ }
+
+ /**
+ * Show empty state message
+ */
+ showEmptyState(message = 'No items found.', container = null) {
+ if (!container) {
+ container = this.ui.termsList;
+ }
+ const emptyElement = window.getTemplate('noResults').cloneNode(true);
+
+ if (message && emptyElement.querySelector('span')) {
+ emptyElement.querySelector('span').textContent = message;
+ }
+
+ container.appendChild(emptyElement);
+ }
+
+ /**
+ * Get field ID from any element within the field
+ */
+ getFieldId(element) {
+ if (element.dataset.fieldId) {
+ return element.dataset.fieldId;
+ }
+
+ const fieldContainer = element.closest('[data-field-id]');
+ if (fieldContainer) {
+ return fieldContainer.dataset.fieldId;
+ }
+
+ return null;
+ }
+ /********************************************
+ BATCH FETCH: fetches first page for all taxonomies in one call
+ ********************************************/
+ async batchFetchTaxonomies() {
+ if (this.taxonomiesToFetch.size === 0) return;
+
+ const taxonomies = Array.from(this.taxonomiesToFetch);
+ this.taxonomiesToFetch.clear();
+
+ // Single fetch - the data-loaded event will handle cache splitting
+ this.store.setFilters({
+ taxonomy: taxonomies.join(','),
+ page: 1,
+ search: '',
+ parent: 0
+ });
+ }
+ handleBatchDataLoaded(taxonomyString, data) {
+ const taxonomies = taxonomyString.split(',').map(t => t.trim());
+ const storeInstance = this.store.getStore(); // Access actual store instance
+
+ taxonomies.forEach(taxonomy => {
+ const filters = {
+ taxonomy: taxonomy,
+ page: 1,
+ search: '',
+ parent: 0
+ };
+
+ // Use the internal generateCacheKey method via store instance
+ const cacheKey = this.generateCacheKeyForFilters(filters);
+
+ // Filter items for this specific taxonomy
+ const items = Array.from(this.store.data.values())
+ .filter(item => item.taxonomy === taxonomy)
+ .map(item => item.id);
+
+ const cacheEntry = {
+ key: cacheKey,
+ items: items,
+ timestamp: Date.now(),
+ endpoint: storeInstance.config.endpoint,
+ filters: filters
+ };
+
+ // Set in both memory and IndexedDB cache
+ storeInstance.cache.set(cacheKey, cacheEntry);
+
+ // Persist to IndexedDB (if available)
+ if (storeInstance.db?.objectStoreNames.contains('cache')) {
+ const tx = storeInstance.db.transaction(['cache'], 'readwrite');
+ const objectStore = tx.objectStore('cache');
+ objectStore.put(cacheEntry);
+ }
+
+ // Update button states for this taxonomy
+ this.updateFieldsForTaxonomy(taxonomy);
+ });
+
+ // Initialize field displays
+ this.fields.forEach((config, fieldId) => {
+ if (config.selectedTerms.size > 0) {
+ this.initFieldDisplay(fieldId);
+ }
+ });
+ }
+
+ /**
+ * Generate cache key for given filters (matching DataStore's internal logic)
+ */
+ generateCacheKeyForFilters(filters) {
+ const normalized = Object.keys(filters)
+ .sort()
+ .reduce((acc, key) => {
+ acc[key] = filters[key];
+ return acc;
+ }, {});
+
+ return JSON.stringify(normalized);
+ }
+
+ /**
+ * Preload taxonomy data on hover
+ */
+ async preloadTaxonomy(taxonomy) {
+ // Trigger fetch for this taxonomy
+ this.store.setFilters({
+ taxonomy: taxonomy,
+ page: 1,
+ search: '',
+ parent: 0
+ });
+ }
+ /*****************************************
+ SUBSCRIBERS
+ *****************************************/
+
+ subscribe(callback) {
+ this.subscribers.add(callback);
+ return () => this.subscribers.delete(callback);
+ }
+
+ notify(event, data = {}) {
+ this.subscribers.forEach( callback => {
+ try {
+ callback(event, data);
+ } catch (error) {
+ console.error('Subscriber error:', error);
+ }
+ });
+ }
+
+ /**
+ * Clean up
+ */
+ destroy() {
+ // Remove event listeners
+ document.removeEventListener('click', this.handleClick);
+ document.removeEventListener('change', this.handleChange);
+
+ // Clear intervals and cleanup
+ this.observer?.disconnect();
+
+ // Destroy all stores
+ this.store.destroy();
+
+ this.subscribers.clear();
+ // Clear all maps
+ this.fields.clear();
+ this.selectedTerms.clear();
+ }
+}
+
+/**
+ * Initialize singleton
+ */
+document.addEventListener('DOMContentLoaded', function() {
+ window.auth.subscribe((event) => {
+ if (event === 'auth-loaded') {
+ window.jvbSelector = new TaxonomySelectorOld();
+ }
+ });
+
+});
diff --git a/assets/js/concise/TempForm.js b/assets/js/concise/TempForm.js
deleted file mode 100644
index f17a757..0000000
--- a/assets/js/concise/TempForm.js
+++ /dev/null
@@ -1,385 +0,0 @@
-/**
- * Simplified autosave approach for FormController
- * Leverages QueueManager for all server operations
- */
-
-// In FormController class - replace the autosave-related methods with:
-
-class FormController {
- // ... existing constructor and init code ...
-
- /**
- * Initialize form tracking for autosave
- */
- registerForm(formElement, options = {}) {
- const formId = formElement.dataset.formId || `form_${Date.now()}`;
- formElement.dataset.formId = formId;
-
- const formConfig = {
- element: formElement,
- id: formId,
- contentId: formElement.dataset.contentId || null, // For existing content
- contentType: formElement.dataset.contentType || null,
- options: {
- autoSave: true,
- saveDelay: this.autoSaveDefaults.delay,
- endpoint: formElement.dataset.save || formElement.action,
- ...options
- },
- lastSnapshot: {}, // Last saved state
- isDirty: false
- };
-
- // Take initial snapshot
- formConfig.lastSnapshot = this.collectFormData(formElement);
-
- // Initialize special fields
- this.initializeFormFields(formElement, formConfig);
-
- // Store form config
- this.forms.set(formId, formConfig);
-
- return formConfig;
- }
-
- /**
- * Simplified change handler
- */
- handleChange(event) {
- const target = event.target;
- const form = target.form || target.closest('form');
- if (!form) return;
-
- const formConfig = this.forms?.get(form.dataset.formId);
- if (!formConfig) return;
-
- // Check conditional fields (existing functionality)
- const dependencies = formConfig.dependencies?.get(target.name);
- if (dependencies) {
- dependencies.forEach(dep => {
- this.checkFieldDependency(form, dep.field, target.name, dep.requiredValue, dep.operator);
- });
- }
-
- // Schedule autosave if enabled
- if (formConfig.options.autoSave && !form.dataset.noautosave) {
- const delay = this.getDelayForField(target);
- this.scheduleSave(formConfig, delay);
- }
- }
-
- /**
- * Get appropriate delay based on field type and context
- */
- getDelayForField(field) {
- // Text fields get longer delay for typing
- if (field.type === 'text' || field.type === 'textarea') {
- return this.autoSaveDefaults.typingDelay;
- }
-
- // Checkboxes, radios, selects get shorter delay
- if (['checkbox', 'radio', 'select-one', 'select-multiple'].includes(field.type)) {
- return 1000;
- }
-
- // Default delay
- return this.autoSaveDefaults.delay;
- }
-
- /**
- * Simplified scheduleSave - just debounces the queue addition
- */
- scheduleSave(formConfig, delay = this.autoSaveDefaults.delay) {
- const saveKey = `autosave_${formConfig.id}`;
-
- this.debouncer.schedule(
- saveKey,
- () => this.queueAutosave(formConfig),
- delay
- );
- }
-
- /**
- * Queue the autosave operation
- */
- async queueAutosave(formConfig) {
- const currentData = this.collectFormData(formConfig.element);
- const changes = this.getChangedFields(formConfig.lastSnapshot, currentData);
-
- // No changes? Don't save
- if (Object.keys(changes).length === 0) return;
-
- // For bulk edit forms, handle specially
- if (formConfig.element.classList.contains('bulk-edit')) {
- this.queueBulkSave(formConfig, changes);
- return;
- }
-
- // Build the queue operation
- const operation = {
- endpoint: formConfig.options.endpoint || '/wp-json/jvb/v1/autosave',
- method: formConfig.contentId ? 'PUT' : 'POST',
- data: {
- form_id: formConfig.id,
- content_id: formConfig.contentId,
- content_type: formConfig.contentType,
- changes: changes,
- full_data: currentData
- },
- title: `Autosaving ${formConfig.contentType || 'form'}`,
- popup: null, // No popup for autosave
- headers: {
- 'X-Autosave': 'true'
- },
- source: 'form',
- formId: formConfig.id,
- // For optimistic updates
- localUpdate: formConfig.contentId ? {
- action: 'update',
- id: formConfig.contentId,
- changes: changes
- } : null,
- dataStore: this.store
- };
-
- // Add to queue
- const queueItem = await this.queue.addToQueue(operation);
-
- if (queueItem) {
- // Update snapshot to prevent re-saving same changes
- formConfig.lastSnapshot = currentData;
- formConfig.isDirty = false;
-
- // Visual feedback
- this.showFormStatus(formConfig.element, 'queued');
-
- // Track the operation
- this.trackOperation(formConfig, queueItem.id);
- }
- }
-
- /**
- * Handle bulk edit forms specially
- */
- queueBulkSave(formConfig, changes) {
- const selectedItems = formConfig.element.querySelectorAll('.bulk-item input:checked');
- const itemIds = Array.from(selectedItems).map(cb => cb.value);
-
- if (itemIds.length === 0) return;
-
- const operation = {
- endpoint: formConfig.options.endpoint || '/wp-json/jvb/v1/bulk-update',
- method: 'PUT',
- data: {
- content_type: formConfig.contentType,
- item_ids: itemIds,
- changes: changes
- },
- title: `Updating ${itemIds.length} items`,
- popup: `${itemIds.length} items queued for update`,
- headers: {
- 'X-Bulk-Operation': 'true'
- },
- source: 'form',
- formId: formConfig.id,
- // Optimistic update for each item
- localUpdate: {
- action: 'bulk-update',
- ids: itemIds,
- changes: changes
- },
- dataStore: this.store
- };
-
- // Add to queue
- const queueItem = this.queue.addToQueue(operation);
-
- if (queueItem) {
- formConfig.lastSnapshot = this.collectFormData(formConfig.element);
- formConfig.isDirty = false;
- this.showFormStatus(formConfig.element, 'queued');
- }
- }
-
- /**
- * Track operations for forms
- */
- trackOperation(formConfig, operationId) {
- if (!formConfig.operations) {
- formConfig.operations = new Set();
- }
- formConfig.operations.add(operationId);
-
- // Subscribe to queue updates for this operation
- const unsubscribe = this.queue.subscribe((event, data) => {
- if (data?.id !== operationId) return;
-
- switch(event) {
- case 'operation-status':
- this.updateFormStatus(formConfig, data.status);
- break;
-
- case 'operation-completed':
- this.handleSaveSuccess(formConfig, data);
- formConfig.operations.delete(operationId);
- unsubscribe();
- break;
-
- case 'operation-failed':
- this.handleSaveFailure(formConfig, data);
- formConfig.operations.delete(operationId);
- unsubscribe();
- break;
- }
- });
- }
-
- /**
- * Update form status based on queue status
- */
- updateFormStatus(formConfig, status) {
- const statusMap = {
- 'queued': 'queued',
- 'uploading': 'saving',
- 'processing': 'saving',
- 'pending': 'saving',
- 'completed': 'saved',
- 'failed': 'error',
- 'failed_permanent': 'error'
- };
-
- this.showFormStatus(formConfig.element, statusMap[status] || status);
- }
-
- /**
- * Handle successful save from queue
- */
- handleSaveSuccess(formConfig, data) {
- // Update content ID if this was a create operation
- if (!formConfig.contentId && data.result?.id) {
- formConfig.contentId = data.result.id;
- formConfig.element.dataset.contentId = data.result.id;
- }
-
- // Reset dirty flag
- formConfig.isDirty = false;
-
- // Show success
- this.showFormStatus(formConfig.element, 'saved');
-
- // Notify subscribers
- this.notify('form-saved', {
- formId: formConfig.id,
- contentId: formConfig.contentId,
- data: data
- });
- }
-
- /**
- * Handle save failure from queue
- */
- handleSaveFailure(formConfig, data) {
- // Mark as dirty so it will retry
- formConfig.isDirty = true;
-
- // Show error
- this.showFormStatus(formConfig.element, 'error');
-
- // Notify subscribers
- this.notify('form-save-failed', {
- formId: formConfig.id,
- error: data.lastError
- });
- }
-
- /**
- * Manual save (for submit button)
- */
- async handleSubmit(event) {
- const form = event.target;
- if (!form.dataset.formId) return;
-
- event.preventDefault();
-
- const formConfig = this.forms.get(form.dataset.formId);
- if (!formConfig) return;
-
- // Force immediate save
- this.debouncer.cancel(`autosave_${formConfig.id}`);
-
- // Add to queue with higher priority
- const currentData = this.collectFormData(form);
-
- const operation = {
- endpoint: formConfig.options.endpoint,
- method: formConfig.contentId ? 'PUT' : 'POST',
- data: {
- content_id: formConfig.contentId,
- content_type: formConfig.contentType,
- ...currentData
- },
- title: `Saving ${formConfig.contentType || 'form'}`,
- popup: 'Saved successfully',
- priority: 'high', // Process before autosaves
- source: 'form',
- formId: formConfig.id,
- localUpdate: formConfig.contentId ? {
- action: 'update',
- id: formConfig.contentId,
- changes: currentData
- } : {
- action: 'create',
- data: currentData
- },
- dataStore: this.store
- };
-
- const queueItem = await this.queue.addToQueue(operation);
-
- if (queueItem) {
- formConfig.lastSnapshot = currentData;
- this.trackOperation(formConfig, queueItem.id);
- this.showFormStatus(form, 'saving');
- }
- }
-
- /**
- * Check if form has unsaved changes
- */
- hasUnsavedChanges(formId) {
- const formConfig = this.forms.get(formId);
- if (!formConfig) return false;
-
- // Check if there are pending operations
- if (formConfig.operations?.size > 0) return true;
-
- // Check if current data differs from snapshot
- const currentData = this.collectFormData(formConfig.element);
- const changes = this.getChangedFields(formConfig.lastSnapshot, currentData);
-
- return Object.keys(changes).length > 0;
- }
-
- /**
- * Cleanup when form is closed/destroyed
- */
- cleanupForm(formId) {
- const formConfig = this.forms.get(formId);
- if (!formConfig) return;
-
- // Cancel any pending debounced saves
- this.debouncer.cancel(`autosave_${formId}`);
-
- // Check for unsaved changes
- if (this.hasUnsavedChanges(formId)) {
- // Could show a warning or auto-save
- this.queueAutosave(formConfig);
- }
-
- // Clean up special fields
- this.cleanupSpecialFields();
-
- // Remove form config
- this.forms.delete(formId);
- }
-}
diff --git a/assets/js/concise/TempQueue.js b/assets/js/concise/TempQueue.js
deleted file mode 100644
index e69de29..0000000
--- a/assets/js/concise/TempQueue.js
+++ /dev/null
diff --git a/assets/js/concise/TempUploads.js b/assets/js/concise/TempUploads.js
deleted file mode 100644
index e69de29..0000000
--- a/assets/js/concise/TempUploads.js
+++ /dev/null
diff --git a/assets/js/concise/UploadManagerC.js b/assets/js/concise/UploadManagerC.js
deleted file mode 100644
index 4e755ea..0000000
--- a/assets/js/concise/UploadManagerC.js
+++ /dev/null
@@ -1,2278 +0,0 @@
-/**
- * UploadManager - Refactored with simplified store architecture
- *
- * Architecture:
- * - uploadStore: Individual uploads with blob data, keyed by uploadId
- * - groupStore: Group metadata + upload references, keyed by groupId
- * - Runtime Maps: DOM references only (fieldElements, uploadElements, groupElements)
- *
- * Flow: File → Process → Store → Queue → Server → Clear stores
- * Recovery: Check stores on load → Show notification → Restore to DOM
- */
-class UploadManager {
- constructor() {
- this.queue = window.jvbQueue;
- this.a11y = window.jvbA11y;
- this.error = window.jvbError;
-
- // Store initialization flags
- this.storesReady = false;
- this.hasCheckedForRecovery = false;
-
- // Register stores
- const { uploads, groups } = window.jvbStore.register(
- 'uploads',
- [
- {
- storeName: 'uploads',
- keyPath: 'id',
- storeBlobs: true,
- indexes: [
- { name: 'fieldId', keyPath: 'fieldId' },
- { name: 'status', keyPath: 'status' },
- { name: 'groupId', keyPath: 'groupId' },
- { name: 'pageUrl', keyPath: 'pageUrl' }
- ],
- TTL: 7 * 24 * 60 * 60 * 1000, // 1 week
- delayFetch: true
- },
- {
- storeName: 'groups',
- keyPath: 'id',
- indexes: [
- { name: 'fieldId', keyPath: 'fieldId' },
- { name: 'pageUrl', keyPath: 'pageUrl' }
- ],
- TTL: 7 * 24 * 60 * 60 * 1000,
- delayFetch: true
- }
- ]
- );
-
- this.uploadStore = uploads;
- this.groupStore = groups;
-
- // Subscribe to store events
- this.uploadStore.subscribe(this.handleStoreEvent.bind(this, 'uploads'));
- this.groupStore.subscribe(this.handleStoreEvent.bind(this, 'groups'));
-
- // RUNTIME DATA - DOM references only, not persisted
- this.fieldElements = new Map(); // fieldId → { element, ui, config }
- this.uploadElements = new Map(); // uploadId → { element, preview, location }
- this.groupElements = new Map(); // groupId → { element, grid, fieldId }
-
- // Selection state
- this.selected = new Map(); // fieldId -> { }
- this.selectionHandlers = new Map();
- this.sortableInstances = new Map();
-
- // Preview URL tracking for cleanup
- this.previewUrls = new Set();
-
- // Worker for image processing
- this.worker = this.createWorkerConfig();
-
- // Notification subscribers
- this.subscribers = new Set();
-
- // Selectors
- this.selectors = {
- field: {
- field: '[data-upload-field]',
- input: 'input[type="file"]',
- dropZone: '.file-upload-container',
- preview: '.item-grid.preview',
- progress: '.image-progress'
- },
- groups: {
- container: '.upload-group',
- grid: '.item-grid.group',
- header: '.group-header',
- selectAll: '[name="select-all-group"]',
- actions: '.group-actions',
- count: '.selection-controls .info'
- },
- items: {
- item: '[data-upload-id]',
- checkbox: '[name*="select-item"]',
- featured: '[name="featured"]',
- details: 'details'
- }
- };
-
- this.statusMapping = {
- 'received': 'Image Received',
- 'processing': 'Processing Image...',
- 'queued': 'Waiting to upload...',
- 'uploading': 'Uploading to Server',
- 'pending': 'Sent to server, awaiting processing.',
- 'server_processing': 'Processing on server...',
- 'completed': 'Upload complete!',
- 'failed': 'Upload failed (will retry)',
- 'failed_permanent': 'Upload failed permanently'
- };
-
- this.init();
- }
-
- /*******************************************************************************
- * INITIALIZATION
- *******************************************************************************/
-
- async init() {
- this.initListeners();
- this.initQueueSubscription();
-
- window.addEventListener('beforeunload', () => this.cleanupAllPreviewUrls());
- }
-
- createWorkerConfig() {
- return {
- worker: null,
- tasks: new Map(),
- restart: { count: 0, max: 3 },
- settings: {
- timeout: 10000,
- maxConcurrent: 3,
- restartAfterTimeout: true
- }
- };
- }
-
- initQueueSubscription() {
- this.queue.subscribe((event, operation) => {
- if (!['uploads', 'uploads/meta', 'uploads/groups'].includes(operation.endpoint)) {
- return;
- }
-
- const fieldId = operation.data instanceof FormData
- ? operation.data.get('fieldId')
- : operation.data?.fieldId;
-
- switch (event) {
- case 'cancel-operation':
- if (fieldId) this.handleOperationCancelled(fieldId);
- break;
- case 'operation-status':
- if (fieldId) this.updateFieldStatus(fieldId, operation.status);
- break;
- case 'operation-complete':
- this.handleOperationComplete(operation, fieldId);
- break;
- case 'operation-failed':
- case 'operation-failed-permanent':
- this.handleOperationFailed(operation, fieldId);
- break;
- }
- });
- }
-
- /*******************************************************************************
- * STORE EVENT HANDLING & RECOVERY
- *******************************************************************************/
-
- handleStoreEvent(storeName, event, data) {
- if (event === 'data-loaded') {
- this.checkStoresReady();
- }
- }
-
- checkStoresReady() {
- // Both stores need to be ready before checking for recovery
- const uploadsReady = this.uploadStore.getStore()._initialized;
- const groupsReady = this.groupStore.getStore()._initialized;
-
- if (uploadsReady && groupsReady && !this.hasCheckedForRecovery) {
- this.hasCheckedForRecovery = true;
- this.storesReady = true;
- this.checkForRecoverableUploads();
- }
- }
-
- async checkForRecoverableUploads() {
- const allUploads = this.uploadStore.getAll();
-
- // Find uploads that weren't completed and don't have an active operation
- const recoverableUploads = allUploads.filter(upload =>
- !upload.operationId &&
- ['processed', 'processing', 'queued'].includes(upload.status)
- );
-
- if (recoverableUploads.length === 0) return;
-
- // Group by fieldId for display
- const byField = this.groupUploadsByField(recoverableUploads);
- await this.showRecoveryNotification(byField);
- }
-
- groupUploadsByField(uploads) {
- const byField = new Map();
-
- uploads.forEach(upload => {
- if (!byField.has(upload.fieldId)) {
- byField.set(upload.fieldId, {
- fieldId: upload.fieldId,
- pageUrl: upload.pageUrl,
- uploads: [],
- groups: new Map()
- });
- }
-
- const fieldData = byField.get(upload.fieldId);
- fieldData.uploads.push(upload);
-
- // Track groups
- if (upload.groupId) {
- const group = this.groupStore.get(upload.groupId);
- if (group) {
- fieldData.groups.set(upload.groupId, group);
- }
- }
- });
-
- return byField;
- }
-
- /*******************************************************************************
- * FIELD MANAGEMENT - Runtime only, no persistence
- *******************************************************************************/
-
- scanFields(container, autoUpload = false) {
- const fields = container.querySelectorAll(this.selectors.field.field);
- fields.forEach(uploader => this.registerUploader(uploader, autoUpload));
- }
-
- registerUploader(uploader, autoUpload = false) {
- const fieldId = this.determineFieldId(uploader);
- const config = this.extractFieldConfig(uploader, autoUpload);
- const ui = this.buildFieldUI(uploader);
-
- // Store DOM references only - not persisted
- this.fieldElements.set(fieldId, { element: uploader, ui, config });
-
- uploader.dataset.uploader = fieldId;
- this.addFieldSelectionHandler(fieldId);
-
- if (config.type !== 'single') {
- this.initSortable(fieldId);
- }
-
- return fieldId;
- }
-
- extractFieldConfig(fieldElement, autoUpload) {
- return {
- autoUpload,
- destination: fieldElement.dataset.destination || 'meta',
- content: fieldElement.dataset.content || null,
- mode: fieldElement.dataset.mode || 'direct',
- type: fieldElement.dataset.type || 'single',
- name: fieldElement.dataset.field,
- itemID: fieldElement.dataset.itemId || 0,
- maxFiles: parseInt(fieldElement.dataset.maxFiles) || 999,
- subtype: fieldElement.dataset.subtype || 'image'
- };
- }
-
- buildFieldUI(fieldElement) {
- const UI = {
- field: fieldElement,
- input: fieldElement.querySelector(this.selectors.field.input),
- dropZone: fieldElement.querySelector(this.selectors.field.dropZone),
- preview: fieldElement.querySelector(this.selectors.field.preview),
- progress: {
- container: fieldElement.querySelector(this.selectors.field.progress),
- bar: fieldElement.querySelector('.bar'),
- fill: fieldElement.querySelector('.fill'),
- details: fieldElement.querySelector('.details'),
- text: fieldElement.querySelector('.details .text'),
- count: fieldElement.querySelector('.details .count')
- }
- };
-
- const display = fieldElement.querySelector('.group-display');
- if (display) {
- UI.groups = {
- display,
- container: fieldElement.querySelector('.item-grid.groups'),
- empty: fieldElement.querySelector('.empty-group'),
- groups: new Map()
- };
- }
-
- return UI;
- }
-
- /**
- * Get uploads for a field - derived from store, not cached
- */
- getFieldUploads(fieldId) {
- return this.uploadStore.getAll().filter(u => u.fieldId === fieldId);
- }
-
- /**
- * Get groups for a field - derived from store
- */
- getFieldGroups(fieldId) {
- return this.groupStore.getAll().filter(g => g.fieldId === fieldId);
- }
-
- /**
- * Get upload count for a field
- */
- getFieldUploadCount(fieldId) {
- return this.getFieldUploads(fieldId).length;
- }
-
- /*******************************************************************************
- * FILE PROCESSING
- *******************************************************************************/
-
- async processFiles(fieldId, files) {
- const fieldEl = this.fieldElements.get(fieldId);
- if (!fieldEl) return;
-
- const { config, ui } = fieldEl;
-
- // Show group display, hide upload zone
- if (ui.dropZone) ui.dropZone.hidden = true;
- if (ui.groups?.display) ui.groups.display.hidden = false;
-
- const totalFiles = files.length;
- let processedCount = 0;
-
- this.updateFieldProgress(fieldId, 0, totalFiles, 'Processing files...');
-
- const processPromises = Array.from(files).map(async (file) => {
- try {
- const uploadId = this.generateId('upload');
-
- // Create upload data
- const uploadData = {
- id: uploadId,
- fieldId,
- pageUrl: window.location.href,
- status: 'processing',
- groupId: null,
- attachmentId: null,
- meta: {
- originalName: file.name,
- size: file.size,
- type: file.type
- }
- };
-
- // Save initial state
- await this.uploadStore.save(uploadData);
-
- // Process file
- const preview = this.createPreviewUrl(file);
- const processedFile = file.type.startsWith('image/')
- ? await this.processImage(file, uploadId)
- : file;
-
- // Show progress
- this.showUploadProgress(uploadId, true);
- this.updateUploadItemProgress(uploadId, 50, 'processing');
-
- // Store blob data
- await this.saveBlobData(uploadId, processedFile || file);
-
- // Create DOM element
- const subtype = this.getSubtypeFromMime(file.type);
- const element = this.createUploadElement({
- id: uploadId,
- preview,
- meta: uploadData.meta,
- subtype
- }, config.destination === 'post_group');
-
- // Add to preview grid
- if (ui.preview) {
- ui.preview.appendChild(element);
- this.uploadElements.set(uploadId, { element, preview, location: ui.preview });
- }
-
- // Update status
- await this.updateUploadStatus(uploadId, 'processed');
-
- // Update progress
- processedCount++;
- this.updateFieldProgress(fieldId, processedCount, totalFiles, 'Processing files...');
- this.updateUploadItemProgress(uploadId, 100, 'processed');
-
- setTimeout(() => this.showUploadProgress(uploadId, false), 1000);
-
- return uploadId;
-
- } catch (error) {
- console.error('Error processing file:', file.name, error);
- processedCount++;
- this.updateFieldProgress(fieldId, processedCount, totalFiles, 'Processing files...');
- return null;
- }
- });
-
- await Promise.all(processPromises);
-
- this.updateFieldState(fieldId);
- this.refreshSortable(fieldId);
-
- // Queue for upload if auto-upload enabled
- if (config.autoUpload && config.destination !== 'post_group') {
- await this.queueUpload(fieldId);
- this.maybeLockUploads(fieldId);
- }
- }
-
- /*******************************************************************************
- * IMAGE PROCESSING (unchanged logic, just cleaner structure)
- *******************************************************************************/
-
- async processImage(file, uploadId) {
- const timeout = this.worker.settings.timeout;
-
- return new Promise((resolve, reject) => {
- let timeoutId;
- let completed = false;
-
- timeoutId = setTimeout(() => {
- if (!completed) {
- completed = true;
- this.worker.tasks.delete(uploadId);
- if (this.worker.settings.restartAfterTimeout) {
- this.restartWorker();
- }
- reject(new Error(`Processing timeout for ${file.name}`));
- }
- }, timeout);
-
- this.worker.tasks.set(uploadId, { file, timeoutId });
-
- this.handleImageProcess(file, uploadId)
- .then(result => {
- if (!completed) {
- completed = true;
- clearTimeout(timeoutId);
- this.worker.tasks.delete(uploadId);
- resolve(result);
- }
- })
- .catch(error => {
- if (!completed) {
- completed = true;
- clearTimeout(timeoutId);
- this.worker.tasks.delete(uploadId);
- reject(error);
- }
- });
- });
- }
-
- async handleImageProcess(file, uploadId) {
- if (!file.type.startsWith('image/')) return file;
-
- const maxDimension = this.getMaxDimension();
- const quality = 0.95;
-
- if (this.shouldUseWorker(file)) {
- try {
- if (!this.worker.worker) this.initWorker();
- if (this.worker.worker) {
- return await this.processWithWorker(file, uploadId, maxDimension, quality);
- }
- } catch (error) {
- console.warn('Worker failed, using main thread:', error);
- }
- }
-
- return this.processOnMainThread(file, maxDimension, quality);
- }
-
- async processOnMainThread(file, maxDimension, quality) {
- return new Promise((resolve, reject) => {
- const img = new Image();
- const canvas = document.createElement('canvas');
- const ctx = canvas.getContext('2d');
- let objectUrl = null;
-
- const cleanup = () => {
- img.onload = img.onerror = null;
- if (objectUrl) {
- URL.revokeObjectURL(objectUrl);
- objectUrl = null;
- }
- canvas.width = canvas.height = 1;
- ctx.clearRect(0, 0, 1, 1);
- };
-
- img.onload = () => {
- try {
- const { width, height } = this.calculateDimensions(img, maxDimension);
- canvas.width = width;
- canvas.height = height;
-
- ctx.imageSmoothingEnabled = true;
- ctx.imageSmoothingQuality = 'high';
- ctx.drawImage(img, 0, 0, width, height);
-
- const outputFormat = this.getOptimalFormat(file);
- const outputQuality = this.getOptimalQuality(file, quality);
-
- canvas.toBlob(
- (blob) => {
- cleanup();
- if (blob) {
- resolve(new File(
- [blob],
- this.getProcessedFileName(file, outputFormat),
- { type: outputFormat, lastModified: Date.now() }
- ));
- } else {
- reject(new Error('Canvas toBlob failed'));
- }
- },
- outputFormat,
- outputQuality
- );
- } catch (error) {
- cleanup();
- reject(new Error(`Canvas processing failed: ${error.message}`));
- }
- };
-
- img.onerror = () => {
- cleanup();
- reject(new Error(`Failed to load image: ${file.name}`));
- };
-
- try {
- objectUrl = this.createPreviewUrl(file);
- img.src = objectUrl;
- } catch (error) {
- cleanup();
- reject(new Error(`Failed to create object URL: ${error.message}`));
- }
- });
- }
-
- // Worker methods (simplified)
- initWorker() {
- if (this.worker.worker || typeof Worker === 'undefined') return;
-
- try {
- const workerScript = `
- self.onmessage = async function(e) {
- const { messageId, file, maxDimension, quality, outputFormat } = e.data;
- try {
- const bitmap = await createImageBitmap(file);
- const scale = Math.min(maxDimension / bitmap.width, maxDimension / bitmap.height, 1);
- const width = Math.round(bitmap.width * scale);
- const height = Math.round(bitmap.height * scale);
- const canvas = new OffscreenCanvas(width, height);
- const ctx = canvas.getContext('2d');
- ctx.imageSmoothingEnabled = true;
- ctx.imageSmoothingQuality = 'high';
- ctx.drawImage(bitmap, 0, 0, width, height);
- bitmap.close();
- const blob = await canvas.convertToBlob({ type: outputFormat, quality });
- self.postMessage({ messageId, success: true, blob, format: outputFormat });
- } catch (error) {
- self.postMessage({ messageId, success: false, error: error.message });
- }
- };
- `;
-
- const blob = new Blob([workerScript], { type: 'application/javascript' });
- this.worker.worker = new Worker(this.createPreviewUrl(blob));
- } catch (error) {
- console.warn('Failed to initialize worker:', error);
- this.worker.worker = null;
- }
- }
-
- async processWithWorker(file, uploadId, maxDimension, quality) {
- return new Promise((resolve, reject) => {
- if (!this.worker.worker) {
- reject(new Error('Worker not available'));
- return;
- }
-
- const messageId = `${uploadId}_${Date.now()}`;
- const outputFormat = this.getOptimalFormat(file);
-
- const handler = (e) => {
- if (e.data.messageId !== messageId) return;
- this.worker.worker.removeEventListener('message', handler);
- this.worker.worker.removeEventListener('error', errorHandler);
-
- if (e.data.success) {
- resolve(new File(
- [e.data.blob],
- this.getProcessedFileName(file, e.data.format || outputFormat),
- { type: e.data.format || outputFormat, lastModified: Date.now() }
- ));
- } else {
- reject(new Error(e.data.error || 'Worker processing failed'));
- }
- };
-
- const errorHandler = (error) => {
- this.worker.worker.removeEventListener('message', handler);
- this.worker.worker.removeEventListener('error', errorHandler);
- reject(new Error(`Worker error: ${error.message}`));
- };
-
- this.worker.worker.addEventListener('message', handler);
- this.worker.worker.addEventListener('error', errorHandler);
- this.worker.worker.postMessage({ messageId, file, maxDimension, quality, outputFormat });
- });
- }
-
- restartWorker() {
- if (this.worker.worker) {
- this.worker.worker.terminate();
- this.worker.worker = null;
- }
- this.worker.tasks.clear();
-
- if (this.worker.restart.count < this.worker.restart.max) {
- this.worker.restart.count++;
- this.initWorker();
- }
- }
-
- // Image processing helpers
- calculateDimensions(img, maxDimension) {
- let { width, height } = img;
- if (width <= maxDimension && height <= maxDimension) {
- return { width, height };
- }
- const scale = Math.min(maxDimension / width, maxDimension / height);
- return { width: Math.round(width * scale), height: Math.round(height * scale) };
- }
-
- getMaxDimension() {
- const screenWidth = window.screen.width;
- const dpr = window.devicePixelRatio || 1;
- if (screenWidth * dpr > 2560) return 2400;
- if (screenWidth * dpr > 1920) return 1920;
- return 1200;
- }
-
- shouldUseWorker(file) {
- return this.worker.worker && file.size > 1024 * 1024 && typeof OffscreenCanvas !== 'undefined';
- }
-
- getOptimalFormat(file) {
- if (file.type === 'image/gif' || file.type === 'image/svg+xml') return file.type;
- return this.supportsWebP() ? 'image/webp' : 'image/jpeg';
- }
-
- getOptimalQuality(file, requestedQuality) {
- if (file.size < 500 * 1024) return Math.max(requestedQuality, 0.9);
- if (file.size < 2 * 1024 * 1024) return requestedQuality;
- return Math.min(requestedQuality, 0.8);
- }
-
- getProcessedFileName(file, outputFormat) {
- const baseName = file.name.replace(/\.[^/.]+$/, '');
- const extensions = { 'image/webp': '.webp', 'image/jpeg': '.jpg', 'image/png': '.png', 'image/gif': '.gif' };
- return baseName + (extensions[outputFormat] || '.jpg');
- }
-
- supportsWebP() {
- const canvas = document.createElement('canvas');
- return canvas.toDataURL('image/webp').startsWith('data:image/webp');
- }
-
- /*******************************************************************************
- * BLOB DATA MANAGEMENT
- *******************************************************************************/
-
- async saveBlobData(uploadId, file) {
- const arrayBuffer = await file.arrayBuffer();
- const upload = this.uploadStore.get(uploadId) || { id: uploadId };
-
- upload.blobData = {
- buffer: arrayBuffer,
- name: file.name,
- type: file.type,
- size: file.size,
- lastModified: file.lastModified || Date.now()
- };
-
- await this.uploadStore.save(upload);
- }
-
- async getBlobData(uploadId) {
- const upload = this.uploadStore.get(uploadId);
- if (!upload?.blobData) return null;
-
- const blob = new Blob([upload.blobData.buffer], { type: upload.blobData.type });
- return new File([blob], upload.blobData.name, {
- type: upload.blobData.type,
- lastModified: upload.blobData.lastModified
- });
- }
-
- /*******************************************************************************
- * QUEUE INTEGRATION
- *******************************************************************************/
-
- async queueUpload(fieldId) {
- const uploads = this.getFieldUploads(fieldId);
- if (uploads.length === 0) return;
-
- const fieldEl = this.fieldElements.get(fieldId);
- if (!fieldEl) return;
-
- const formData = await this.prepareUploadFormData(fieldId, uploads, fieldEl.config);
-
- this.a11y.announce('Queuing for upload');
-
- const operation = {
- endpoint: 'uploads',
- method: 'POST',
- data: formData,
- title: `Uploading ${uploads.length} file${uploads.length > 1 ? 's' : ''}...`,
- popup: `Uploading ${uploads.length} file${uploads.length > 1 ? 's' : ''}...`,
- canMerge: false,
- headers: { 'action_nonce': window.auth.getNonce('dash') },
- append: '_upload'
- };
-
- try {
- const operationId = await this.queue.addToQueue(operation);
-
- // Update upload statuses
- for (const upload of uploads) {
- upload.operationId = operationId;
- upload.status = 'queued';
- await this.uploadStore.save(upload);
- this.updateUploadUI(upload.id);
- }
-
- return operationId;
- } catch (error) {
- throw error;
- }
- }
-
- async prepareUploadFormData(fieldId, uploads, config) {
- const formData = new FormData();
-
- formData.append('content', config.content);
- formData.append('mode', config.mode);
- formData.append('field_name', config.name);
- formData.append('fieldId', fieldId);
- formData.append('field_type', config.type);
- formData.append('subtype', config.subtype);
- formData.append('item_id', config.itemID);
- formData.append('destination', config.destination || 'meta');
-
- const uploadMap = [];
-
- for (const upload of uploads) {
- const file = await this.getBlobData(upload.id);
- if (file) {
- formData.append('files[]', file);
- uploadMap.push(upload.id);
- }
- }
-
- formData.append('upload_ids', JSON.stringify(uploadMap));
- return formData;
- }
-
- async submitGroupedUploads(fieldId) {
- const uploads = this.getFieldUploads(fieldId);
- const groups = this.getFieldGroups(fieldId);
- const fieldEl = this.fieldElements.get(fieldId);
-
- if (uploads.length === 0 || !fieldEl) return;
-
- const formData = new FormData();
- const posts = [];
- const uploadMap = [];
-
- // Process each group
- for (const group of groups) {
- const groupUploads = uploads.filter(u => u.groupId === group.id);
-
- const post = {
- images: [],
- fields: { ...group.fields }
- };
-
- for (const upload of groupUploads) {
- const file = await this.getBlobData(upload.id);
- if (file) {
- formData.append('files[]', file);
- post.images.push({ upload_id: upload.id, index: uploadMap.length });
- uploadMap.push(upload.id);
-
- // Check featured
- const uploadEl = this.uploadElements.get(upload.id);
- const featured = uploadEl?.element?.querySelector('[name="featured"]');
- if (featured?.checked) {
- post.fields.featured = upload.id;
- }
- }
- }
-
- if (post.images.length > 0) {
- posts.push(post);
- }
- }
-
- // Handle ungrouped uploads - each becomes its own post
- const ungrouped = uploads.filter(u => !u.groupId);
- for (const upload of ungrouped) {
- const file = await this.getBlobData(upload.id);
- if (file) {
- formData.append('files[]', file);
- posts.push({
- images: [{ upload_id: upload.id, index: uploadMap.length }],
- fields: {}
- });
- uploadMap.push(upload.id);
- }
- }
-
- formData.append('content', fieldEl.config.content);
- formData.append('user', fieldEl.config.itemID);
- formData.append('posts', JSON.stringify(posts));
- formData.append('upload_ids', JSON.stringify(uploadMap));
-
- const operation = {
- endpoint: 'uploads/groups',
- method: 'POST',
- data: formData,
- title: `Creating ${posts.length} ${fieldEl.config.content}${posts.length > 1 ? 's' : ''}...`,
- popup: `Creating ${posts.length} post${posts.length > 1 ? 's' : ''}...`,
- canMerge: false,
- headers: { 'action_nonce': window.auth.getNonce('dash') },
- append: '_upload'
- };
-
- try {
- const operationId = await this.queue.addToQueue(operation);
-
- for (const upload of uploads) {
- upload.operationId = operationId;
- upload.status = 'queued';
- await this.uploadStore.save(upload);
- this.updateUploadUI(upload.id);
- }
-
- this.a11y.announce(`Creating ${posts.length} post${posts.length > 1 ? 's' : ''}`);
- return operationId;
- } catch (error) {
- this.error.log(error, { component: 'UploadManager', action: 'submitGroupedUploads', fieldId });
- throw error;
- }
- }
-
- async queueUploadMeta(e) {
- const uploadId = this.getUploadIdFromElement(e.target);
- const upload = this.uploadStore.get(uploadId);
- if (!upload) return;
-
- const data = { [e.target.name]: e.target.value };
- upload.meta = { ...upload.meta, ...data };
- await this.uploadStore.save(upload);
-
- const queueData = { [upload.attachmentId ?? upload.id]: upload.meta };
-
- const operation = {
- endpoint: 'uploads/meta',
- method: 'POST',
- data: queueData,
- title: 'Updating meta',
- canMerge: true,
- headers: { 'action_nonce': window.auth.getNonce('dash') }
- };
-
- try {
- await this.queue.addToQueue(operation);
- } catch (error) {
- this.error.log(error, { component: 'UploadManager', action: 'queueUploadMeta', uploadId });
- }
- }
-
- /*******************************************************************************
- * QUEUE EVENT HANDLERS - Cleanup after success
- *******************************************************************************/
-
- async handleOperationComplete(operation, fieldId) {
- const results = operation.result?.data || operation.serverData?.data || [];
-
- // Update attachment IDs
- for (const result of results) {
- const upload = this.uploadStore.get(result.upload_id);
- if (upload) {
- upload.attachmentId = result.attachment_id;
- upload.status = 'completed';
- await this.uploadStore.save(upload);
- this.updateUploadUI(result.upload_id);
- }
- }
-
- if (!fieldId) return;
-
- // Clear completed uploads and their groups
- await this.clearCompletedUploads(fieldId);
-
- this.updateFieldState(fieldId);
- this.a11y.announce('All uploads completed successfully');
- }
-
- async clearCompletedUploads(fieldId) {
- const uploads = this.getFieldUploads(fieldId);
- const groupIds = new Set();
-
- for (const upload of uploads) {
- if (upload.status === 'completed') {
- if (upload.groupId) groupIds.add(upload.groupId);
- await this.clearUpload(upload.id);
- }
- }
-
- // Clear associated groups
- for (const groupId of groupIds) {
- await this.groupStore.delete(groupId);
- this.groupElements.delete(groupId);
- }
- }
-
- handleOperationFailed(operation, fieldId) {
- const uploadIds = operation.data instanceof FormData
- ? JSON.parse(operation.data.get('upload_ids') || '[]')
- : operation.data?.upload_ids || [];
-
- const status = operation.status === 'operation-failed-permanent' ? 'failed_permanent' : 'failed';
-
- uploadIds.forEach(async uploadId => {
- await this.updateUploadStatus(uploadId, status);
- });
-
- if (fieldId) this.updateFieldState(fieldId);
- }
-
- async handleOperationCancelled(fieldId) {
- const uploads = this.getFieldUploads(fieldId);
- const groups = this.getFieldGroups(fieldId);
-
- for (const upload of uploads) {
- await this.clearUpload(upload.id);
- }
-
- for (const group of groups) {
- await this.groupStore.delete(group.id);
- this.groupElements.delete(group.id);
- }
-
- this.updateFieldState(fieldId);
- this.a11y.announce('Upload cancelled');
- }
-
- /*******************************************************************************
- * GROUP MANAGEMENT
- *******************************************************************************/
-
- async createGroup(fieldId, groupId = null) {
- const fieldEl = this.fieldElements.get(fieldId);
- if (!fieldEl) return null;
-
- groupId = groupId || this.generateId('group');
-
- // Create group data
- const groupData = {
- id: groupId,
- fieldId,
- pageUrl: window.location.href,
- uploads: [],
- fields: {}
- };
-
- await this.groupStore.save(groupData);
-
- // Create DOM element
- const element = this.createGroupElement(groupId, fieldId);
- if (!element) return null;
-
- const grid = element.querySelector('.item-grid.group');
-
- // Store DOM references
- this.groupElements.set(groupId, { element, grid, fieldId });
-
- // Insert into DOM
- if (fieldEl.ui.groups?.container && fieldEl.ui.groups.empty) {
- fieldEl.ui.groups.container.insertBefore(element, fieldEl.ui.groups.empty);
- } else if (fieldEl.ui.groups?.container) {
- fieldEl.ui.groups.container.appendChild(element);
- }
-
- // Initialize sortable and selection
- this.addGroupSelectionHandler(fieldId, groupId);
- if (grid) this.createSortableForGrid(grid, fieldId, groupId);
-
- return { id: groupId, element, grid };
- }
-
- createGroupElement(groupId, fieldId) {
- const element = window.getTemplate('imageGroup');
- if (!element) return null;
-
- element.dataset.groupId = groupId;
- element.dataset.fieldId = fieldId;
-
- const fields = window.getTemplate('groupMetadata');
- const fieldsContainer = element.querySelector('.fields');
-
- if (fieldsContainer && fields) {
- fieldsContainer.append(fields);
-
- const titleInput = fieldsContainer.querySelector('[name="post_title"]');
- const excerptInput = fieldsContainer.querySelector('[name="post_excerpt"]');
-
- if (titleInput) {
- titleInput.id = `${groupId}_title`;
- titleInput.name = `${groupId}[post_title]`;
- }
- if (excerptInput) {
- excerptInput.id = `${groupId}_excerpt`;
- excerptInput.name = `${groupId}[post_excerpt]`;
- }
-
- const fieldEl = this.fieldElements.get(fieldId);
- if (fieldEl?.config.content) {
- const summary = element.querySelector('summary');
- if (summary) summary.textContent = fieldEl.config.content + ' Fields';
- }
- } else {
- element.querySelector('details')?.remove();
- }
-
- const gridContainer = element.querySelector('.item-grid.group');
- if (gridContainer) gridContainer.dataset.groupId = groupId;
-
- return element;
- }
-
- async deleteGroup(groupId, moveUploadsToPreview = true) {
- const groupEl = this.groupElements.get(groupId);
- if (!groupEl) return;
-
- const group = this.groupStore.get(groupId);
- const fieldEl = this.fieldElements.get(groupEl.fieldId);
-
- if (moveUploadsToPreview && group?.uploads) {
- for (const uploadId of group.uploads) {
- await this.removeFromGroup(uploadId);
- }
- }
-
- // Remove from store
- await this.groupStore.delete(groupId);
-
- // Remove DOM element
- groupEl.element?.remove();
-
- // Cleanup
- this.groupElements.delete(groupId);
-
- const sortableKey = `${groupEl.fieldId}-group-${groupId}`;
- this.sortableInstances.get(sortableKey)?.destroy();
- this.sortableInstances.delete(sortableKey);
-
- this.a11y.announce('Group removed');
- }
-
- async addToGroup(uploadId, targetGrid, persist = true) {
- const upload = this.uploadStore.get(uploadId);
- const uploadEl = this.uploadElements.get(uploadId);
- if (!upload || !uploadEl) return;
-
- const groupId = targetGrid.dataset.groupId;
- const group = this.groupStore.get(groupId);
-
- // Remove from previous group if needed
- if (upload.groupId && upload.groupId !== groupId) {
- const oldGroup = this.groupStore.get(upload.groupId);
- if (oldGroup) {
- oldGroup.uploads = oldGroup.uploads.filter(id => id !== uploadId);
- if (oldGroup.uploads.length === 0) {
- await this.deleteGroup(upload.groupId, false);
- } else {
- await this.groupStore.save(oldGroup);
- }
- }
- }
-
- // Add to new group
- upload.groupId = groupId;
- if (group && !group.uploads.includes(uploadId)) {
- group.uploads.push(uploadId);
- await this.groupStore.save(group);
- }
-
- // Update upload
- await this.uploadStore.save(upload);
-
- // Move DOM element
- targetGrid.appendChild(uploadEl.element);
- uploadEl.location = targetGrid;
-
- // Show featured radio
- const featured = uploadEl.element.querySelector('[name="featured"]');
- if (featured) {
- featured.hidden = false;
- featured.name = `${groupId}_featured`;
- }
-
- // Clear checkbox
- const checkbox = uploadEl.element.querySelector('[name*="select-item"]');
- if (checkbox) checkbox.checked = false;
-
- this.updateSortableState(targetGrid);
- }
-
- async removeFromGroup(uploadId) {
- const upload = this.uploadStore.get(uploadId);
- const uploadEl = this.uploadElements.get(uploadId);
- if (!upload || !uploadEl) return;
-
- const fieldEl = this.fieldElements.get(upload.fieldId);
- if (!fieldEl?.ui?.preview) return;
-
- // Update group
- if (upload.groupId) {
- const group = this.groupStore.get(upload.groupId);
- if (group) {
- group.uploads = group.uploads.filter(id => id !== uploadId);
- if (group.uploads.length === 0) {
- await this.deleteGroup(upload.groupId, false);
- } else {
- await this.groupStore.save(group);
- }
- }
- upload.groupId = null;
- await this.uploadStore.save(upload);
- }
-
- // Move to preview
- fieldEl.ui.preview.appendChild(uploadEl.element);
- uploadEl.location = fieldEl.ui.preview;
-
- // Hide featured radio
- const featured = uploadEl.element.querySelector('[name="featured"]');
- if (featured) {
- featured.hidden = true;
- featured.checked = false;
- }
-
- this.updateSortableState(fieldEl.ui.preview);
- }
-
- async removeUpload(fieldId, uploadId) {
- const upload = this.uploadStore.get(uploadId);
- const uploadEl = this.uploadElements.get(uploadId);
-
- if (upload?.groupId) {
- const group = this.groupStore.get(upload.groupId);
- if (group) {
- group.uploads = group.uploads.filter(id => id !== uploadId);
- if (group.uploads.length === 0) {
- await this.deleteGroup(upload.groupId, false);
- } else {
- await this.groupStore.save(group);
- }
- }
- }
-
- uploadEl?.element?.remove();
- await this.clearUpload(uploadId);
-
- this.updateFieldState(fieldId);
- this.maybeLockUploads(fieldId);
-
- this.selectionHandlers.get(fieldId)?.deselect(uploadId);
- this.a11y.announce('Upload removed');
- }
-
- async handleGroupMetaChange(input) {
- const groupEl = input.closest(this.selectors.groups.container);
- if (!groupEl) return;
-
- const groupId = groupEl.dataset.groupId;
- const group = this.groupStore.get(groupId);
- if (!group) return;
-
- let name = input.name;
- if (name.includes(groupId)) {
- name = name.replace(`${groupId}_`, '').replace(`${groupId}[`, '').replace(']', '');
- }
-
- group.fields[name] = input.value;
- await this.groupStore.save(group);
- }
-
- /*******************************************************************************
- * SORTABLE & DRAG/DROP
- *******************************************************************************/
-
- initSortable(fieldId) {
- if (!window.Sortable) return;
-
- if (!Sortable._multiDragMounted && Sortable.MultiDrag) {
- Sortable.mount(new Sortable.MultiDrag());
- Sortable._multiDragMounted = true;
- }
-
- const fieldEl = this.fieldElements.get(fieldId);
- if (!fieldEl) return;
-
- const grids = fieldEl.element.querySelectorAll('.item-grid.preview, .item-grid.group');
- grids.forEach(grid => {
- const groupId = grid.classList.contains('group')
- ? grid.closest('.upload-group')?.dataset.groupId
- : null;
- this.createSortableForGrid(grid, fieldId, groupId);
- });
-
- // Empty group drop zone
- const emptyGroup = fieldEl.element.querySelector('.empty-group');
- if (emptyGroup && !emptyGroup.sortableInstance) {
- emptyGroup.sortableInstance = new Sortable(emptyGroup, {
- animation: 150,
- draggable: '.item',
- multiDrag: true,
- selectedClass: 'selected-for-drag',
- avoidImplicitDeselect: true,
- group: { name: fieldId, pull: false, put: true },
- ghostClass: 'sortable-ghost',
- onEnd: (evt) => this.handleDrop(evt, fieldId)
- });
- }
- }
-
- createSortableForGrid(grid, fieldId, groupId = null) {
- if (!grid || grid.sortableInstance) return;
-
- const instance = new Sortable(grid, {
- animation: 150,
- draggable: '.item',
- multiDrag: true,
- selectedClass: 'selected-for-drag',
- avoidImplicitDeselect: true,
- group: { name: fieldId, pull: true, put: true },
- ghostClass: 'sortable-ghost',
- chosenClass: 'sortable-chosen',
- dragClass: 'sortable-drag',
- onEnd: (evt) => this.handleDrop(evt, fieldId),
- onSelect: (evt) => this.syncCheckboxToSortable(evt.item, true),
- onDeselect: (evt) => this.syncCheckboxToSortable(evt.item, false),
- onAdd: (evt) => this.updateSortableState(evt.to),
- onRemove: (evt) => this.updateSortableState(evt.from)
- });
-
- grid.sortableInstance = instance;
-
- const gridId = groupId ? `${fieldId}-group-${groupId}` : `${fieldId}-preview`;
- this.sortableInstances.set(gridId, instance);
-
- return instance;
- }
-
- syncCheckboxToSortable(item, selected) {
- const checkbox = item.querySelector('[name*="select-item"]');
- if (checkbox && checkbox.checked !== selected) {
- checkbox.checked = selected;
- checkbox.dispatchEvent(new Event('change', { bubbles: true }));
- }
- }
-
- /**
- * Unified drop handler - consolidated from multiple methods
- */
- async handleDrop(evt, fieldId) {
- const target = evt.to;
- const source = evt.from;
- const items = evt.items?.length > 0 ? evt.items : [evt.item];
- const uploadIds = items.map(item => item.dataset.uploadId);
-
- // Same container = reorder only
- if (target === source) {
- this.handleReorder(evt);
- return;
- }
-
- const targetType = this.getDropTargetType(target);
-
- try {
- switch (targetType) {
- case 'empty-group':
- const group = await this.createGroup(fieldId);
- if (!group) throw new Error('Group creation failed');
- for (const uploadId of uploadIds) {
- await this.addToGroup(uploadId, group.grid, false);
- }
- break;
-
- case 'preview':
- for (const uploadId of uploadIds) {
- await this.removeFromGroup(uploadId);
- }
- break;
-
- case 'group':
- for (const uploadId of uploadIds) {
- await this.addToGroup(uploadId, target, false);
- }
- break;
-
- default:
- throw new Error('Unknown drop target');
- }
-
- this.finalizeDrop(fieldId, items.length, targetType);
-
- } catch (error) {
- console.error('Drop error:', error);
- // Return items to preview as fallback
- const fieldEl = this.fieldElements.get(fieldId);
- if (fieldEl?.ui?.preview) {
- items.forEach(item => fieldEl.ui.preview.appendChild(item));
- }
- this.a11y.announce('An error occurred. Items returned to preview.');
- }
-
- this.updateSortableState(target);
- if (source !== target) this.updateSortableState(source);
- }
-
- finalizeDrop(fieldId, count, targetType) {
- const messages = {
- 'empty-group': count > 1 ? `Created group with ${count} items` : 'Created group with item',
- 'preview': count > 1 ? `Moved ${count} items to preview` : 'Moved item to preview',
- 'group': count > 1 ? `Moved ${count} items to group` : 'Moved item to group'
- };
-
- this.a11y.announce(messages[targetType] || 'Items moved');
- this.selectionHandlers.get(fieldId)?.clearSelection();
- }
-
- getDropTargetType(target) {
- if (target.classList.contains('empty-group')) return 'empty-group';
- if (target.classList.contains('preview')) return 'preview';
- if (target.classList.contains('group')) return 'group';
- return 'unknown';
- }
-
- handleReorder(evt) {
- const grid = evt.to;
- const fieldWrapper = grid.closest('.field, .upload');
- if (!fieldWrapper) return;
-
- const items = Array.from(grid.querySelectorAll('.item:not(.sortable-ghost):not(.sortable-clone)'))
- .map(el => el.dataset.uploadId)
- .filter(Boolean);
-
- const hiddenInput = fieldWrapper.querySelector('input[type="hidden"]');
- if (hiddenInput && items.length > 0) {
- hiddenInput.value = items.join(',');
- }
-
- // Update group order in store
- const groupId = grid.dataset.groupId;
- if (groupId) {
- const group = this.groupStore.get(groupId);
- if (group) {
- group.uploads = items;
- this.groupStore.save(group);
- }
- }
-
- this.a11y.announce('Item reordered');
-
- fieldWrapper.dispatchEvent(new CustomEvent('jvb-items-reordered', {
- detail: { from: evt.from, to: evt.to, items },
- bubbles: true
- }));
- }
-
- updateSortableState(grid) {
- const sortable = grid?.sortableInstance;
- if (sortable) sortable.option('disabled', false);
- }
-
- refreshSortable(fieldId) {
- const fieldEl = this.fieldElements.get(fieldId);
- if (!fieldEl) return;
-
- const grids = fieldEl.element.querySelectorAll('.item-grid.preview, .item-grid.group');
- grids.forEach(grid => this.updateSortableState(grid));
- }
-
- syncSortableSelection(fieldId, selectedItems) {
- this.sortableInstances.forEach((instance, key) => {
- if (key.startsWith(fieldId)) {
- instance.el.querySelectorAll('.item').forEach(item => {
- const uploadId = item.dataset.uploadId;
- if (selectedItems.has(uploadId)) {
- Sortable.utils.select(item);
- } else {
- Sortable.utils.deselect(item);
- }
- });
- }
- });
- }
-
- /*******************************************************************************
- * EVENT LISTENERS
- *******************************************************************************/
-
- initListeners() {
- this.clickHandler = this.handleClick.bind(this);
- this.changeHandler = this.handleChange.bind(this);
- this.dragEnterHandler = this.handleDragEnter.bind(this);
- this.dragLeaveHandler = this.handleDragLeave.bind(this);
- this.dragOverHandler = this.handleDragOver.bind(this);
- this.dropHandler = this.handleExternalDrop.bind(this);
-
- document.addEventListener('click', this.clickHandler);
- document.addEventListener('change', this.changeHandler);
- document.addEventListener('dragenter', this.dragEnterHandler);
- document.addEventListener('dragleave', this.dragLeaveHandler);
- document.addEventListener('dragover', this.dragOverHandler);
- document.addEventListener('drop', this.dropHandler);
- }
-
- handleClick(e) {
- // Trigger file input
- const dropZone = e.target.closest(this.selectors.field.dropZone);
- if (dropZone && !e.target.matches('input, button, a')) {
- dropZone.querySelector(this.selectors.field.input)?.click();
- }
-
- // Action buttons
- const actionButton = e.target.closest('[data-action]');
- if (actionButton) this.handleAction(actionButton);
- }
-
- handleChange(e) {
- const fieldId = this.getFieldIdFromElement(e.target);
- if (!fieldId) return;
-
- // File input
- if (e.target.matches(this.selectors.field.input)) {
- const files = Array.from(e.target.files);
- if (files.length > 0) this.processFiles(fieldId, files);
- return;
- }
-
- // Meta field changes
- const fieldEl = this.fieldElements.get(fieldId);
- if (!fieldEl?.config.autoUpload) return;
-
- if (fieldEl.config.destination === 'post_group') {
- this.handleGroupMetaChange(e.target);
- } else {
- this.queueUploadMeta(e);
- }
- }
-
- handleDragEnter(e) {
- if (!e.dataTransfer.types.includes('Files')) return;
- const dropZone = e.target.closest(this.selectors.field.dropZone);
- if (dropZone) {
- e.preventDefault();
- dropZone.classList.add('dragover');
- }
- }
-
- handleDragLeave(e) {
- const dropZone = e.target.closest(this.selectors.field.dropZone);
- if (dropZone && !dropZone.contains(e.relatedTarget)) {
- dropZone.classList.remove('dragover');
- }
- }
-
- handleDragOver(e) {
- if (!e.dataTransfer.types.includes('Files')) return;
- const dropZone = e.target.closest(this.selectors.field.dropZone);
- if (dropZone) {
- e.preventDefault();
- e.dataTransfer.dropEffect = 'copy';
- }
- }
-
- handleExternalDrop(e) {
- const dropZone = e.target.closest(this.selectors.field.dropZone);
- if (!dropZone) return;
-
- e.preventDefault();
- dropZone.classList.remove('dragover');
-
- const files = Array.from(e.dataTransfer.files);
- if (files.length === 0) return;
-
- const fieldId = this.getFieldIdFromElement(dropZone);
- if (fieldId) {
- this.processFiles(fieldId, files);
- this.a11y.announce(`${files.length} file(s) dropped for upload`);
- }
- }
-
- /*******************************************************************************
- * ACTION HANDLERS
- *******************************************************************************/
-
- handleAction(button) {
- const action = button.dataset.action;
- const fieldId = this.getFieldIdFromElement(button);
-
- switch (action) {
- case 'add-to-group':
- this.handleAddToGroup(fieldId);
- break;
- case 'delete-group':
- this.handleDeleteGroup(button);
- break;
- case 'delete-upload':
- case 'remove-from-group':
- this.handleRemoveItem(button);
- break;
- case 'upload':
- this.handleSubmitUploads(fieldId);
- break;
- case 'restore':
- this.handleRestoreSelected();
- break;
- case 'restore-all':
- this.handleRestoreAll();
- break;
- case 'clear-cache':
- this.handleClearCache();
- break;
- }
- }
-
- async handleAddToGroup(fieldId) {
- const selected = this.selected.get(fieldId);
-
- if (!selected || selected.size === 0) {
- await this.createGroup(fieldId);
- } else {
- const group = await this.createGroup(fieldId);
- if (!group) return;
-
- for (const uploadId of selected) {
- await this.addToGroup(uploadId, group.grid, false);
- }
-
- this.selectionHandlers.get(fieldId)?.clearSelection();
- this.a11y.announce(`Created group with ${selected.size} items`);
- }
- }
-
- async handleDeleteGroup(button) {
- const group = button.closest(this.selectors.groups.container);
- if (!group) return;
-
- const groupId = group.dataset.groupId;
-
- if (!confirm('Delete this group? Items will be moved back to the upload area.')) {
- return;
- }
-
- await this.deleteGroup(groupId, true);
- }
-
- async handleRemoveItem(button) {
- const item = button.closest(this.selectors.items.item);
- if (!item) return;
-
- if (!confirm('Remove this item?')) return;
-
- const uploadId = item.dataset.uploadId;
- const fieldId = this.getFieldIdFromElement(item);
-
- await this.removeUpload(fieldId, uploadId);
- }
-
- handleSubmitUploads(fieldId) {
- const fieldEl = this.fieldElements.get(fieldId);
- if (!fieldEl) return;
-
- fieldEl.element.closest('details')?.removeAttribute('open');
- document.body.classList.add('uploading');
-
- if (fieldEl.config.destination === 'post_group') {
- this.submitGroupedUploads(fieldId);
- } else {
- this.queueUpload(fieldId);
- }
- }
-
- /*******************************************************************************
- * SELECTION MANAGEMENT
- *******************************************************************************/
-
- addFieldSelectionHandler(fieldId) {
- if (this.selectionHandlers.has(fieldId)) return;
-
- const fieldEl = this.fieldElements.get(fieldId);
- if (!fieldEl?.element) return;
-
- const handler = new window.jvbHandleSelection({
- container: fieldEl.element,
- ui: {
- selectAll: fieldEl.element.querySelector('[name="select-all-uploads"]'),
- bulkControls: fieldEl.element.querySelector('.selection-actions'),
- count: fieldEl.element.querySelector('.selection-count')
- },
- itemSelector: '[data-upload-id]',
- checkboxSelector: '[name*="select-item"]'
- });
-
- handler.subscribe((event, data) => {
- if (['item-selected', 'item-deselected', 'range-selected'].includes(event)) {
- this.syncSortableSelection(fieldId, data.selectedItems);
- this.selected.set(fieldId, data.selectedItems);
- }
- });
-
- this.selectionHandlers.set(fieldId, handler);
- }
-
- addGroupSelectionHandler(fieldId, groupId) {
- const key = `${fieldId}_${groupId}`;
- if (this.selectionHandlers.has(key)) return;
-
- const groupEl = this.groupElements.get(groupId);
- if (!groupEl?.element) return;
-
- const handler = new window.jvbHandleSelection({
- container: groupEl.element,
- ui: {
- selectAll: groupEl.element.querySelector(this.selectors.groups.selectAll),
- bulkControls: groupEl.element.querySelector(this.selectors.groups.actions),
- count: groupEl.element.querySelector(this.selectors.groups.count)
- },
- itemSelector: '[data-upload-id]',
- checkboxSelector: '[name*="select-item"]'
- });
-
- handler.subscribe((event, data) => {
- if (['item-selected', 'item-deselected', 'range-selected'].includes(event)) {
- this.selected.set(fieldId, data.selectedItems);
- }
- });
-
- this.selectionHandlers.set(key, handler);
- }
-
- /*******************************************************************************
- * UI UPDATES
- *******************************************************************************/
-
- updateFieldState(fieldId) {
- const fieldEl = this.fieldElements.get(fieldId);
- if (!fieldEl) return;
-
- const uploadCount = this.getFieldUploadCount(fieldId);
- const groupCount = this.getFieldGroups(fieldId).length;
-
- fieldEl.element.dataset.hasUploads = uploadCount > 0;
- fieldEl.element.dataset.uploadCount = uploadCount;
- fieldEl.element.dataset.hasGroups = groupCount > 0;
-
- if (fieldEl.ui.preview) {
- fieldEl.ui.preview.setAttribute('aria-label',
- `Upload preview area with ${uploadCount} item${uploadCount !== 1 ? 's' : ''}`
- );
- }
- }
-
- updateFieldProgress(fieldId, current, total, message) {
- const fieldEl = this.fieldElements.get(fieldId);
- const progress = fieldEl?.ui?.progress;
- if (!progress?.container) return;
-
- const percent = total > 0 ? (current / total) * 100 : 0;
-
- if (progress.fill) progress.fill.style.width = `${percent}%`;
- if (progress.text) progress.text.textContent = message;
- if (progress.count) progress.count.textContent = `${current}/${total}`;
-
- progress.container.hidden = current === total;
- }
-
- updateFieldStatus(fieldId, status) {
- // Status is now derived from uploads, no field-level status needed
- }
-
- async updateUploadStatus(uploadId, status) {
- const upload = this.uploadStore.get(uploadId);
- if (!upload) return;
-
- upload.status = status;
- await this.uploadStore.save(upload);
- this.updateUploadUI(uploadId);
- }
-
- updateUploadUI(uploadId) {
- const uploadEl = this.uploadElements.get(uploadId);
- const upload = this.uploadStore.get(uploadId);
- if (!upload || !uploadEl?.element) return;
-
- // Update class
- uploadEl.element.className = uploadEl.element.className.replace(/status-[\w-]+/g, '');
- uploadEl.element.classList.add(`status-${upload.status}`);
-
- // Update progress
- const progress = uploadEl.element.querySelector('.progress');
- if (progress) {
- this.updateUploadItemProgress(uploadId, this.getStatusProgress(upload.status), upload.status);
- }
- }
-
- showUploadProgress(uploadId, show = true) {
- const uploadEl = this.uploadElements.get(uploadId);
- const progress = uploadEl?.element?.querySelector('.progress');
- if (!progress) return;
-
- if (show) {
- progress.style.removeProperty('animation');
- progress.hidden = false;
- } else {
- progress.style.animation = 'fadeOut var(--transition-base)';
- setTimeout(() => { progress.hidden = true; }, 300);
- }
- }
-
- updateUploadItemProgress(uploadId, percent, status = null) {
- const uploadEl = this.uploadElements.get(uploadId);
- const progress = uploadEl?.element?.querySelector('.progress');
- if (!progress) return;
-
- const fill = progress.querySelector('.fill');
- const details = progress.querySelector('.details');
- const icon = progress.querySelector('.icon');
-
- if (fill) fill.style.width = `${percent}%`;
- if (status && details) details.textContent = this.statusMapping[status] || status;
- if (status && icon) icon.innerHTML = this.getStatusIcon(status).outerHTML;
- }
-
- maybeLockUploads(fieldId) {
- const fieldEl = this.fieldElements.get(fieldId);
- if (!fieldEl?.ui?.dropZone) return;
-
- const uploadCount = this.getFieldUploadCount(fieldId);
- const maxFiles = fieldEl.config.destination === 'post_group' ? 20 : (fieldEl.config.maxFiles || 999);
-
- fieldEl.ui.dropZone.hidden = uploadCount >= maxFiles;
- fieldEl.element.classList.toggle('at-max-uploads', uploadCount >= maxFiles);
-
- if (fieldEl.config.destination === 'post_group' && uploadCount >= maxFiles) {
- this.a11y.announce('Maximum of 20 uploads reached.');
- }
- }
-
- /*******************************************************************************
- * CLEANUP
- *******************************************************************************/
-
- async clearUpload(uploadId) {
- const uploadEl = this.uploadElements.get(uploadId);
-
- if (uploadEl) {
- this.revokePreviewUrl(uploadEl.preview);
- if (uploadEl.element?.dataset.previewUrl) {
- this.revokePreviewUrl(uploadEl.element.dataset.previewUrl);
- }
- }
-
- this.uploadElements.delete(uploadId);
- await this.uploadStore.delete(uploadId);
- }
-
- createPreviewUrl(file) {
- const url = URL.createObjectURL(file);
- this.previewUrls.add(url);
- return url;
- }
-
- revokePreviewUrl(url) {
- if (url?.startsWith('blob:')) {
- URL.revokeObjectURL(url);
- this.previewUrls.delete(url);
- }
- }
-
- cleanupAllPreviewUrls() {
- this.previewUrls.forEach(url => {
- try { URL.revokeObjectURL(url); } catch (e) {}
- });
- this.previewUrls.clear();
- }
-
- /*******************************************************************************
- * RECOVERY & RESTORATION
- *******************************************************************************/
-
- async showRecoveryNotification(byField) {
- let totalUploads = 0;
- let totalGroups = 0;
-
- byField.forEach(field => {
- totalUploads += field.uploads.length;
- totalGroups += field.groups.size;
- });
-
- const notification = window.getTemplate('restoreNotification');
- if (!notification) return;
-
- const message = totalGroups > 0
- ? `${totalGroups} group${totalGroups > 1 ? 's' : ''} with ${totalUploads} upload${totalUploads > 1 ? 's' : ''} can be restored.`
- : `${totalUploads} upload${totalUploads > 1 ? 's' : ''} can be recovered.`;
-
- const detailsEl = notification.querySelector('.restore-details');
- if (detailsEl) detailsEl.textContent = message;
-
- // Build preview
- for (const [fieldId, fieldData] of byField) {
- const fieldTemplate = window.getTemplate('restoreField');
- if (!fieldTemplate) continue;
-
- const titleEl = fieldTemplate.querySelector('h3');
- if (titleEl) titleEl.textContent = fieldId;
-
- const itemGrid = fieldTemplate.querySelector('.item-grid.restore');
-
- for (const upload of fieldData.uploads) {
- const file = await this.getBlobData(upload.id);
- if (!file) continue;
-
- const uploadItem = this.createUploadElement({
- id: upload.id,
- preview: this.createPreviewUrl(file),
- meta: upload.meta,
- subtype: this.getSubtypeFromMime(file.type)
- }, false);
-
- uploadItem.dataset.fieldId = fieldId;
- itemGrid?.appendChild(uploadItem);
- }
-
- notification.querySelector('.wrap')?.appendChild(fieldTemplate);
- }
-
- document.querySelector('.field.upload')?.appendChild(notification);
-
- const dialog = document.querySelector('dialog.restore-uploads');
- if (dialog) {
- this.restoreModal = new window.jvbModal(dialog);
- this.restoreSelection = new window.jvbHandleSelection({
- container: dialog,
- ui: {
- selectAll: dialog.querySelector('#select-all-restore'),
- count: dialog.querySelector('.selection-count')
- }
- });
- this.restoreModal.handleOpen();
- }
- }
-
- async handleRestoreSelected() {
- const dialog = document.querySelector('dialog.restore-uploads');
- if (!dialog) return;
-
- const selected = [];
- dialog.querySelectorAll('[type=checkbox]:checked').forEach(checkbox => {
- const item = checkbox.closest('.item');
- if (item) {
- selected.push({
- uploadId: item.dataset.uploadId,
- fieldId: item.dataset.fieldId
- });
- }
- });
-
- if (selected.length > 0) {
- await this.restoreUploads(selected);
- }
-
- this.cleanupRestoreModal();
- }
-
- async handleRestoreAll() {
- const dialog = document.querySelector('dialog.restore-uploads');
- if (!dialog) return;
-
- const all = [];
- dialog.querySelectorAll('.item.upload').forEach(item => {
- all.push({
- uploadId: item.dataset.uploadId,
- fieldId: item.dataset.fieldId
- });
- });
-
- await this.restoreUploads(all);
- this.cleanupRestoreModal();
- }
-
- async restoreUploads(items) {
- const byField = new Map();
-
- items.forEach(item => {
- if (!byField.has(item.fieldId)) {
- byField.set(item.fieldId, []);
- }
- byField.get(item.fieldId).push(item.uploadId);
- });
-
- for (const [fieldId, uploadIds] of byField) {
- await this.restoreFieldUploads(fieldId, uploadIds);
- }
- }
-
- async restoreFieldUploads(fieldId, uploadIds) {
- // Find or register field element
- let fieldEl = this.fieldElements.get(fieldId);
-
- if (!fieldEl) {
- // Try to find by data attribute
- const element = document.querySelector(`[data-uploader="${fieldId}"]`);
- if (element) {
- this.registerUploader(element);
- fieldEl = this.fieldElements.get(fieldId);
- }
- }
-
- if (!fieldEl) {
- console.warn(`Field ${fieldId} not found for restoration`);
- return;
- }
-
- // Show upload UI
- if (fieldEl.ui.dropZone) fieldEl.ui.dropZone.hidden = true;
- if (fieldEl.ui.groups?.display) fieldEl.ui.groups.display.hidden = false;
-
- // Restore groups first
- const groups = this.getFieldGroups(fieldId);
- for (const group of groups) {
- await this.restoreGroup(fieldId, group);
- }
-
- // Restore uploads
- for (const uploadId of uploadIds) {
- const upload = this.uploadStore.get(uploadId);
- if (upload) {
- await this.restoreUploadElement(fieldId, upload);
- }
- }
-
- this.updateFieldState(fieldId);
- this.refreshSortable(fieldId);
- this.maybeLockUploads(fieldId);
-
- // Auto-upload if configured
- if (fieldEl.config.autoUpload && fieldEl.config.destination !== 'post_group') {
- await this.queueUpload(fieldId);
- }
- }
-
- async restoreGroup(fieldId, groupData) {
- const group = await this.createGroup(fieldId, groupData.id);
- if (!group) return;
-
- // Restore field values
- if (groupData.fields) {
- const titleInput = group.element.querySelector('[name*="post_title"]');
- const excerptInput = group.element.querySelector('[name*="post_excerpt"]');
-
- if (titleInput && groupData.fields.post_title) {
- titleInput.value = groupData.fields.post_title;
- }
- if (excerptInput && groupData.fields.post_excerpt) {
- excerptInput.value = groupData.fields.post_excerpt;
- }
- }
- }
-
- async restoreUploadElement(fieldId, upload) {
- const fieldEl = this.fieldElements.get(fieldId);
- if (!fieldEl) return;
-
- const file = await this.getBlobData(upload.id);
- if (!file) return;
-
- const preview = this.createPreviewUrl(file);
- const element = this.createUploadElement({
- id: upload.id,
- preview,
- meta: upload.meta,
- subtype: this.getSubtypeFromMime(file.type)
- }, fieldEl.config.destination === 'post_group');
-
- // Determine location
- let location;
- if (upload.groupId) {
- const groupEl = this.groupElements.get(upload.groupId);
- location = groupEl?.grid || fieldEl.ui.preview;
- } else {
- location = fieldEl.ui.preview;
- }
-
- location.appendChild(element);
- this.uploadElements.set(upload.id, { element, preview, location });
-
- // Update upload status
- upload.status = 'processed';
- await this.uploadStore.save(upload);
- }
-
- handleClearCache() {
- if (!confirm('Discard these uploads?')) return;
- this.cleanupStoredData();
- this.cleanupRestoreModal();
- }
-
- async cleanupStoredData() {
- await this.uploadStore.clear();
- await this.groupStore.clear();
- }
-
- cleanupRestoreModal() {
- if (this.restoreModal) {
- this.restoreModal.handleClose();
- this.restoreSelection?.destroy();
- this.restoreModal.destroy();
- this.restoreModal.modal.remove();
- this.restoreModal = null;
- this.restoreSelection = null;
- }
- }
-
- /*******************************************************************************
- * HELPER METHODS
- *******************************************************************************/
-
- generateId(prefix) {
- return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
- }
-
- determineFieldId(fieldElement) {
- const content = fieldElement.dataset.content ||
- fieldElement.closest('dialog')?.dataset.content ||
- fieldElement.closest('form')?.dataset.save || '';
- const itemID = fieldElement.dataset.itemId ||
- fieldElement.closest('dialog')?.dataset.itemId || '';
- const field = fieldElement.dataset.field || '';
-
- return `${content}_${itemID}_${field}`;
- }
-
- getFieldIdFromElement(el) {
- const field = el.closest(this.selectors.field.field);
- return field?.dataset.uploader || null;
- }
-
- getUploadIdFromElement(el) {
- const item = el.closest(this.selectors.items.item);
- return item?.dataset.uploadId || null;
- }
-
- getSubtypeFromMime(mimeType) {
- if (mimeType.startsWith('image/')) return 'image';
- if (mimeType.startsWith('video/')) return 'video';
- return 'document';
- }
-
- getStatusIcon(status) {
- return window.getIcon(this.queue.icons[status]);
- }
-
- getStatusProgress(status) {
- const progress = {
- 'processing': 28,
- 'queued': 50,
- 'uploading': 66,
- 'pending': 75,
- 'server_processing': 89,
- 'completed': 100
- };
- return progress[status] || 0;
- }
-
- formatBytes(bytes, decimals = 2) {
- if (bytes === 0) return '0 Bytes';
- const k = 1024;
- const sizes = ['Bytes', 'KB', 'MB', 'GB'];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
- }
-
- createUploadElement(upload, draggable = false) {
- const element = window.getTemplate('uploadItem');
- if (!element) return null;
-
- element.dataset.uploadId = upload.id;
- element.dataset.subtype = upload.subtype || 'image';
-
- const featured = element.querySelector('[name="featured"]');
- const img = element.querySelector('img');
- const video = element.querySelector('video');
- const preview = element.querySelector('label > span');
- const details = element.querySelector('details');
-
- if (featured) featured.value = upload.id;
-
- switch (upload.subtype) {
- case 'image':
- if (img) {
- img.src = upload.preview;
- img.alt = upload.meta?.originalName || '';
- }
- video?.remove();
- preview?.remove();
- break;
- case 'video':
- if (video) video.src = upload.preview;
- img?.remove();
- preview?.remove();
- break;
- case 'document':
- const fileName = upload.meta?.originalName || '';
- const ext = fileName.split('.').pop()?.toLowerCase() || '';
- const iconMap = {
- 'pdf': 'file-pdf', 'csv': 'file-csv',
- 'doc': 'file-doc', 'docx': 'file-doc',
- 'txt': 'file-txt', 'xls': 'file-xls', 'xlsx': 'file-xls'
- };
- const icon = window.getIcon(iconMap[ext] || 'file');
- if (preview) {
- preview.innerText = fileName;
- preview.prepend(icon);
- }
- img?.remove();
- video?.remove();
- break;
- }
-
- if (details) {
- const template = window.getTemplate('uploadMeta');
- if (template) details.append(template);
- }
-
- element.draggable = draggable;
-
- // Update input IDs
- element.querySelectorAll('input').forEach(input => {
- const id = input.id;
- if (id) {
- const newId = id + upload.id;
- const label = input.parentNode.querySelector(`label[for="${id}"]`);
- input.id = newId;
- if (label) label.htmlFor = newId;
- }
- });
-
- return element;
- }
-
- /**
- * Get all files for a form
- */
- async getFilesForForm(formElement) {
- const uploadFields = formElement.querySelectorAll('[data-upload-field]');
- const allFiles = [];
-
- for (const field of uploadFields) {
- const fieldId = this.determineFieldId(field);
- const files = await this.getFilesForField(fieldId);
- allFiles.push(...files);
- }
-
- return allFiles;
- }
-
- /**
- * Get all files for a field
- */
- async getFilesForField(fieldId) {
- const uploads = this.getFieldUploads(fieldId);
- const files = [];
-
- for (const upload of uploads) {
- const file = await this.getBlobData(upload.id);
- if (file) {
- files.push({
- file,
- uploadId: upload.id,
- fieldName: this.fieldElements.get(fieldId)?.config.name,
- meta: upload.meta || {}
- });
- }
- }
-
- return files;
- }
-
- /*******************************************************************************
- * EVENT SYSTEM
- *******************************************************************************/
-
- subscribe(callback) {
- this.subscribers.add(callback);
- return () => this.subscribers.delete(callback);
- }
-
- notify(event, data = {}) {
- this.subscribers.forEach(cb => {
- try { cb(event, data); } catch (e) { console.error('Subscriber error:', e); }
- });
- }
-
- /*******************************************************************************
- * DESTROY
- *******************************************************************************/
-
- destroy() {
- document.removeEventListener('click', this.clickHandler);
- document.removeEventListener('change', this.changeHandler);
- document.removeEventListener('dragenter', this.dragEnterHandler);
- document.removeEventListener('dragleave', this.dragLeaveHandler);
- document.removeEventListener('dragover', this.dragOverHandler);
- document.removeEventListener('drop', this.dropHandler);
-
- this.selectionHandlers.forEach(handler => handler.destroy());
- this.selectionHandlers.clear();
-
- this.cleanupAllPreviewUrls();
-
- this.sortableInstances.forEach(instance => instance?.destroy?.());
- this.sortableInstances.clear();
-
- this.uploadElements.clear();
- this.fieldElements.clear();
- this.groupElements.clear();
- this.selected.clear();
- this.subscribers.clear();
- }
-}
-
-// Initialize
-document.addEventListener('DOMContentLoaded', async function() {
- window.auth.subscribe((event) => {
- if (event === 'auth-loaded') {
- window.jvbUploads = new UploadManager();
- }
- });
-});
diff --git a/assets/js/concise/UploadManagerOld.js b/assets/js/concise/UploadManagerOld.js
deleted file mode 100644
index 3f1ae58..0000000
--- a/assets/js/concise/UploadManagerOld.js
+++ /dev/null
@@ -1,3141 +0,0 @@
-/**
- * UploadManager - Refactored for clarity
- *
- * Architecture:
- * - DataStores (fieldStore, uploadStore) = Recovery cache only, cleared after successful upload
- * - Maps (uploadElements, fieldElements) = Runtime DOM references
- * - Upload data flows: File → Process → Queue → Server → Clean up stores
- */
-class UploadManager {
- constructor() {
- // Load dependencies
- this.queue = window.jvbQueue;
- this.a11y = window.jvbA11y;
- this.error = window.jvbError;
- this.fieldStoreReady = false;
- this.uploadStoreReady = false;
- this.hasCheckedForUploads = false;
- const {fields, uploads} = window.jvbStore.register(
- 'uploads',
- [
- {
- storeName: 'fields',
- keyPath: 'id',
- indexes: [
- { name: 'fieldId', keyPath: 'fieldId' },
- { name: 'timestamp', keyPath: 'timestamp' },
- { name: 'content', keyPath: 'content' },
- { name: 'itemId', keyPath: 'itemId' },
- { name: 'status', keyPath: 'status' }
- ],
- TTL: 7 * 24 * 60 * 60 * 1000, // 1 week
- delayFetch: true
- },
- {
- storeName: 'uploads',
- keyPath: 'id',
- storeBlobs: true,
- indexes: [
- { name: 'fieldId', keyPath: 'fieldId' },
- { name: 'status', keyPath: 'status' },
- { name: 'groupId', keyPath: 'groupId' },
- { name: 'attachmentId', keyPath: 'attachmentId' }
- ],
- delayFetch: true
- }
- ]
- );
- this.fieldStore = fields;
- this.uploadStore = uploads;
-
- window.jvbUploadBlobs = this.uploadStore;
-
- // Subscribe to store events
- this.fieldStore.subscribe(this.handleFieldStoreEvent.bind(this));
- this.uploadStore.subscribe(this.handleUploadStoreEvent.bind(this));
-
- // RUNTIME DATA - DOM references and ephemeral state
- this.uploadElements = new Map(); // uploadId → { element, preview, location }
- this.fieldElements = new Map(); // fieldId → { element, ui, config }
- this.groupElements = new Map(); // groupId → { element, grid, fieldId }
-
- // Selection and UI state
- this.selected = new Map();
- this.selectionHandlers = new Map();
- this.previewUrls = new Set();
- this.sortableInstances = new Map();
-
- // Worker for image processing
- this.initWorker();
-
- // Notification subscribers
- this.subscribers = new Set();
-
- // Selectors
- this.selectors = {
- field: {
- field: '[data-upload-field]',
- input: 'input[type="file"]',
- dropZone: '.file-upload-container',
- preview: '.item-grid.preview',
- progress: '.image-progress'
- },
- groups: {
- container: '.upload-group',
- grid: '.item-grid.group',
- header: '.group-header',
- selectAll: '[name="select-all-group"]',
- actions: '.group-actions',
- count: '.selection-controls .info'
- },
- items: {
- item: '[data-upload-id]',
- checkbox: '[name*="select-item"]',
- featured: '[name="featured"]',
- details: 'details'
- }
- };
-
- this.statusMapping = {
- 'received': 'Image Received',
- 'local_processing': 'Processing Image...',
- 'queued': 'Waiting to upload...',
- 'uploading': 'Uploading to Server',
- 'pending': 'Successfully sent to server. In line for further processing.',
- 'processing': 'Processing on server...',
- 'completed': 'Upload complete!',
- 'failed': 'Upload failed (will retry)',
- 'failed_permanent': 'Upload failed permanently'
- };
-
- this.init();
- }
-
- async init() {
- // this.initializeFields();
- this.initListeners();
-
- // Queue integration - handle completion/failure
- this.queue.subscribe((event, operation) => {
- if (!['uploads', 'uploads/meta', 'uploads/groups'].includes(operation.endpoint)) {
- return;
- }
-
- const fieldId = operation.data instanceof FormData
- ? operation.data.get('fieldId')
- : operation.data?.fieldId;
-
- switch(event) {
- case 'cancel-operation':
- if (fieldId) this.handleOperationCancelled(fieldId);
- break;
- case 'operation-status':
- if (fieldId) this.updateFieldStatus(fieldId, operation.status);
- break;
- case 'operation-complete':
- this.handleOperationComplete(operation, fieldId);
- break;
- case 'operation-failed':
- case 'operation-failed-permanent':
- this.handleOperationFailed(operation, fieldId);
- break;
- }
- });
-
- window.addEventListener('beforeunload', () => {
- this.cleanupAllPreviewUrls();
- });
- }
-
- initWorker() {
- this.worker = {
- worker: null,
- timeout: null,
- tasks: new Map(),
- restart: { count: 0, max: 3 },
- settings: {
- timeout: 10000,
- batchSize: 1,
- maxConcurrent: 3,
- restartAfterTimeout: true
- }
- };
- }
-
- /*******************************************************************************
- * FIELD MANAGEMENT
- *******************************************************************************/
- scanFields(container, autoUpload) {
- console.log(autoUpload, 'autoUpload');
- const fields = container.querySelectorAll(this.selectors.field.field);
- fields.forEach(uploader => this.registerUploader(uploader, autoUpload));
- }
-
- registerUploader(uploader, autoUpload) {
- const fieldId = this.determineFieldId(uploader);
- const config = this.extractFieldConfig(uploader, autoUpload);
- const ui = this.buildFieldUI(uploader);
-
- console.log(config, 'registering with config');
- // Store field data with Sets for runtime
- const fieldData = {
- id: fieldId,
- config: config,
- uploads: new Set(),
- groups: [],
- state: 'ready',
- timestamp: Date.now()
- };
-
- // Save to store (will convert Sets to Arrays automatically)
- this.fieldStore.save(fieldData);
-
- // Store DOM references separately
- this.fieldElements.set(fieldId, { element: uploader, ui, config });
-
- uploader.dataset.uploader = fieldId;
- this.addFieldSelectionHandler(fieldId);
-
- if (config.type !== 'single') {
- this.initSortable(fieldId);
- }
-
- return fieldId;
- }
-
- extractFieldConfig(fieldElement, autoUpload) {
- return {
- autoUpload: autoUpload,
- destination: fieldElement.dataset.destination || 'meta',
- content: fieldElement.dataset.content || null,
- mode: fieldElement.dataset.mode || 'direct',
- type: fieldElement.dataset.type || 'single',
- name: fieldElement.dataset.field,
- itemID: fieldElement.dataset.itemId || 0,
- maxFiles: parseInt(fieldElement.dataset.maxFiles) || 999,
- subtype: fieldElement.dataset.subtype || 'image'
- };
- }
-
- buildFieldUI(fieldElement) {
- let UI = {
- field: fieldElement,
- input: fieldElement.querySelector(this.selectors.field.input),
- dropZone: fieldElement.querySelector(this.selectors.field.dropZone),
- preview: fieldElement.querySelector(this.selectors.field.preview),
- progress: {
- progress: fieldElement.querySelector(this.selectors.field.progress),
- bar: fieldElement.querySelector('.bar'),
- fill: fieldElement.querySelector('.fill'),
- details: fieldElement.querySelector('.details'),
- text: fieldElement.querySelector('.details .text'),
- count: fieldElement.querySelector('.details .count')
- }
- };
-
- let display = fieldElement.querySelector('.group-display');
- if (display) {
- UI.groups = {
- display: display,
- container: fieldElement.querySelector('.item-grid.groups'),
- empty: fieldElement.querySelector('.empty-group'),
- groups: new Map()
- };
- }
-
- return UI;
- }
-
- /*******************************************************************************
- * SORTABLE INITIALIZATION
- *******************************************************************************/
- initSortable(fieldId) {
- if (!window.Sortable) return;
-
- // Mount MultiDrag plugin once
- if (!Sortable._multiDragMounted && Sortable.MultiDrag) {
- Sortable.mount(new Sortable.MultiDrag());
- Sortable._multiDragMounted = true;
- }
-
- const fieldEl = this.fieldElements.get(fieldId);
- if (!fieldEl) return;
-
- // Initialize sortable on all existing grids
- const grids = fieldEl.element.querySelectorAll('.item-grid.preview, .item-grid.group');
- grids.forEach(grid => {
- const groupId = grid.classList.contains('group')
- ? grid.closest('.upload-group')?.dataset.groupId
- : null;
- this.createSortableForGrid(grid, fieldId, groupId);
- });
-
- // Special handler for empty-group
- const emptyGroup = fieldEl.element.querySelector('.empty-group');
- if (emptyGroup && !emptyGroup.sortableInstance) {
- emptyGroup.sortableInstance = new Sortable(emptyGroup, {
- animation: 150,
- draggable: '.item',
- multiDrag: true,
- selectedClass: 'selected-for-drag',
- avoidImplicitDeselect: true,
- group: { name: fieldId, pull: false, put: true },
- ghostClass: 'sortable-ghost',
- chosenClass: 'sortable-chosen',
- dragClass: 'sortable-drag',
- onEnd: (evt) => this.handleDrop(evt, fieldId)
- });
- }
- }
-
- syncSortableSelection(fieldId, selectedItems) {
- // Update Sortable's selection state to match checkboxes
- this.sortableInstances.forEach((instance, key) => {
- if (key.startsWith(fieldId)) {
- const grid = instance.el;
- const items = grid.querySelectorAll('.item');
-
- items.forEach(item => {
- const uploadId = item.dataset.uploadId;
- const shouldBeSelected = selectedItems.has(uploadId);
-
- if (shouldBeSelected) {
- Sortable.utils.select(item);
- } else {
- Sortable.utils.deselect(item);
- }
- });
- }
- });
- }
-
- handleDrop(evt, fieldId) {
- const dropTarget = evt.to;
- const sourceTarget = evt.from;
- const items = evt.items?.length > 0 ? evt.items : [evt.item];
- const uploadIds = items.map(item => item.dataset.uploadId);
-
- // Determine drop target type
- const targetType = this.getDropTargetType(dropTarget);
-
- switch (targetType) {
- case 'empty-group':
- this.handleDropToEmptyGroup(items, uploadIds, fieldId);
- break;
-
- case 'preview':
- this.handleDropToPreview(items, uploadIds, fieldId);
- break;
-
- case 'group':
- this.handleDropToGroup(items, uploadIds, dropTarget, sourceTarget, fieldId);
- break;
- default:
- // Fallback: return to preview
- this.handleDropToPreview(items, uploadIds, fieldId);
- break;
- }
-
- // Update UI
- this.updateSortableState(dropTarget);
- if (sourceTarget !== dropTarget) {
- this.updateSortableState(sourceTarget);
- }
- }
-
- /**
- * Determine what type of drop target this is
- */
- getDropTargetType(target) {
- if (target.classList.contains('empty-group')) {
- return 'empty-group';
- }
-
- if (target.classList.contains('preview')) {
- return 'preview';
- }
-
- if (target.classList.contains('group')) {
- return 'group';
- }
-
- return 'unknown';
- }
-
- /**
- * Handle drop to group: add to existing group
- */
- handleDropToGroup(items, uploadIds, dropTarget, sourceTarget, fieldId) {
- try {
- // If same container, it's just a reorder
- if (dropTarget === sourceTarget) {
- this.handleReorder({ to: dropTarget, items: items });
- return;
- }
-
- // Moving to different group
- uploadIds.forEach(uploadId => {
- this.addToGroup(uploadId, dropTarget, false);
- });
-
- this.schedulePersistance(fieldId);
-
- const message = items.length > 1
- ? `Moved ${items.length} items to group`
- : 'Moved item to group';
- this.a11y.announce(message);
-
- // Clear selection
- const handler = this.selectionHandlers.get(fieldId);
- handler?.clearSelection();
- } catch (error) {
- this.handleDropError(items, fieldId, error);
- }
- }
-
- /**
- * Handle drop to preview: remove from groups
- */
- handleDropToPreview(items, uploadIds, fieldId) {
- try {
- uploadIds.forEach(uploadId => {
- this.removeFromGroup(uploadId);
- });
-
- this.schedulePersistance(fieldId);
-
- const message = items.length > 1
- ? `Moved ${items.length} items to preview`
- : 'Moved item to preview';
- this.a11y.announce(message);
-
- // Clear selection
- const handler = this.selectionHandlers.get(fieldId);
- handler?.clearSelection();
- } catch (error) {
- this.handleDropError(items, fieldId, error);
- }
- }
-
- /**
- * Handle drop errors consistently
- */
- handleDropError(items, fieldId, error, message = 'An error occurred') {
- console.error('Drop error:', error);
-
- // Return items to preview as fallback
- const fieldEl = this.fieldElements.get(fieldId);
- if (fieldEl?.ui?.preview) {
- items.forEach(item => fieldEl.ui.preview.appendChild(item));
- }
-
- this.a11y.announce(`${message}. Items returned to preview.`);
- }
-
- /**
- * Handle drop to group: add to existing group
- */
- handleDropToEmptyGroup(items, uploadIds, fieldId) {
- try {
- const group = this.createGroup(fieldId);
- if (!group) {
- this.handleDropError(items, fieldId, new Error('Group creation failed'), 'Failed to create group');
- return;
- }
-
- // Move items to new group
- items.forEach((item, index) => {
- group.grid.appendChild(item);
- this.addToGroup(uploadIds[index], group.grid, false);
- });
-
- this.schedulePersistance(fieldId);
-
- const message = items.length > 1
- ? `Created group with ${items.length} items`
- : 'Created group with item';
- this.a11y.announce(message);
-
- // Clear selection after move
- const handler = this.selectionHandlers.get(fieldId);
- handler?.clearSelection();
- } catch (error) {
- this.handleDropError(items, fieldId, error);
- }
- }
-
- /**
- * Update sortable enabled/disabled state based on item count
- */
- updateSortableState(grid) {
- const sortable = grid?.sortableInstance;
- if (!sortable) return;
-
- // const hasItems = grid.querySelectorAll('.item').length > 0;
- sortable.option('disabled', false);
- }
-
- /**
- * Refresh sortable for a field (call after adding/removing items dynamically)
- */
- refreshSortable(fieldId) {
- const fieldEl = this.fieldElements.get(fieldId);
- if (!fieldEl) return;
-
- const grids = fieldEl.element.querySelectorAll('.item-grid.preview, .item-grid.group');
- grids.forEach(grid => this.updateSortableState(grid));
- }
-
- handleReorder(evt) {
- const grid = evt.to;
- const fieldWrapper = grid.closest('.field, .upload');
- if (!fieldWrapper) return;
-
- // Get current order from DOM
- let items = Array.from(grid.querySelectorAll('.item:not(.sortable-ghost):not(.sortable-clone)'))
- .map(upload => upload.dataset.uploadId)
- .filter(id => id);
-
-
- // Update hidden input (for form submission)
- let hiddenInput = fieldWrapper.querySelector('input[type="hidden"]');
- if (hiddenInput && items.length > 0) {
- hiddenInput.value = items.join(',');
- }
-
- // Update fieldState with new order
- const fieldId = this.getFieldIdFromElement(grid);
- if (fieldId) {
- const fieldData = this.getFieldData(fieldId);
-
- // If reordering within a group, update that group's uploads array
- if (grid.classList.contains('group')) {
- const groupId = grid.dataset.groupId;
- const group = fieldData?.groups?.find(g => g.id === groupId);
- if (group) {
- group.uploads = items; // Update order
- }
- }
- // If reordering in preview, the order is implicit by DOM position
- // (we don't store preview order separately)
-
- this.schedulePersistance(fieldId);
- }
-
- this.a11y.announce('Item reordered');
-
- fieldWrapper.dispatchEvent(new CustomEvent('jvb-items-reordered', {
- detail: {
- from: evt.from,
- to: evt.to,
- oldIndex: evt.oldIndex,
- newIndex: evt.newIndex,
- items: items
- },
- bubbles: true
- }));
- }
-
- /*******************************************************************************
- * FILE DROP HANDLERS
- *******************************************************************************/
-
- initListeners() {
- this.clickHandler = this.handleClick.bind(this);
- this.changeHandler = this.handleChange.bind(this);
-
- document.addEventListener('click', this.clickHandler);
- document.addEventListener('change', this.changeHandler);
-
- this.dragEnterHandler = this.handleExternalDragEnter.bind(this);
- this.dragLeaveHandler = this.handleExternalDragLeave.bind(this);
- this.dragOverHandler = this.handleExternalDragOver.bind(this);
- this.dropHandler = this.handleExternalDrop.bind(this);
-
- document.addEventListener('dragenter', this.dragEnterHandler);
- document.addEventListener('dragleave', this.dragLeaveHandler);
- document.addEventListener('dragover', this.dragOverHandler);
- document.addEventListener('drop', this.dropHandler);
- }
-
- handleExternalDragLeave(e) {
- const dropZone = e.target.closest(this.selectors.field.dropZone);
- if (dropZone && !dropZone.contains(e.relatedTarget)) {
- dropZone.classList.remove('dragover');
- }
- }
-
- handleExternalDragEnter(e) {
- if (!e.dataTransfer.types.includes('Files')) return;
- const dropZone = e.target.closest(this.selectors.field.dropZone);
- if (dropZone) {
- e.preventDefault();
- dropZone.classList.add('dragover');
- }
- }
-
- handleExternalDragOver(e) {
- if (!e.dataTransfer.types.includes('Files')) return;
- const dropZone = e.target.closest(this.selectors.field.dropZone);
- if (dropZone) {
- e.preventDefault();
- e.dataTransfer.dropEffect = 'copy';
- }
- }
-
- handleExternalDrop(e) {
- const dropZone = e.target.closest(this.selectors.field.dropZone);
- if (!dropZone) return;
-
- e.preventDefault();
- dropZone.classList.remove('dragover');
-
- const files = Array.from(e.dataTransfer.files);
- if (files.length === 0) return;
-
- const fieldId = this.getFieldIdFromElement(dropZone);
- if (fieldId) {
- this.processFiles(fieldId, files);
- this.a11y.announce(`${files.length} file(s) dropped for upload`);
- }
- }
-
- /*******************************************************************************
- * CLICK & CHANGE HANDLERS
- *******************************************************************************/
-
- handleClick(e) {
- // Trigger file input
- if (e.target.matches(this.selectors.field.dropZone) ||
- e.target.closest(this.selectors.field.dropZone)) {
- const dropZone = e.target.closest(this.selectors.field.dropZone);
- if (dropZone && !e.target.matches('input, button, a')) {
- const input = dropZone.querySelector(this.selectors.field.input);
- input?.click();
- }
- }
-
- // Group actions
- const actionButton = e.target.closest('[data-action]');
- if (actionButton) {
- this.handleAction(actionButton);
- }
- }
-
- handleChange(e) {
- const fieldId = this.getFieldIdFromElement(e.target);
-
- // File input change
- if (e.target.matches(this.selectors.field.input)) {
- const files = Array.from(e.target.files);
- if (files.length > 0 && fieldId) {
- this.processFiles(fieldId, files);
- }
- }
-
- // Meta field changes
- if (fieldId) {
- const fieldData = this.getFieldData(fieldId);
- if (!fieldData.config.autoUpload) {
- return;
- }
- if (fieldData?.config.destination === 'post_group') {
- this.handleGroupMetaChange(e.target);
- } else {
- this.queueUploadMeta(e);
- }
- }
- }
-
- /*******************************************************************************
- * FILE PROCESSING
- *******************************************************************************/
-
- async processFiles(fieldId, files) {
- const fieldData = this.getFieldData(fieldId);
- const fieldEl = this.fieldElements.get(fieldId);
- if (!fieldData || !fieldEl) return;
-
- // Show group display, hide upload zone
- if (fieldEl.ui.dropZone) {
- fieldEl.ui.dropZone.hidden = true;
- }
- if (fieldEl.ui.groups?.display) {
- fieldEl.ui.groups.display.hidden = false;
- }
-
- const totalFiles = files.length;
- let processedCount = 0;
-
- this.updateUploadProgress(fieldId, 0, totalFiles, 'Processing files...');
-
- const processPromises = Array.from(files).map(async (file) => {
- try {
- const uploadId = `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
-
- // Create initial upload data
- const uploadData = {
- id: uploadId,
- attachmentId: null,
- fieldId: fieldId,
- status: 'local_processing',
- groupId: null,
- meta: {
- originalName: file.name,
- size: file.size,
- type: file.type
- }
- };
-
- // Save initial data
- await this.uploadStore.save(uploadData);
-
- // Process file
- const preview = this.createPreviewUrl(file);
- const processedFile = file.type.startsWith('image/')
- ? await this.processImage(file, fieldData.config.subtype)
- : file;
-
- // Show progress
- this.showUploadProgress(uploadId, true);
- this.updateUploadItemProgress(uploadId, 50, 'local_processing');
-
- // Store blob data (this updates the existing uploadData)
- await this.saveBlobData(uploadId, processedFile || file);
-
- // Create DOM element
- const subtype = this.getSubtypeFromMime(file.type);
- const element = this.createUploadElement({
- id: uploadId,
- preview: preview,
- meta: uploadData.meta,
- subtype: subtype
- }, fieldData.config.destination === 'post_group');
-
- // Add to preview grid
- if (fieldEl.ui.preview) {
- fieldEl.ui.preview.appendChild(element);
-
- // Store runtime element data
- this.uploadElements.set(uploadId, {
- element: element,
- preview: preview,
- location: fieldEl.ui.preview
- });
- }
-
- // Update status (gets existing data with blobData intact)
- const storedUpload = this.uploadStore.get(uploadId);
- if (storedUpload) {
- storedUpload.status = 'processed';
- await this.uploadStore.save(storedUpload);
- }
-
- // Add to field
- fieldData.uploads.add(uploadId);
- await this.saveFieldData(fieldData);
-
- // Update progress
- processedCount++;
- this.updateUploadProgress(fieldId, processedCount, totalFiles, 'Processing files...');
- this.updateUploadItemProgress(uploadId, 100, 'processed');
-
- // Fade out progress
- setTimeout(() => this.showUploadProgress(uploadId, false), 1000);
-
- return uploadId;
-
- } catch (error) {
- console.error('Error processing file:', file.name, error);
- processedCount++;
- this.updateUploadProgress(fieldId, processedCount, totalFiles, 'Processing files...');
- return null;
- }
- });
-
- await Promise.all(processPromises);
-
- this.updateFieldState(fieldId);
- this.refreshSortable(fieldId);
-
- // Queue for upload if in direct mode
- if (fieldData.config.autoUpload && fieldData.config.destination !== 'post_group') {
- await this.queueUpload(fieldId);
- this.maybeLockUploads(fieldId);
- }
- }
-
- /*******************************************************************************
- * IMAGE PROCESSING
- *******************************************************************************/
-
- async processImage(file, uploadId) {
- const timeout = this.worker.settings.timeout;
-
- return new Promise((resolve, reject) => {
- let timeoutId;
- let taskCompleted = false;
-
- timeoutId = setTimeout(() => {
- if (!taskCompleted) {
- taskCompleted = true;
- this.worker.tasks.delete(uploadId);
- if (this.worker.settings.restartAfterTimeout) {
- this.restartCompressionWorker();
- }
- reject(new Error(`Processing timeout for ${file.name}`));
- }
- }, timeout);
-
- this.worker.tasks.set(uploadId, { file, timeoutId });
-
- this.handleProcess(file, uploadId)
- .then(result => {
- if (!taskCompleted) {
- taskCompleted = true;
- clearTimeout(timeoutId);
- this.worker.tasks.delete(uploadId);
- resolve(result);
- }
- })
- .catch(error => {
- if (!taskCompleted) {
- taskCompleted = true;
- clearTimeout(timeoutId);
- this.worker.tasks.delete(uploadId);
- reject(error);
- }
- });
- });
- }
-
- async handleProcess(file, uploadId) {
- if (!file.type.startsWith('image/')) {
- return file;
- }
-
- const maxDimension = this.getMaxDimension();
- const quality = 0.85;
-
- if (this.shouldUseWorker(file)) {
- try {
- if (!this.worker.worker) {
- this.initCompressionWorker();
- }
- if (this.worker.worker) {
- return await this.processWithWorker(file, uploadId, maxDimension, quality);
- }
- } catch (error) {
- console.warn('Worker processing failed, falling back to main thread:', error);
- }
- }
-
- return await this.processOnMainThread(file, maxDimension, quality);
- }
-
- async processOnMainThread(file, maxDimension, quality) {
- return new Promise((resolve, reject) => {
- const img = new Image();
- const canvas = document.createElement('canvas');
- const ctx = canvas.getContext('2d');
- let objectUrl = null;
-
- const cleanup = () => {
- img.onload = null;
- img.onerror = null;
- if (objectUrl) {
- URL.revokeObjectURL(objectUrl);
- objectUrl = null;
- }
- canvas.width = 1;
- canvas.height = 1;
- ctx.clearRect(0, 0, 1, 1);
- };
-
- img.onload = () => {
- try {
- const { width, height } = this.calculateOptimalDimensions(img, maxDimension);
- canvas.width = width;
- canvas.height = height;
-
- ctx.imageSmoothingEnabled = true;
- ctx.imageSmoothingQuality = 'high';
- ctx.drawImage(img, 0, 0, width, height);
-
- const outputFormat = this.getOptimalFormat(file);
- const outputQuality = this.getOptimalQuality(file, quality);
-
- canvas.toBlob(
- (blob) => {
- cleanup();
- if (blob) {
- const processedFile = new File(
- [blob],
- this.getProcessedFileName(file, outputFormat),
- { type: outputFormat, lastModified: Date.now() }
- );
- resolve(processedFile);
- } else {
- reject(new Error('Canvas toBlob failed'));
- }
- },
- outputFormat,
- outputQuality
- );
-
- } catch (error) {
- cleanup();
- reject(new Error(`Canvas processing failed: ${error.message}`));
- }
- };
-
- img.onerror = () => {
- cleanup();
- reject(new Error(`Failed to load image: ${file.name}`));
- };
-
- try {
- objectUrl = this.createPreviewUrl(file);
- img.src = objectUrl;
- } catch (error) {
- cleanup();
- reject(new Error(`Failed to create object URL: ${error.message}`));
- }
- });
- }
-
- getOptimalFormat(file) {
- if (file.type === 'image/gif' || file.type === 'image/svg+xml') {
- return file.type;
- }
- return this.supportsWebP() ? 'image/webp' : 'image/jpeg';
- }
-
- getOptimalQuality(file, requestedQuality) {
- if (file.size < 500 * 1024) return Math.max(requestedQuality, 0.9);
- if (file.size < 2 * 1024 * 1024) return requestedQuality;
- return Math.min(requestedQuality, 0.8);
- }
-
- getProcessedFileName(originalFile, outputFormat) {
- const baseName = originalFile.name.replace(/\.[^/.]+$/, '');
- const extensions = {
- 'image/webp': '.webp',
- 'image/jpeg': '.jpg',
- 'image/png': '.png',
- 'image/gif': '.gif'
- };
- return baseName + (extensions[outputFormat] || '.jpg');
- }
-
- getMaxDimension() {
- const screenWidth = window.screen.width;
- const devicePixelRatio = window.devicePixelRatio || 1;
- if (screenWidth * devicePixelRatio > 2560) return 2400;
- if (screenWidth * devicePixelRatio > 1920) return 1920;
- return 1200;
- }
-
- shouldUseWorker(file) {
- return this.worker.worker &&
- file.size > 1024 * 1024 &&
- typeof OffscreenCanvas !== 'undefined';
- }
-
- async processWithWorker(file, uploadId, maxDimension, quality) {
- return new Promise((resolve, reject) => {
- if (!this.worker.worker) {
- reject(new Error('Worker not available'));
- return;
- }
-
- const messageId = `${uploadId}_${Date.now()}`;
-
- const messageHandler = (e) => {
- if (e.data.messageId !== messageId) return;
-
- this.worker.worker.removeEventListener('message', messageHandler);
- this.worker.worker.removeEventListener('error', errorHandler);
-
- if (e.data.success) {
- const processedFile = new File(
- [e.data.blob],
- this.getProcessedFileName(file, e.data.format || 'image/webp'),
- { type: e.data.format || 'image/webp', lastModified: Date.now() }
- );
- resolve(processedFile);
- } else {
- reject(new Error(e.data.error || 'Worker processing failed'));
- }
- };
-
- const errorHandler = (error) => {
- this.worker.worker.removeEventListener('message', messageHandler);
- this.worker.worker.removeEventListener('error', errorHandler);
- reject(new Error(`Worker error: ${error.message}`));
- };
-
- this.worker.worker.addEventListener('message', messageHandler);
- this.worker.worker.addEventListener('error', errorHandler);
-
- this.worker.worker.postMessage({
- messageId,
- file,
- maxDimension,
- quality,
- outputFormat: this.getOptimalFormat(file)
- });
- });
- }
-
- restartCompressionWorker() {
- if (this.worker.worker) {
- this.worker.worker.terminate();
- this.worker.worker = null;
- }
- this.worker.tasks.clear();
- if (this.worker.restart.count >= this.worker.restart.max) {
- console.error('Max worker restarts reached, disabling worker');
- return;
- }
- this.worker.restart.count++;
- this.initCompressionWorker();
- }
-
- initCompressionWorker() {
- if (this.worker.worker || typeof Worker === 'undefined') return;
-
- try {
- const workerScript = `
- self.onmessage = async function(e) {
- const { messageId, file, maxDimension, quality, outputFormat } = e.data;
- try {
- const bitmap = await createImageBitmap(file);
- const scale = Math.min(maxDimension / bitmap.width, maxDimension / bitmap.height, 1);
- const width = Math.round(bitmap.width * scale);
- const height = Math.round(bitmap.height * scale);
- const canvas = new OffscreenCanvas(width, height);
- const ctx = canvas.getContext('2d');
- ctx.imageSmoothingEnabled = true;
- ctx.imageSmoothingQuality = 'high';
- ctx.drawImage(bitmap, 0, 0, width, height);
- bitmap.close();
- const blob = await canvas.convertToBlob({ type: outputFormat, quality: quality });
- self.postMessage({ messageId, success: true, blob: blob, format: outputFormat });
- } catch (error) {
- self.postMessage({ messageId, success: false, error: error.message });
- }
- };
- `;
-
- const blob = new Blob([workerScript], { type: 'application/javascript' });
- this.worker.worker = new Worker(this.createPreviewUrl(blob));
-
- } catch (error) {
- console.warn('Failed to initialize compression worker:', error);
- this.worker.worker = null;
- }
- }
-
- calculateOptimalDimensions(img, maxDimension) {
- let { width, height } = img;
- if (width <= maxDimension && height <= maxDimension) {
- return { width, height };
- }
- const scale = Math.min(maxDimension / width, maxDimension / height);
- return {
- width: Math.round(width * scale),
- height: Math.round(height * scale)
- };
- }
-
- supportsWebP() {
- const canvas = document.createElement('canvas');
- return canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0;
- }
-
- createPreviewUrl(file) {
- const url = URL.createObjectURL(file);
- if (!this.previewUrls) this.previewUrls = new Set();
- this.previewUrls.add(url);
- return url;
- }
-
- revokePreviewUrl(url) {
- if (url?.startsWith('blob:')) {
- URL.revokeObjectURL(url);
- this.previewUrls?.delete(url);
- }
- }
-
- /*******************************************************************************
- * QUEUE INTEGRATION
- *******************************************************************************/
-
- async submitUploads(fieldId) {
- const fieldData = this.getFieldData(fieldId);
- const fieldEl = this.fieldElements.get(fieldId);
- if (!fieldData?.uploads || fieldData.uploads.size === 0) {
- return;
- }
-
- let uploadIds = Array.from(fieldData.uploads);
- if (uploadIds.length === 0) {
- this.error.log('No uploads to upload', {
- component: 'UploadManager',
- action: 'submitGroupedUploads',
- fieldId: fieldId
- });
- return;
- }
-
- const fieldGroups = this.getFieldGroups(fieldId);
-
- if (fieldGroups.length === 0) {
- this.error.log('No groups created for post_group upload', {
- component: 'UploadManager',
- action: 'submitGroupedUploads',
- fieldId: fieldId
- });
- return;
- }
-
- // Build posts array from groups
- const posts = [];
- const formData = new FormData();
- let uploadMap = [];
-
- // Process each group
- for (const group of fieldGroups) {
- const post = {
- images: [],
- fields: {}
- };
-
- // Add group metadata
- for (let [name, value] of Object.entries(group.changes)) {
- post.fields[name] = value;
- }
-
- // Get uploads for this group
- const groupUploadIds = uploadIds.filter(uploadId => {
- const upload = this.uploadStore.get(uploadId);
- return upload?.groupId === group.id;
- });
-
- // Add files for this group
- for (const uploadId of groupUploadIds) {
- const file = await this.getBlobData(uploadId);
- if (file) {
- formData.append('files[]', file);
-
- const imageData = {
- upload_id: uploadId,
- index: uploadMap.length
- };
-
- // Check if featured
- const uploadEl = this.uploadElements.get(uploadId);
- const radioInput = uploadEl?.element?.querySelector('[name="featured"]');
- if (radioInput?.checked) {
- post.fields.featured = uploadId;
- }
-
- post.images.push(imageData);
- uploadMap.push(uploadId);
- }
- }
-
- posts.push(post);
- }
-
- // Handle remaining uploads (without groupId) - each becomes its own post
- const remainingUploadIds = uploadIds.filter(uploadId => {
- const upload = this.uploadStore.get(uploadId);
- return !upload?.groupId;
- });
-
- for (const uploadId of remainingUploadIds) {
- const post = {
- images: [],
- fields: {}
- };
-
- const file = await this.getBlobData(uploadId);
- if (file) {
- formData.append('files[]', file);
-
- const imageData = {
- upload_id: uploadId,
- index: uploadMap.length
- };
- post.images.push(imageData);
- uploadMap.push(uploadId);
- }
-
- posts.push(post);
- }
-
- // Add metadata to FormData
- formData.append('content', fieldData.config.content);
- formData.append('user', fieldData.config.itemID);
- formData.append('posts', JSON.stringify(posts));
- formData.append('upload_ids', JSON.stringify(uploadMap));
-
- const operation = {
- endpoint: 'uploads/groups',
- method: 'POST',
- data: formData,
- title: `Creating ${posts.length} ${fieldData.config.content}${posts.length > 1 ? 's' : ''} from uploads...`,
- popup: `Creating ${posts.length} post${posts.length > 1 ? 's' : ''}...`,
- canMerge: false,
- headers: {
- 'action_nonce': window.auth.getNonce('dash')
- },
- append: '_upload',
- };
-
- try {
- const operationId = await this.queue.addToQueue(operation);
-
- // Update upload statuses
- uploadIds.forEach(uploadId => {
- const upload = this.uploadStore.get(uploadId);
- if (upload) {
- upload.operationId = operationId;
- upload.status = 'queued';
- this.uploadStore.save(upload);
- this.updateUploadStatus(uploadId, 'queued');
- }
- });
-
- fieldData.operationId = operationId;
- await this.saveFieldData(fieldData);
-
- this.a11y.announce(`Creating ${posts.length} post${posts.length > 1 ? 's' : ''} from your uploads`);
-
- return operationId;
- } catch (error) {
- this.error.log(error, {
- component: 'UploadManager',
- action: 'submitGroupedUploads',
- fieldId: fieldId
- });
- throw error;
- }
- }
-
- async queueUpload(fieldId) {
- const fieldData = this.getFieldData(fieldId);
- if (!fieldData?.uploads || fieldData.uploads.size === 0) return;
-
- const uploads = Array.from(fieldData.uploads);
- const data = this.prepareUploadData(fieldData, uploads);
-
- this.a11y.announce('Queuing for upload');
-
- const operation = {
- endpoint: 'uploads',
- method: 'POST',
- data: data,
- title: `Uploading ${uploads.length} file${uploads.length > 1 ? 's' : ''} to server...`,
- popup: `Uploading ${uploads.length} file${uploads.length > 1 ? 's' : ''}...`,
- canMerge: false,
- headers: { 'action_nonce': window.auth.getNonce('dash') },
- append: '_upload'
- };
-
- try {
- const operationId = await this.queue.addToQueue(operation);
-
- // Update upload statuses
- uploads.forEach(uploadId => {
- const upload = this.uploadStore.get(uploadId);
- if (upload) {
- upload.operationId = operationId;
- upload.status = 'queued';
- this.uploadStore.save(upload);
- this.updateUploadStatus(uploadId, 'queued');
- }
- });
-
- fieldData.operationId = operationId;
- await this.saveFieldData(fieldData);
-
- return operationId;
- } catch (error) {
- throw error;
- }
- }
-
- async prepareUploadData(fieldData, uploads) {
- const formData = new FormData();
- formData.append('content', fieldData.config.content);
- formData.append('mode', fieldData.config.mode);
- formData.append('field_name', fieldData.config.name);
- formData.append('fieldId', fieldData.id);
- formData.append('field_type', fieldData.config.type);
- formData.append('subtype', fieldData.config.subtype);
- formData.append('item_id', fieldData.config.itemID);
- formData.append('destination', fieldData.config.destination || 'meta');
-
- let uploadMap = [];
-
-
- const blobPromises = uploads.map(async (uploadId) => {
- const upload = this.uploadStore.get(uploadId);
- if (!upload) return;
-
- const file = await this.getBlobData(uploadId);
- if (file) {
- formData.append('files[]', file);
- uploadMap.push(upload.id);
- }
- });
-
- await Promise.all(blobPromises);
-
- formData.append('upload_ids', JSON.stringify(uploadMap));
- return formData;
- }
-
- async queueUploadMeta(e) {
- const uploadId = this.getUploadIdFromElement(e.target);
- const upload = this.uploadStore.get(uploadId);
- if (!upload) return;
-
- const fieldData = this.getFieldData(upload.fieldId);
- if (!fieldData) return;
-
- let data = {};
- data[e.target.name] = e.target.value;
-
- upload.meta = { ...upload.meta, ...data };
- await this.uploadStore.save(upload);
-
- let queueData = {};
- queueData[upload.attachmentId ?? upload.id] = upload.meta;
-
- const operation = {
- endpoint: 'uploads/meta',
- method: 'POST',
- data: queueData,
- title: 'Updating meta',
- canMerge: true,
- headers: { 'action_nonce': window.auth.getNonce('dash') }
- };
-
- try {
- await this.queue.addToQueue(operation);
- } catch (error) {
- this.error.log(error, {
- component: 'UploadManager',
- action: 'sendMetaUpdate',
- uploadId: upload.id
- });
- }
- }
-
- /*******************************************************************************
- * QUEUE EVENT HANDLERS - CLEANUP AFTER SUCCESS
- *******************************************************************************/
-
- /**
- * Handle successful operation completion - CLEAR STORES
- */
- async handleOperationComplete(operation, fieldId) {
- const results = operation.result?.data || operation.serverData?.data || [];
-
- // Update upload statuses with attachment IDs
- results.forEach(result => {
- const upload = this.uploadStore.get(result.upload_id);
- if (upload) {
- upload.attachmentId = result.attachment_id;
- upload.status = 'completed';
- this.uploadStore.save(upload);
- this.updateUploadStatus(result.upload_id, 'completed');
- }
- });
-
- if (!fieldId) return;
-
- const fieldData = this.getFieldData(fieldId);
- if (!fieldData) return;
-
- // Clean up completed uploads from stores
- const completedUploads = Array.from(fieldData.uploads).filter(uploadId => {
- const upload = this.uploadStore.get(uploadId);
- return upload?.status === 'completed';
- });
-
- for (const uploadId of completedUploads) {
- await this.clearUpload(uploadId, false);
- fieldData.uploads.delete(uploadId);
- }
-
- // If all uploads complete, clear entire field from stores
- if (fieldData.uploads.size === 0) {
- await this.clearFieldFromStores(fieldId);
- this.a11y.announce('All uploads completed successfully');
- } else {
- // Otherwise just update field state
- await this.saveFieldData(fieldData);
- }
-
- this.updateFieldState(fieldId);
- }
-
- /**
- * Handle operation failure
- */
- handleOperationFailed(operation, fieldId) {
- const uploadIds = operation.data instanceof FormData
- ? JSON.parse(operation.data.get('upload_ids') || '[]')
- : operation.data.upload_ids || [];
-
- uploadIds.forEach(uploadId => {
- const upload = this.uploadStore.get(uploadId);
- if (upload) {
- upload.status = operation.status === 'operation-failed-permanent'
- ? 'failed_permanent'
- : 'failed';
- this.uploadStore.save(upload);
- this.updateUploadStatus(uploadId, upload.status);
- }
- });
-
- if (fieldId) {
- this.updateFieldState(fieldId);
- }
- }
-
- /**
- * Handle operation cancellation
- */
- async handleOperationCancelled(fieldId) {
- const fieldData = this.getFieldData(fieldId);
- if (!fieldData) return;
-
- const uploadsArray = fieldData.uploads instanceof Set
- ? Array.from(fieldData.uploads)
- : fieldData.uploads;
-
- for (const uploadId of uploadsArray) {
- await this.clearUpload(uploadId, false);
- }
-
- await this.clearFieldFromStores(fieldId);
- this.updateFieldState(fieldId);
- this.a11y.announce('Upload cancelled');
- }
-
- getFieldGroups(fieldId) {
- const fieldData = this.getFieldData(fieldId);
- if (!fieldData?.groups) return [];
-
- return fieldData.groups.map(group => ({
- id: group.id,
- uploads: group.uploads || [],
- changes: group.changes || {}
- }));
- }
-
- getSelectedRestorationUploads(notificationEl) {
- let selected = [];
- const checkboxes = notificationEl.querySelectorAll('[type=checkbox]:checked');
-
- checkboxes.forEach(checkbox => {
- const item = checkbox.closest('.item');
- if (item) {
- selected.push({
- uploadId: item.dataset.uploadId,
- fieldId: item.dataset.fieldId
- });
- }
- });
-
- return selected;
- }
-
- async restoreSelectedUploads(selectedUploads) {
- const byField = new Map();
- selectedUploads.forEach(item => {
- if (!byField.has(item.fieldId)) {
- byField.set(item.fieldId, []);
- }
- byField.get(item.fieldId).push(item.uploadId);
- });
-
- for (const [fieldId, uploadIds] of byField.entries()) {
- const fieldState = this.fieldStore.get(fieldId);
- if (fieldState) {
- fieldState.uploads = uploadIds;
- await this.restoreField(fieldState);
- }
- }
- }
-
- async restoreField(fieldState) {
- const { config, context, uploads, groups, id } = fieldState;
-
- // If in a modal, open it first
- if (context?.modalType) {
- await this.openModalForRestore(context);
- }
-
- // Find field element
- let fieldElement = document.querySelector(`.field.upload[data-field="${config.name}"]`);
-
- if (!fieldElement) {
- const uploaderKey = `${config.content}_${config.itemID}_${config.name}`;
- fieldElement = document.querySelector(`.field.upload[data-uploader="${uploaderKey}"]`);
- }
-
- if (!fieldElement) {
- console.warn(`Field ${config.name} not found for restoration`, config);
- return;
- }
-
- // Register the field if not already registered
- let fieldKey = fieldElement.dataset.uploader;
- if (!fieldKey || !this.fieldElements.has(fieldKey)) {
- fieldKey = this.registerUploader(fieldElement);
- }
-
- const fieldEl = this.fieldElements.get(fieldKey);
- const fieldData = this.getFieldData(fieldKey);
-
- if (!fieldEl || !fieldData) {
- console.error('Failed to register field for restoration');
- return;
- }
-
- // Merge saved state back into field
- fieldData.state = fieldState.state || 'ready';
-
- // Rebuild UI references if needed
- if (!fieldEl.ui) {
- fieldEl.ui = this.buildFieldUI(fieldElement);
- }
-
- if (fieldEl.ui.groups?.display) {
- fieldEl.ui.groups.display.hidden = false;
- }
- if (fieldEl.ui.dropZone) {
- fieldEl.ui.dropZone.hidden = true;
- }
-
- // Restore groups first
- if (groups && groups.length > 0) {
- await this.restoreGroups(fieldKey, groups);
- }
-
- // Handle both Array and Set for uploads
- const uploadsArray = uploads instanceof Set
- ? Array.from(uploads)
- : Array.isArray(uploads)
- ? uploads
- : [];
-
- // Restore uploads
- for (const uploadId of uploadsArray) {
- // Get upload data from store
- const uploadData = this.uploadStore.get(uploadId);
- if (uploadData) {
- await this.restoreUpload(fieldKey, uploadData);
- }
- }
-
- // Update field state
- await this.saveFieldData(fieldData);
- this.updateFieldState(fieldKey);
- this.maybeLockUploads(fieldKey);
- this.refreshSortable(fieldKey);
-
- // Queue for upload if needed
- console.log(config);
- if (config.autoUpload && config.mode === 'direct' && config.destination !== 'post_group') {
- await this.queueUpload(fieldKey);
- }
- }
-
- async restoreUpload(fieldId, uploadData) {
- const fieldEl = this.fieldElements.get(fieldId);
- const fieldData = this.getFieldData(fieldId);
-
- if (!fieldEl || !fieldData) {
- console.error('Field not found for upload restoration:', fieldId);
- return;
- }
-
- // Get reconstructed File from blob data
- const file = await this.getBlobData(uploadData.id);
-
- if (!file) {
- console.warn('Blob data not found for upload:', uploadData.id);
- return;
- }
-
- // Create preview URL
- const previewUrl = this.createPreviewUrl(file);
-
- // Recreate DOM element
- const subtype = this.getSubtypeFromMime(file.type);
- const element = this.createUploadElement({
- id: uploadData.id,
- preview: previewUrl,
- meta: uploadData.meta || {
- originalName: file.name,
- size: file.size,
- type: file.type
- },
- subtype: subtype
- }, fieldData.config.destination === 'post_group');
-
- // Determine correct location
- let location;
- if (uploadData.groupId) {
- // Check if group exists
- const groupEl = this.groupElements.get(uploadData.groupId);
- if (groupEl?.grid) {
- location = groupEl.grid;
-
- // Add to group's upload list
- const group = fieldData.groups?.find(g => g.id === uploadData.groupId);
- if (group) {
- if (!group.uploads) group.uploads = [];
- if (!group.uploads.includes(uploadData.id)) {
- group.uploads.push(uploadData.id);
- }
- }
- } else {
- // Group doesn't exist, add to preview
- location = fieldEl.ui.preview;
- uploadData.groupId = null;
- }
- } else {
- // No group, add to preview
- location = fieldEl.ui.preview;
- }
-
- // Add element to DOM
- if (location) {
- location.appendChild(element);
- } else if (fieldEl.ui.preview) {
- fieldEl.ui.preview.appendChild(element);
- location = fieldEl.ui.preview;
- }
-
- // Store runtime element data
- this.uploadElements.set(uploadData.id, {
- element: element,
- preview: previewUrl,
- location: location
- });
-
- // Add to field uploads
- if (!fieldData.uploads) fieldData.uploads = new Set();
- fieldData.uploads.add(uploadData.id);
-
- // Update upload data in store
- uploadData.status = 'processed';
- await this.uploadStore.save(uploadData);
-
- // Update sortable state for the grid
- if (location) {
- this.updateSortableState(location);
- }
- }
-
- async restoreGroups(fieldId, groups) {
- const fieldEl = this.fieldElements.get(fieldId);
- const fieldData = this.getFieldData(fieldId);
-
- if (!fieldEl || !fieldData) {
- console.error('Field not found for group restoration:', fieldId);
- return;
- }
-
- for (const groupData of groups) {
- const group = this.createGroup(fieldId, groupData.id);
- if (!group) {
- console.warn('Failed to create group:', groupData.id);
- continue;
- }
-
- const storedGroup = fieldData.groups?.find(g => g.id === groupData.id);
- if (storedGroup) {
- // Restore metadata
- if (groupData.changes) {
- storedGroup.changes = { ...groupData.changes };
- }
-
- // Preserve upload order
- if (groupData.uploads) {
- storedGroup.uploads = [...groupData.uploads];
- }
-
- // Restore form field values
- if (groupData.changes) {
- const titleInput = group.element.querySelector('[name*="post_title"]');
- const excerptInput = group.element.querySelector('[name*="post_excerpt"]');
-
- if (titleInput && groupData.changes.post_title) {
- titleInput.value = groupData.changes.post_title;
- }
- if (excerptInput && groupData.changes.post_excerpt) {
- excerptInput.value = groupData.changes.post_excerpt;
- }
- }
- }
- }
-
- await this.saveFieldData(fieldData);
- }
-
- async openModalForRestore(context) {
- if (!context) return;
-
- const { modalType, itemId } = context;
-
- // Find and click the appropriate button to open the modal
- let trigger = null;
-
- switch(modalType) {
- case 'create':
- trigger = document.querySelector('[data-action="create"]');
- break;
- case 'edit':
- // Need to find the specific edit button
- if (itemId) {
- trigger = document.querySelector(`[data-action="edit"][data-id="${itemId}"]`);
- }
- break;
- case 'bulkEdit':
- trigger = document.querySelector('[data-action="bulk-edit"]');
- break;
- }
-
- if (trigger) {
- trigger.click();
-
- // Wait for modal to open and render
- await new Promise(resolve => setTimeout(resolve, 300));
- } else {
- console.warn('Modal trigger not found for restoration:', context);
- }
- }
-
- formatBytes(bytes, decimals = 2) {
- if (bytes === 0) return '0 Bytes';
- const k = 1024;
- const dm = decimals < 0 ? 0 : decimals;
- const sizes = ['Bytes', 'KB', 'MB', 'GB'];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
- }
-
- /*******************************************************************************
- * CLEANUP METHODS - AGGRESSIVE CLEANUP AFTER SUCCESS
- *******************************************************************************/
-
- /**
- * Clear individual upload from stores (called after successful upload)
- */
- async clearUpload(uploadId, persist = true) {
- const uploadEl = this.uploadElements.get(uploadId);
- if (uploadEl) {
- this.revokePreviewUrl(uploadEl.preview);
- if (uploadEl.element) {
- const previewUrl = uploadEl.element.dataset.previewUrl;
- this.revokePreviewUrl(previewUrl);
- delete uploadEl.element.dataset.previewUrl;
- }
- }
-
- // Remove from runtime memory
- this.uploadElements.delete(uploadId);
-
- // Remove from store (no separate blob store - it's part of the upload object)
- await this.uploadStore.delete(uploadId);
-
- // Update field if needed
- if (persist) {
- const upload = this.uploadStore.get(uploadId);
- if (upload?.fieldId) {
- await this.schedulePersistance(upload.fieldId);
- }
- }
- }
-
- /**
- * Clear entire field from stores (called when all uploads complete)
- */
- async clearFieldFromStores(fieldId) {
- const fieldData = this.getFieldData(fieldId);
-
- // Clear all related uploads
- if (fieldData?.uploads) {
- const uploadsArray = fieldData.uploads instanceof Set
- ? Array.from(fieldData.uploads)
- : fieldData.uploads;
-
- for (const uploadId of uploadsArray) {
- await this.uploadStore.delete(uploadId);
- }
- }
-
- // Clear field from store
- await this.fieldStore.delete(fieldId);
-
- // Keep runtime references (fieldElements, etc) intact for reuse
- }
-
- cleanupAllPreviewUrls() {
- if (this.previewUrls) {
- this.previewUrls.forEach(url => {
- try {
- URL.revokeObjectURL(url);
- } catch (e) {
- // Ignore errors during cleanup
- }
- });
- this.previewUrls.clear();
- }
- }
-
- /*******************************************************************************
- * UI UPDATE METHODS
- *******************************************************************************/
-
- updateFieldState(fieldId) {
- const fieldEl = this.fieldElements.get(fieldId);
- const fieldData = this.getFieldData(fieldId);
- if (!fieldEl || !fieldData) return;
-
- const container = fieldEl.element;
- const uploadCount = fieldData.uploads?.size || 0;
- const hasGroups = fieldEl.ui.groups?.container?.querySelectorAll('.upload-group').length > 0;
-
- container.dataset.hasUploads = uploadCount > 0 ? 'true' : 'false';
- container.dataset.uploadCount = uploadCount.toString();
- container.dataset.hasGroups = hasGroups ? 'true' : 'false';
-
- if (fieldEl.ui.preview) {
- fieldEl.ui.preview.setAttribute('aria-label',
- `Upload preview area with ${uploadCount} item${uploadCount !== 1 ? 's' : ''}`
- );
- }
- }
-
- updateUploadProgress(fieldId, current, total, message) {
- const fieldEl = this.fieldElements.get(fieldId);
- if (!fieldEl?.ui?.progress?.progress) return;
-
- const progress = fieldEl.ui.progress;
- const percent = total > 0 ? (current / total) * 100 : 0;
-
- if (progress.fill) progress.fill.style.width = `${percent}%`;
- if (progress.text) progress.text.textContent = message;
- if (progress.count) progress.count.textContent = `${current}/${total}`;
-
- progress.progress.hidden = (current === total);
- }
-
- updateFieldStatus(fieldId, status) {
- const fieldData = this.getFieldData(fieldId);
- if (!fieldData) return;
-
- fieldData.state = status;
- this.saveFieldData(fieldData);
- }
-
- updateUploadStatus(uploadId, status) {
- const upload = this.uploadStore.get(uploadId);
- if (!upload) return;
-
- upload.status = status;
- this.uploadStore.save(upload);
- this.updateUploadUI(uploadId);
- }
-
- updateUploadUI(uploadId) {
- const uploadEl = this.uploadElements.get(uploadId);
- const upload = this.uploadStore.get(uploadId);
- if (!upload || !uploadEl?.element) return;
-
- uploadEl.element.className = uploadEl.element.className.replace(/status-[\w-]+/g, '');
- uploadEl.element.classList.add(`status-${upload.status}`);
-
- const progress = uploadEl.element.querySelector('.progress');
- if (progress) {
- this.updateUploadItemProgress(uploadId,
- this.getStatusProgress(upload.status),
- upload.status
- );
- }
- }
-
- showUploadProgress(uploadId, show = true) {
- const uploadEl = this.uploadElements.get(uploadId);
- if (!uploadEl?.element) return;
-
- const progressEl = uploadEl.element.querySelector('.progress');
- if (progressEl) {
- if (show) {
- progressEl.style.removeProperty('animation');
- progressEl.hidden = false;
- } else {
- progressEl.style.animation = 'fadeOut var(--transition-base)';
- setTimeout(() => { progressEl.hidden = true; }, 300);
- }
- }
- }
-
- updateUploadItemProgress(uploadId, percent, status = null) {
- const uploadEl = this.uploadElements.get(uploadId);
- if (!uploadEl?.element) return;
-
- const progressEl = uploadEl.element.querySelector('.progress');
- if (!progressEl) return;
-
- const fill = progressEl.querySelector('.fill');
- const details = progressEl.querySelector('.details');
- const icon = progressEl.querySelector('.icon');
-
- if (fill) fill.style.width = `${percent}%`;
- if (status && details) details.textContent = this.getStatusText(status);
- if (status && icon) icon.innerHTML = this.getStatusIcon(status).outerHTML;
- }
-
- maybeLockUploads(fieldId) {
- const fieldEl = this.fieldElements.get(fieldId);
- const fieldData = this.getFieldData(fieldId);
- if (!fieldEl?.ui?.dropZone || !fieldData) return;
-
- const uploadCount = fieldData.uploads?.size || 0;
-
- // For groupable uploads, set max to 20
- const maxFiles = fieldData.config.destination === 'post_group'
- ? 20
- : (fieldData.config?.maxFiles || 999);
-
- fieldEl.ui.dropZone.hidden = uploadCount >= maxFiles;
- fieldEl.element.classList.toggle('at-max-uploads', uploadCount >= maxFiles);
-
- // Show helpful message for groupable uploads
- if (fieldData.config.destination === 'post_group' && uploadCount >= maxFiles) {
- this.a11y.announce('Maximum of 20 uploads reached. Please submit current uploads before adding more.');
- }
- }
-
- /*******************************************************************************
- * GROUP MANAGEMENT
- *******************************************************************************/
- /**
- * Create sortable instance for a grid
- */
- createSortableForGrid(grid, fieldId, groupId = null) {
- if (!grid || grid.sortableInstance) return;
-
- const sortableInstance = new Sortable(grid, {
- animation: 150,
- draggable: '.item',
- multiDrag: true,
- selectedClass: 'selected-for-drag',
- avoidImplicitDeselect: true,
- group: { name: fieldId, pull: true, put: true },
- ghostClass: 'sortable-ghost',
- chosenClass: 'sortable-chosen',
- dragClass: 'sortable-drag',
-
- // Centralized drop handler
- onEnd: (evt) => this.handleDrop(evt, fieldId),
-
- // Selection sync
- onSelect: (evt) => {
- const checkbox = evt.item.querySelector('[name*="select-item"]');
- if (checkbox && !checkbox.checked) {
- checkbox.checked = true;
- checkbox.dispatchEvent(new Event('change', { bubbles: true }));
- }
- },
-
- onDeselect: (evt) => {
- const checkbox = evt.item.querySelector('[name*="select-item"]');
- if (checkbox && checkbox.checked) {
- checkbox.checked = false;
- checkbox.dispatchEvent(new Event('change', { bubbles: true }));
- }
- },
-
- onAdd: (evt) => this.updateSortableState(evt.to),
- onRemove: (evt) => this.updateSortableState(evt.from)
- });
-
- grid.sortableInstance = sortableInstance;
-
- const gridId = groupId
- ? `${fieldId}-group-${groupId}`
- : `${fieldId}-preview`;
-
- this.sortableInstances.set(gridId, sortableInstance);
-
- return sortableInstance;
- }
- createGroup(fieldId, groupId = null) {
- const fieldData = this.getFieldData(fieldId);
- const fieldEl = this.fieldElements.get(fieldId);
- if (!fieldData || !fieldEl) return null;
-
- if (!groupId) {
- groupId = `group_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
- }
-
- const groupElement = this.createGroupElement(groupId, fieldId);
- if (!groupElement) return null;
-
- // Store in field UI Map
- if (!fieldEl.ui.groups) {
- fieldEl.ui.groups = {
- groups: new Map(),
- container: null,
- empty: null,
- display: null
- };
- }
-
- fieldEl.ui.groups.groups.set(groupId, groupElement);
-
- // Insert into DOM
- if (fieldEl.ui.groups.container && fieldEl.ui.groups.empty) {
- fieldEl.ui.groups.container.insertBefore(groupElement, fieldEl.ui.groups.empty);
- } else if (fieldEl.ui.groups.container) {
- fieldEl.ui.groups.container.appendChild(groupElement);
- }
-
- // Store group element reference
- const grid = groupElement.querySelector('.item-grid.group');
- this.groupElements.set(groupId, {
- element: groupElement,
- grid: grid,
- fieldId: fieldId
- });
-
- // Add to field groups
- if (!fieldData.groups) fieldData.groups = [];
- const existingGroup = fieldData.groups.find(g => g.id === groupId);
- if (!existingGroup) {
- fieldData.groups.push({
- id: groupId,
- uploads: [],
- changes: {}
- });
- this.saveFieldData(fieldData);
- }
-
- // Initialize selection handler
- this.addGroupSelectionHandler(fieldId, groupId);
-
- if (grid) {
- this.createSortableForGrid(grid, fieldId, groupId);
- }
-
- return { id: groupId, element: groupElement, grid: grid };
- }
- createGroupElement(groupId, fieldId) {
- let groupElement = window.getTemplate('imageGroup');
- if (!groupElement) return;
-
- groupElement.dataset.groupId = groupId;
- groupElement.dataset.fieldId = fieldId;
-
- let fields = window.getTemplate('groupMetadata');
- const fieldsContainer = groupElement.querySelector('.fields');
- if (fieldsContainer && fields) {
- fieldsContainer.append(fields);
-
- const titleInput = fieldsContainer.querySelector('[name="post_title"]');
- const excerptInput = fieldsContainer.querySelector('[name="post_excerpt"]');
-
- if (titleInput) {
- titleInput.id = `${groupId}_title`;
- titleInput.name = `${groupId}[post_title]`;
- }
- if (excerptInput) {
- excerptInput.id = `${groupId}_excerpt`;
- excerptInput.name = `${groupId}[post_excerpt]`;
- }
-
- const fieldData = this.getFieldData(fieldId);
- if (fieldData && fieldData.config.content !== '') {
- let summary = groupElement.querySelector('summary');
- if (summary) summary.textContent = fieldData.config.content + ' Fields';
- }
- } else {
- groupElement.querySelector('details')?.remove();
- }
-
- const gridContainer = groupElement.querySelector('.item-grid.group');
- if (gridContainer) {
- gridContainer.dataset.groupId = groupId;
- }
-
- return groupElement;
- }
-
- deleteGroup(groupId, confirm = true) {
- const groupEl = this.groupElements.get(groupId);
- if (!groupEl) return;
-
- const fieldData = this.getFieldData(groupEl.fieldId);
- if (!fieldData) return;
-
- const group = fieldData.groups?.find(g => g.id === groupId);
- let keepUploads = true;
-
- if (confirm && group?.uploads?.length > 0) {
- keepUploads = !window.confirm('Delete uploads in group?');
- }
-
- if (confirm && keepUploads && group?.uploads) {
- // Move uploads back to preview
- group.uploads.forEach(uploadId => {
- this.removeFromGroup(uploadId);
- });
- }
-
- // Remove from field groups
- if (fieldData.groups) {
- fieldData.groups = fieldData.groups.filter(g => g.id !== groupId);
- this.saveFieldData(fieldData);
- }
-
- // Remove DOM element
- if (groupEl.element) {
- groupEl.element.remove();
- this.a11y.announce('Group removed');
- }
-
- // Remove from maps
- this.groupElements.delete(groupId);
-
- // Clean up sortable
- const sortableKey = `${groupEl.fieldId}-group-${groupId}`;
- const sortable = this.sortableInstances.get(sortableKey);
- if (sortable?.destroy) {
- sortable.destroy();
- }
- this.sortableInstances.delete(sortableKey);
-
- this.schedulePersistance(groupEl.fieldId);
- }
-
- addToGroup(uploadId, target = null, persist = true) {
- const upload = this.uploadStore.get(uploadId);
- const uploadEl = this.uploadElements.get(uploadId);
- if (!upload || !uploadEl) return;
-
- const fieldData = this.getFieldData(upload.fieldId);
- const fieldEl = this.fieldElements.get(upload.fieldId);
- if (!fieldData || !fieldEl) return;
-
- // Already in correct location
- if ((!target && uploadEl.location === fieldEl.ui.preview) || target === uploadEl.location) {
- return;
- }
-
- // Remove from previous group
- if (upload.groupId) {
- const group = fieldData.groups?.find(g => g.id === upload.groupId);
- if (group) {
- group.uploads = group.uploads.filter(id => id !== uploadId);
- if (group.uploads.length === 0) {
- this.deleteGroup(upload.groupId);
- }
- }
- }
-
- // Clear selection checkbox
- const checkbox = uploadEl.element.querySelector('[name*="select-item"]');
- if (checkbox) checkbox.checked = false;
-
- let featured = uploadEl.element.querySelector('[name="featured"]');
- if (featured) featured.hidden = !target;
-
- // Moving to preview or to group
- if (!target || target.classList.contains('preview')) {
- target = fieldEl.ui.preview;
- upload.groupId = null;
- } else {
- // Moving to group
- const groupId = target.dataset.groupId;
- if (featured) featured.name = groupId + '_' + featured.name;
-
- const group = fieldData.groups?.find(g => g.id === groupId);
- if (group) {
- if (!group.uploads) group.uploads = [];
- group.uploads.push(uploadId);
- upload.groupId = groupId;
- }
- }
-
- // Update location
- uploadEl.location = target;
- target.append(uploadEl.element);
-
- // Update stores
- this.uploadStore.save(upload);
- if (persist) {
- this.saveFieldData(fieldData);
- }
-
- // Update sortable state
- this.updateSortableState(target);
- if (uploadEl.location && uploadEl.location !== target) {
- this.updateSortableState(uploadEl.location);
- }
- }
-
- removeFromGroup(uploadId) {
- const upload = this.uploadStore.get(uploadId);
- const uploadEl = this.uploadElements.get(uploadId);
- if (!upload || !uploadEl) return;
-
- const fieldData = this.getFieldData(upload.fieldId);
- const fieldEl = this.fieldElements.get(upload.fieldId);
- if (!fieldData || !fieldEl) return;
-
- // Remove from current group
- if (upload.groupId) {
- const group = fieldData.groups?.find(g => g.id === upload.groupId);
- if (group) {
- group.uploads = group.uploads.filter(id => id !== uploadId);
- if (group.uploads.length === 0) {
- this.deleteGroup(upload.groupId, false);
- }
- }
- upload.groupId = null;
- }
-
- // Move back to preview
- if (fieldEl.ui?.preview) {
- fieldEl.ui.preview.appendChild(uploadEl.element);
- uploadEl.location = fieldEl.ui.preview;
- }
-
- // Hide featured radio
- const featured = uploadEl.element.querySelector('[name="featured"]');
- if (featured) {
- featured.hidden = true;
- featured.checked = false;
- }
-
- this.uploadStore.save(upload);
- this.updateSortableState(fieldEl.ui.preview);
- }
-
- removeUpload(fieldId, uploadId) {
- const fieldData = this.getFieldData(fieldId);
- const upload = this.uploadStore.get(uploadId);
- const uploadEl = this.uploadElements.get(uploadId);
-
- if (!fieldData || !upload) return;
-
- // Remove from field
- fieldData.uploads?.delete(uploadId);
-
- // Remove from group if grouped
- if (upload.groupId) {
- const group = fieldData.groups?.find(g => g.id === upload.groupId);
- if (group) {
- group.uploads = group.uploads.filter(id => id !== uploadId);
- if (group.uploads.length === 0) {
- this.deleteGroup(upload.groupId);
- }
- }
- }
-
- // Clean up element
- uploadEl?.element?.remove();
-
- // Clean up memory
- this.clearUpload(uploadId);
-
- // Update field state
- this.saveFieldData(fieldData);
- this.updateFieldState(fieldId);
- this.maybeLockUploads(fieldId);
-
- const handler = this.selectionHandlers.get(fieldId);
- if (handler) {
- handler.deselect(uploadId);
- }
-
- this.a11y.announce('Upload removed');
- }
-
- handleGroupMetaChange(input) {
- const groupEl = this.getGroupFromElement(input);
- if (!groupEl) return;
-
- const fieldData = this.getFieldData(groupEl.fieldId);
- const group = fieldData?.groups?.find(g => g.id === groupEl.element.dataset.groupId);
- if (!group) return;
-
- if (!group.changes) group.changes = {};
-
- let name = input.name;
- if (name.includes('group')) {
- name = name.replace(`${group.id}_`, '').replace(`${group.id}[`, '').replace(']', '');
- }
-
- group.changes[name] = input.value;
- this.saveFieldData(fieldData);
- this.schedulePersistance(groupEl.fieldId);
- }
-
- /*******************************************************************************
- * ACTION HANDLERS
- *******************************************************************************/
-
- handleAction(button) {
- const action = button.dataset.action;
- const fieldId = this.getFieldIdFromElement(button);
-
- switch(action) {
- case 'add-to-group':
- this.handleAddToGroup(button);
- break;
- case 'delete-group':
- this.handleDeleteGroup(button);
- break;
- case 'delete-upload':
- case 'remove-from-group':
- this.handleRemoveItem(button);
- break;
- case 'upload':
- const fieldEl = this.fieldElements.get(fieldId);
- if (fieldEl) {
- fieldEl.element.closest('details').open = false;
- document.body.classList.add('uploading');
- this.submitUploads(fieldId);
- }
- break;
- case 'restore':
- this.handleRestoreUploads().then(() => {});
- break;
- case 'restore-all':
- this.handleRestoreAll().then(() => {});
- break;
- case 'clear-cache':
- if (!confirm('Save these uploads for later?')) {
- this.cleanupStoredUploads();
- }
- this.cleanupRestore();
- break;
- }
- }
-
- handleAddToGroup(button) {
- const fieldElement = button.closest(this.selectors.field.field);
- const fieldId = fieldElement?.dataset.uploader;
- if (!fieldId) return;
-
- const selected = this.selected.get(fieldId);
-
- if (!selected || selected.size === 0) {
- this.createGroup(fieldId);
- } else {
- const group = this.createGroup(fieldId);
- if (!group) return;
-
- selected.forEach(uploadId => {
- this.addToGroup(uploadId, group.grid);
- });
-
- const handler = this.selectionHandlers.get(fieldId);
- handler?.clearSelection();
-
- this.a11y.announce(`Created group with ${selected.size} items`);
- }
-
- this.schedulePersistance(fieldId);
- }
-
- handleDeleteGroup(button) {
- const group = button.closest(this.selectors.groups.container);
- if (!group) return;
-
- const groupId = group.dataset.groupId;
- const fieldId = this.getFieldIdFromElement(group);
-
- if (!confirm('Delete this group? Items will be moved back to the upload area.')) {
- return;
- }
-
- const items = group.querySelectorAll(this.selectors.items.item);
- items.forEach(item => {
- const uploadId = item.dataset.uploadId;
- this.removeFromGroup(uploadId);
- });
-
- this.deleteGroup(groupId);
- this.a11y.announce('Group deleted, items returned to upload area');
- this.schedulePersistance(fieldId);
- }
-
- handleRemoveItem(button) {
- const item = button.closest(this.selectors.items.item);
- if (!item) return;
-
- const uploadId = item.dataset.uploadId;
- const fieldId = this.getFieldIdFromElement(item);
-
- if (!confirm('Remove this item?')) return;
-
- this.removeUpload(fieldId, uploadId);
- this.a11y.announce('Item removed');
- this.schedulePersistance(fieldId);
- }
-
- /*******************************************************************************
- * SELECTION MANAGEMENT
- *******************************************************************************/
-
- addFieldSelectionHandler(fieldId) {
- if (this.selectionHandlers.has(fieldId)) {
- return this.selectionHandlers.get(fieldId);
- }
-
- const fieldEl = this.fieldElements.get(fieldId);
- if (!fieldEl?.element) return;
-
- const handler = new window.jvbHandleSelection({
- container: fieldEl.element,
- ui: {
- selectAll: fieldEl.element.querySelector('[name="select-all-uploads"]'),
- bulkControls: fieldEl.element.querySelector('.selection-actions'),
- count: fieldEl.element.querySelector('.selection-count')
- },
- itemSelector: '[data-upload-id]',
- checkboxSelector: '[name*="select-item"]'
- });
-
- handler.subscribe((event, data) => {
- switch(event) {
- case 'item-selected':
- // Sync with Sortable
- this.syncSortableSelection(fieldId, data.selectedItems);
- this.selected.set(fieldId, data.selectedItems);
- break;
- case 'item-deselected':
- this.syncSortableSelection(fieldId, data.selectedItems);
- this.selected.set(fieldId, data.selectedItems);
- break;
- case 'range-selected':
- this.syncSortableSelection(fieldId, data.selectedItems);
- this.selected.set(fieldId, data.selectedItems);
- break;
- case 'select-all':
- this.handleSelectAll(data.container, data.selected);
- break;
- }
- });
-
- this.selectionHandlers.set(fieldId, handler);
- return handler;
- }
-
- addGroupSelectionHandler(fieldId, groupId) {
- const handlerKey = `${fieldId}_${groupId}`;
- if (this.selectionHandlers.has(handlerKey)) {
- return this.selectionHandlers.get(handlerKey);
- }
-
- const groupEl = this.groupElements.get(groupId);
- if (!groupEl?.element) return;
-
- const handler = new window.jvbHandleSelection({
- container: groupEl.element,
- ui: {
- selectAll: groupEl.element.querySelector(this.selectors.groups.selectAll),
- bulkControls: groupEl.element.querySelector(this.selectors.groups.actions),
- count: groupEl.element.querySelector(this.selectors.groups.count)
- },
- itemSelector: '[data-upload-id]',
- checkboxSelector: '[name*="select-item"]'
- });
-
- handler.subscribe((event, data) => {
- switch(event) {
- case 'item-selected':
- case 'item-deselected':
- case 'range-selected':
- this.selected.set(fieldId, data.selectedItems);
- break;
- case 'select-all':
- this.handleSelectAll(data.container, data.selected);
- break;
- }
- });
-
- this.selectionHandlers.set(handlerKey, handler);
- return handler;
- }
-
- handleSelectAll(container, selected) {
- // Can add custom logic here if needed
- }
-
- /*******************************************************************************
- * HELPER METHODS
- *******************************************************************************/
-
- /**
- * Get field data from store and normalize it
- * Always use this instead of directly accessing fieldStore.get()
- */
- getFieldData(fieldId) {
- const fieldData = this.fieldStore.get(fieldId);
- if (!fieldData) return null;
-
- // Only convert uploads back to Set (DataStore returns Arrays)
- if (Array.isArray(fieldData.uploads)) {
- fieldData.uploads = new Set(fieldData.uploads);
- } else if (!fieldData.uploads) {
- fieldData.uploads = new Set();
- }
-
- // Ensure groups is an array
- if (!Array.isArray(fieldData.groups)) {
- fieldData.groups = [];
- }
-
- return fieldData;
- }
-
- /**
- * Save field data to store, converting Sets to Arrays
- */
- async saveFieldData(fieldData) {
- await this.fieldStore.save({
- ...fieldData,
- timestamp: Date.now()
- });
- }
-
- determineFieldId(fieldElement) {
- const content = fieldElement.dataset.content+'_' ||
- fieldElement.closest('dialog')?.dataset.content+'_' ||
- fieldElement.closest('form')?.dataset.save+'_' || '';
- const itemID = fieldElement.dataset.itemId+'_' ||
- fieldElement.closest('dialog')?.dataset.itemId+'_' || '';
- const field = fieldElement.dataset.field || '';
-
- return `${content}${itemID}${field}`;
- }
-
- getFromElement(element, type) {
- const map = {
- 'field': {
- selector: this.selectors.field.field,
- key: 'uploader',
- getRuntimeData: (id) => this.fieldElements.get(id),
- getStoreData: (id) => this.getFieldData(id)
- },
- 'upload': {
- selector: this.selectors.items.item,
- key: 'uploadId',
- getRuntimeData: (id) => this.uploadElements.get(id),
- getStoreData: (id) => this.uploadStore.get(id)
- },
- 'group': {
- selector: this.selectors.groups.container,
- key: 'groupId',
- getRuntimeData: (id) => this.groupElements.get(id),
- getStoreData: (id) => {
- // Groups are stored in field.groups array
- const groupEl = this.groupElements.get(id);
- if (!groupEl) return null;
- const fieldData = this.getFieldData(groupEl.fieldId);
- return fieldData?.groups?.find(g => g.id === id);
- }
- }
- };
-
- const config = map[type];
- if (!config) return null;
-
- const el = element.closest(config.selector);
- if (!el) return null;
-
- const id = el.dataset[config.key];
-
- // Return combined runtime + store data for convenience
- const runtime = config.getRuntimeData(id);
- const store = config.getStoreData(id);
-
- return { ...runtime, ...store };
- }
-
- getFieldFromElement(el) { return this.getFromElement(el, 'field'); }
- getUploadFromElement(el) { return this.getFromElement(el, 'upload'); }
- getGroupFromElement(el) { return this.getFromElement(el, 'group'); }
-
- getFieldIdFromElement(el) {
- const field = this.getFromElement(el, 'field');
- return field?.id ?? null;
- }
- getUploadIdFromElement(el) {
- const upload = this.getFromElement(el, 'upload');
- return upload?.id ?? null;
- }
- getGroupIdFromElement(el) {
- const group = this.getFromElement(el, 'group');
- return group?.id ?? null;
- }
-
- getSubtypeFromMime(mimeType) {
- if (mimeType.startsWith('image/')) return 'image';
- if (mimeType.startsWith('video/')) return 'video';
- return 'document';
- }
-
- getStatusText(status) {
- return this.statusMapping[status] || status;
- }
-
- getStatusIcon(status) {
- return window.getIcon(this.queue.icons[status]);
- }
-
- getStatusProgress(status) {
- const progress = {
- 'local_processing': 28,
- 'queued': 50,
- 'uploading': 66,
- 'pending': 75,
- 'processing': 89,
- 'completed': 100
- };
- return progress[status] || 0;
- }
-
-
- createUploadElement(upload, draggable = false) {
- let image = window.getTemplate('uploadItem');
- if (!image) return;
-
- image.dataset.uploadId = upload.id;
- image.dataset.subtype = upload.subtype || 'image';
-
- let [featured, img, video, preview, details] = [
- image.querySelector('[name="featured"]'),
- image.querySelector('img'),
- image.querySelector('video'),
- image.querySelector('label > span'),
- image.querySelector('details')
- ];
-
- if (featured) featured.value = upload.id;
-
- switch (upload.subtype) {
- case 'image':
- if (img) {
- img.src = upload.preview;
- img.alt = upload.meta?.originalName || '';
- }
- video?.remove();
- preview?.remove();
- break;
- case 'video':
- if (video) video.src = upload.preview;
- img?.remove();
- preview?.remove();
- break;
- case 'document':
- const fileName = upload.meta?.originalName || '';
- const extension = fileName.split('.').pop()?.toLowerCase() || '';
- const iconMap = {
- 'pdf': 'file-pdf', 'csv': 'file-csv',
- 'doc': 'file-doc', 'docx': 'file-doc',
- 'txt': 'file-txt', 'xls': 'file-xls', 'xlsx': 'file-xls'
- };
- const icon = window.getIcon(iconMap[extension] || 'file');
- if (preview) {
- preview.innerText = fileName;
- preview.prepend(icon);
- }
- img?.remove();
- video?.remove();
- break;
- }
-
- if (details) {
- let template = window.getTemplate('uploadMeta');
- if (template) details.append(template);
- }
-
- image.draggable = draggable;
-
- // Update input IDs
- image.querySelectorAll('input').forEach(input => {
- let id = input.id;
- if (id) {
- let newId = id + upload.id;
- let label = input.parentNode.querySelector(`label[for="${id}"]`);
- input.id = newId;
- if (label) label.htmlFor = newId;
- }
- });
-
- return image;
- }
-
- /*******************************************************************************
- * PERSISTENCE
- *******************************************************************************/
-
- schedulePersistance(fieldId) {
- const key = `persist_${fieldId}`;
- window.debouncer.schedule(
- key,
- () => this.persistFieldState(fieldId),
- 250
- );
- }
-
- async persistFieldState(fieldId) {
- const fieldData = this.getFieldData(fieldId);
- if (!fieldData) return;
-
- // Save with updated timestamp
- await this.saveFieldData(fieldData);
- }
-
- // In UploadManager, add blob conversion helpers
- async saveBlobData(uploadId, file) {
- const arrayBuffer = await file.arrayBuffer();
-
- const uploadData = this.uploadStore.get(uploadId) || { id: uploadId };
-
- // Store blob data as ArrayBuffer with metadata
- uploadData.blobData = {
- buffer: arrayBuffer,
- name: file.name,
- type: file.type,
- size: file.size,
- lastModified: file.lastModified || Date.now()
- };
-
- await this.uploadStore.save(uploadData);
- }
-
- async getBlobData(uploadId) {
- const upload = this.uploadStore.get(uploadId);
- if (!upload?.blobData) return null;
-
- // Reconstruct File from ArrayBuffer
- const blob = new Blob([upload.blobData.buffer], { type: upload.blobData.type });
- return new File([blob], upload.blobData.name, {
- type: upload.blobData.type,
- lastModified: upload.blobData.lastModified
- });
- }
-
- /*******************************************************************************
- HELPER to GET UPLOADED FILES
- *******************************************************************************/
- /**
- * Get all files for a form (searches all upload fields within form)
- */
- async getFilesForForm(formElement) {
- const uploadFields = formElement.querySelectorAll('[data-upload-field]');
- const allFiles = [];
-
- for (const field of uploadFields) {
- const fieldId = this.determineFieldId(field);
- const files = await this.getFilesForField(fieldId);
- allFiles.push(...files);
- }
-
- return allFiles;
- }
-
- /**
- * Get all files for a specific field
- */
- async getFilesForField(fieldId) {
- const fieldData = this.getFieldData(fieldId);
- if (!fieldData?.uploads) return [];
-
- const files = [];
- const uploadsArray = fieldData.uploads instanceof Set
- ? Array.from(fieldData.uploads)
- : fieldData.uploads;
-
- for (const uploadId of uploadsArray) {
- const upload = this.uploadStore.get(uploadId);
- if (!upload) continue;
-
- // Get the actual File object from blob data
- const file = await this.getBlobData(uploadId);
- if (file) {
- files.push({
- file: file,
- uploadId: uploadId,
- fieldName: fieldData.config.name,
- meta: upload.meta || {}
- });
- }
- }
-
- return files;
- }
-
- /*******************************************************************************
- * RECOVERY & RESTORATION
- *******************************************************************************/
-
- handleFieldStoreEvent(event, data) {
- switch(event) {
- case 'data-loaded':
- this.fieldStoreReady = true;
- this.checkIfBothStoresReady();
- break;
- }
- }
-
- handleUploadStoreEvent(event, data) {
- switch(event) {
- case 'data-loaded':
- this.uploadStoreReady = true;
- this.checkIfBothStoresReady();
- break;
- case 'item-saved':
- this.showSaveIndicator(data.key);
- break;
- }
- }
-
- checkIfBothStoresReady() {
- if (this.fieldStoreReady && this.uploadStoreReady && !this.hasCheckedForUploads) {
- this.hasCheckedForUploads = true;
- this.checkForStoredUploads();
- }
- }
-
- async checkForStoredUploads() {
- const allFieldStates = this.fieldStore.getAll();
-
- const pendingFields = allFieldStates.filter(field => {
- if (!field.uploads) return false;
-
- // Handle both Set and Array (from IndexedDB)
- const uploadsArray = field.uploads instanceof Set
- ? Array.from(field.uploads)
- : Array.isArray(field.uploads)
- ? field.uploads
- : [];
-
- return uploadsArray.some(uploadId => {
- const upload = this.uploadStore.get(uploadId);
- return upload && !upload.operationId &&
- ['completed', 'processed', 'local_processing', 'processed-original'].includes(upload.status);
- });
- });
- if (pendingFields.length === 0) return;
-
- await this.showRecoveryNotification(pendingFields);
- }
-
- async showRecoveryNotification(pendingFields) {
- const totalUploads = pendingFields.reduce((sum, field) => sum + field.uploads.length, 0);
- const totalGroups = pendingFields.reduce((sum, field) =>
- sum + (field.groups?.length || 0), 0);
-
- let notification = window.getTemplate('restoreNotification');
- if (!notification) {
- console.error('Restore notification template not found');
- return;
- }
-
- // Build appropriate message
- let message;
- if (totalGroups > 0) {
- let group = totalGroups > 1 ? 'groups' : 'group';
- let upload = totalUploads > 1 ? 'uploads' : 'upload';
- message = `${totalGroups} ${group} with ${totalUploads} ${upload} can be restored.`;
- } else {
- message = `${totalUploads} upload(s) from ${pendingFields.length} field(s) can be recovered.`;
- }
-
- const detailsEl = notification.querySelector('.restore-details');
- if (detailsEl) {
- detailsEl.textContent = message;
- }
-
- // Build the restoration preview
- for (const field of pendingFields) {
- let fieldTemplate = window.getTemplate('restoreField');
- if (!fieldTemplate) continue;
-
- // Set field name/title
- const titleEl = fieldTemplate.querySelector('h3');
- if (titleEl) {
- titleEl.textContent = field.config.name || 'Unnamed Field';
- }
-
- const itemGrid = fieldTemplate.querySelector('.item-grid.restore');
-
- // Process each upload
- for (let uploadId of field.uploads) {
- const upload = this.uploadStore.get(uploadId);
- let uploadItem = window.getTemplate('uploadItem');
- if (!uploadItem) continue;
- //
- // const imgEl = uploadItem.querySelector('img');
- // const placeholderEl = uploadItem.querySelector('.image-placeholder');
- //
- const file = await this.getBlobData(upload.id);
- if (file) {
-
- try {
- // Create new blob URL from stored data
- const previewUrl = this.createPreviewUrl(file);
-
- let [
- featured,
- img,
- video,
- preview,
- details
- ] = [
- uploadItem.querySelector('[name="featured"]'),
- uploadItem.querySelector('img'),
- uploadItem.querySelector('video'),
- uploadItem.querySelector('label > span'),
- uploadItem.querySelector('details')
- ];
-
- uploadItem.dataset.uploadId = upload.id;
-
-
- uploadItem.dataset.fieldId = field.id;
-
- let subtype = this.getSubtypeFromMime(file.type);
- uploadItem.dataset.subtype = subtype;
- switch (subtype) {
- case 'image':
- [
- img.src,
- img.alt
- ] = [
- previewUrl,
- file.name ?? upload.meta?.originalName ?? ''
- ];
- video.remove();
- preview.remove();
- break;
- case 'video':
- video.src = previewUrl;
- img.remove();
- preview.remove();
- break;
- case 'document':
- let extension = '';
- let icon;
- switch (extension) {
- case 'pdf':
- icon = window.getIcon('file-pdf');
- break;
- case 'csv':
- icon = window.getIcon('file-csv');
- break;
- case 'doc':
- icon = window.getIcon('file-doc');
- break;
- case 'txt':
- icon = window.getIcon('file-txt');
- break;
- case 'xls':
- icon = window.getIcon('file-xls');
- break;
- default:
- icon = window.getIcon('file');
- break;
- }
-
- preview.innerText = upload.originalFile.name;
- preview.prepend(icon);
- img.remove();
- video.remove();
- break;
- }
-
- // Store URL for cleanup later
- uploadItem.dataset.previewUrl = previewUrl;
- } catch (error) {
- console.warn('Failed to create preview for upload:', upload.id, error);
- }
- }
-
- // Set upload metadata
- const nameEl = uploadItem.querySelector('summary span');
- if (nameEl) {
- nameEl.textContent = upload.meta?.originalName || 'Unknown file';
- }
-
- const metaEl = uploadItem.querySelector('details');
- if (metaEl && upload.meta) {
- metaEl.textContent = `${this.formatBytes(upload.meta.size)} • ${upload.meta.type}`;
- }
-
- // Update input IDs safely
- uploadItem.querySelectorAll('input').forEach(input => {
- let id = input.id;
- if (id) {
- let newId = id + upload.id;
- let label = input.parentNode.querySelector(`label[for="${id}"]`);
- input.id = newId;
- if (label) {
- label.htmlFor = newId;
- }
- }
- });
-
- if (itemGrid) {
- itemGrid.appendChild(uploadItem);
- }
- }
-
- notification.querySelector('.wrap').appendChild(itemGrid);
- }
-
- document.querySelector('.field.upload').appendChild(notification);
- notification = document.querySelector('dialog.restore-uploads');
- this.restoreModal = new window.jvbModal(notification);
- this.restoreSelection = new window.jvbHandleSelection({
- container: notification,
- ui: {
- selectAll: notification.querySelector('#select-all-restore'),
- count: notification.querySelector('.selection-count'),
- },
- });
-
- this.restoreModal.handleOpen();
-
- }
-
- async handleRestoreUploads() {
- let notification = document.querySelector('dialog.restore-uploads');
- if (!notification) {
- return;
- }
-
- const selectedUploads = this.getSelectedRestorationUploads(notification);
- if (selectedUploads.length === 0) {
- return;
- }
- await this.restoreSelectedUploads(selectedUploads);
-
- this.cleanupRestore();
- }
-
- async handleRestoreAll() {
- let notification = document.querySelector('dialog.restore-uploads');
- if (!notification) {
- return;
- }
- // Gets ALL uploads from notification without checking selection
- const allUploads = [];
- notification.querySelectorAll('.item.upload').forEach(item => {
- let uploadId = item.dataset.uploadId;
- let fieldId = item.dataset.fieldId;
- allUploads.push({ uploadId, fieldId });
- });
-
- await this.restoreSelectedUploads(allUploads);
- this.cleanupRestore();
- }
-
- showSaveIndicator(key) {
- // Optional: show user that state is being saved
- }
-
- cleanupRestore() {
- this.restoreModal.handleClose();
- this.restoreSelection.destroy();
- this.restoreSelection = null;
- this.restoreModal.destroy();
- this.restoreModal.modal.remove();
- this.restoreModal = null;
- }
-
- async cleanupStoredUploads() {
- await this.fieldStore.clear();
- await this.uploadStore.clear();
- }
-
- /*******************************************************************************
- * EVENT SYSTEM
- *******************************************************************************/
-
- subscribe(callback) {
- this.subscribers.add(callback);
- return () => this.subscribers.delete(callback);
- }
-
- notify(event, data = {}) {
- this.subscribers.forEach(cb => {
- try {
- cb(event, data);
- } catch (error) {
- console.error('Subscriber error:', error);
- }
- });
- }
-
- /*******************************************************************************
- * DESTROY & CLEANUP
- *******************************************************************************/
-
- destroy() {
- document.removeEventListener('click', this.clickHandler);
- document.removeEventListener('change', this.changeHandler);
- document.removeEventListener('dragenter', this.dragEnterHandler);
- document.removeEventListener('dragleave', this.dragLeaveHandler);
- document.removeEventListener('dragover', this.dragOverHandler);
- document.removeEventListener('drop', this.dropHandler);
-
- if (this.dragController) {
- this.dragController.destroy();
- }
-
- this.selectionHandlers.forEach(handler => handler.destroy());
- this.selectionHandlers.clear();
-
- this.cleanupAllPreviewUrls();
-
- this.sortableInstances.forEach(instance => {
- if (instance?.destroy) instance.destroy();
- });
- this.sortableInstances.clear();
-
- this.uploadElements.clear();
- this.fieldElements.clear();
- this.groupElements.clear();
- this.selected.clear();
- this.subscribers.clear();
- }
-}
-
-// Initialize when DOM is ready
-document.addEventListener('DOMContentLoaded', async function () {
- window.auth.subscribe((event) => {
- if (event === 'auth-loaded') {
- window.jvbUploads = new UploadManager();
- }
- });
-});
diff --git a/assets/js/min/creator.min.js b/assets/js/min/creator.min.js
index 8ea287c..a08c3d8 100644
--- a/assets/js/min/creator.min.js
+++ b/assets/js/min/creator.min.js
@@ -1 +1 @@
-window.jvbTaxCreator=class{constructor(e){this.selector=e,e.modal&&(this.createNew=e.modal.querySelector(".create-new-term"),this.toggle=e.modal.querySelector(".new-term-toggle"),this.form=this.createNew?.querySelector(".create-new-term-section")),this.initListeners(),this.form&&this.initTermCreation()}initListeners(){this.clickHandler=this.handleClick.bind(this),document.addEventListener("click",this.clickHandler)}handleClick(e){window.targetCheck(e,".create-new-term summary")&&(this.createNew.open&&this.createNew.querySelector('input[name="term_name"]').focus(),this.resetParentOptions()),window.targetCheck(e,".submit-term")&&this.handleTermCreation(e).then((()=>{})),window.targetCheck(e,".create-term")&&this.handleAutocompleteCreate(e).then((()=>{}))}async handleTermCreation(e){const t=this.selector.currentConfig?.taxonomy;if(!t)return;const r=this.form.querySelector('input[name="term_name"]').value.trim(),n=parseInt(this.form.querySelector("input#select_parent")?.value)||0;if(!r)return;const s=this.form.querySelector("button");try{s&&(s.disabled=!0);const e=await this.createTerm(r,n,t);e.success&&e.term&&(await this.handleSuccessfulCreation(e.term,t,n),this.clearForm())}catch(e){console.error("Error creating term:",e),this.selector.handleError(e,"handleTermCreation")}finally{s&&(s.disabled=!1)}}async handleSuccessfulCreation(e,t,r){const n=e.path||e.name;this.createNew.open=!1,await this.selector.store.clearCache(),this.selector.store.data.set(e.id,{id:e.id,name:e.name,path:n,taxonomy:t,parent:r,count:0,hasChildren:!1,slug:e.slug||termName.toLowerCase().replace(/\s+/g,"-")}),this.selector.addSelectedTermToModal(e.id,e.name,n),(this.selector.store.filters.parent||0)===r&&await this.selector.store.setFilters({taxonomy:t,parent:r,page:1,search:""})}async handleAutocompleteCreate(e){const t=e.target.closest(".create-term"),r=this.selector.getFieldId(t),n=this.selector.fields.get(r);if(!n)return;const s=n.container.querySelector("input[data-autocomplete]"),a=s?.value.trim()||t.dataset.query;if(!a)return;const o=t.innerHTML;try{t.disabled=!0,t.textContent="Creating...";const e=await this.createTerm(a,0,n.taxonomy);e.success&&e.term?await this.handleAutocompleteSuccess(e.term,n,s):"exists"===e.reason&&e.term&&this.handleExistingTerm(e.term,n,s)}catch(e){console.error("Error creating term:",e),this.selector.handleError(e,"handleAutocompleteCreate")}finally{t.innerHTML=o,t.disabled=!1}}async handleAutocompleteSuccess(e,t,r){const n=e.path||e.name;t.selectedTerms.add(parseInt(e.id)),this.selector.store.data.set(e.id,{id:e.id,name:e.name,path:n,taxonomy:t.taxonomy,parent:0,count:0,hasChildren:!1,slug:e.slug||e.name.toLowerCase().replace(/\s+/g,"-")}),this.selector.addTermDisplay(e.id,e.name,n,"field",t.id),t.input.value=Array.from(t.selectedTerms).join(","),t.input.dispatchEvent(new Event("change",{bubbles:!0})),t.autocompleteDropdown.hidden=!0,r&&(r.value=""),await this.selector.store.clearCache()}handleExistingTerm(e,t,r){t.selectedTerms.add(parseInt(e.id)),this.selector.addTermDisplay(e.id,e.name,e.path||e.name,"field",t.id),t.input.value=Array.from(t.selectedTerms).join(","),t.input.dispatchEvent(new Event("change",{bubbles:!0})),t.autocompleteDropdown.hidden=!0,r&&(r.value="")}initTermCreation(){this.form&&this.form.addEventListener("change",(e=>{e.preventDefault(),e.stopPropagation()}))}resetParentOptions(){const e=this.selector.currentConfig?.taxonomy;if(!e)return;let t=this.createNew.querySelector("#select_parent");if(!t)return;let r=t.querySelector("option");if(!r)return;window.removeChildren(t),t.append(r.cloneNode(!0));const n=this.selector.store.filters.parent||0;if(0!==n){const e=this.selector.store.data.get(n);if(e){let n=r.cloneNode(!0);n.value=e.id,n.textContent=e.name,t.append(n)}}const s=[];this.selector.store.data.forEach((t=>{t.taxonomy===e&&t.parent===n&&s.push(t)})),s.sort(((e,t)=>e.name.localeCompare(t.name))),s.forEach((e=>{let n=r.cloneNode(!0);n.id=`select-parent-${e.id}`,n.value=e.id,n.textContent=" — "+e.name,t.append(n)}))}async createTerm(e,t=0,r){try{await this.selector.store.setFilters({taxonomy:r,search:e,page:1,parent:0}),await new Promise((e=>setTimeout(e,100)));const n=Array.from(this.selector.store.data.values()).find((t=>t.taxonomy===r&&t.name.toLowerCase()===e.toLowerCase()));if(n)return this.createNew&&this.showTermSuggestions([n],!0),{success:!1,reason:"exists",term:n};const s=await fetch(`${jvbSettings.api}terms`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify({taxonomy:r,name:e,parent:t})});if(!s.ok)throw new Error(`Server error: ${s.status}`);return await s.json()}catch(e){throw console.error("Error creating term:",e),e}}showTermSuggestions(e,t=!1){const r=this.createNew.querySelector(".term-suggestions")||this.createSuggestionContainer();window.removeChildren(r);const n=document.createElement("h4");n.textContent=t?"This term already exists:":"Similar terms already exist:",r.appendChild(n);const s=document.createElement("ul");s.className="term-suggestion-list",e.forEach((e=>{const t=document.createElement("li"),r=this.createSuggestionButton(e);t.appendChild(r),s.appendChild(t)})),r.appendChild(s),r.hidden=!1}createSuggestionButton(e){const t=document.createElement("button");return t.type="button",t.className="use-existing-term",t.dataset.id=e.id,t.textContent=e.path||e.name,t.addEventListener("click",(()=>{this.selector.addSelectedTermToModal(e.id,e.name,e.path||e.name),this.createNew.open=!1;const t=this.createNew.querySelector(".term-suggestions");t&&(t.hidden=!0),this.clearForm()})),t}createSuggestionContainer(){const e=document.createElement("div");return e.className="term-suggestions",e.hidden=!0,this.createNew.querySelector("form").after(e),e}clearForm(){const e=this.form.querySelector('input[name="term_name"]');e&&(e.value="");const t=this.createNew.querySelector(".term-suggestions");t&&(t.hidden=!0)}destroy(){this.clickHandler&&document.removeEventListener("click",this.clickHandler);const e=this.createNew?.querySelector(".loading-message.create-term");e&&(e.hidden=!0);const t=this.createNew?.querySelector(".term-suggestions");t&&(t.hidden=!0)}};
\ No newline at end of file
+window.jvbTaxCreator=class{constructor(e){this.selector=e,this.queue=window.jvbQueue,this.initElements(),this.initListeners()}initElements(){this.selectors={details:"details.create-new",parent:"#select_parent",summary:".create-new summary",suggestion:".term-suggestions",name:"#term_name",button:".submit-term",form:"form.create-term",label:{name:'[for="term_name"]',parent:'[for="select_parent"]'},loading:".loading-message.create-term"},this.ui=window.uiFromSelectors(this.selectors,this.selector.container)}initListeners(){this.clickHandler=this.handleClick.bind(this),document.addEventListener("click",this.clickHandler),this.ui.form&&this.ui.form.addEventListener("change",(e=>{e.preventDefault(),e.stopPropagation()}))}handleClick(e){window.targetCheck(e,this.selectors.summary)&&(this.ui.details.open&&this.ui.name?.focus(),this.resetParentOptions()),window.targetCheck(e,this.selectors.button)&&this.handleTermCreation(e).then((()=>{}))}async handleTermCreation(e){const t=this.selector.currentField().taxonomy;if(!t)return;let r={parent:0,taxonomy:t};if(this.selector.container.open?(r.name=this.ui.name.value.trim(),r.parent=parseInt(this.ui.parent?.value??0)):this.selector.activeField&&(r.name=this.selector.store.query),r.name&&!(r.name.length<2))try{this.ui.button&&(this.ui.button.disabled=!0);const e=await this.createTerm(r);e.success&&e.term&&(await this.handleSuccessfulCreation(e.term,r),this.clearForm())}catch(e){console.error("Error creating term:",e),this.selector.handleError(e,"handleTermCreation")}finally{this.ui.button&&(this.ui.button.disabled=!1)}}async handleSuccessfulCreation(e,t){const r=e.path||e.name;this.ui.details.open=!1,await this.selector.store.clearCache(),this.selector.store.data.set(e.id,{id:e.id,name:e.name,path:r,taxonomy:t.taxonomy,parent:t.parent,count:0,hasChildren:!1,slug:e.slug||e.name.toLowerCase().replace(/\s+/g,"-")}),this.selector.addSelected(e.id,this.selector.activeField),this.selector.container.open&&(this.selector.store.filters.parent||0)===t.parent&&await this.selector.store.setFilters({taxonomy:t.taxonomy,parent:t.parent,page:1,search:""})}resetParentOptions(){const e=this.selector.currentField();if(!e)return;const t=e.taxonomy;if(!t)return;if(!this.ui.parent)return;let r=this.ui.parent.querySelector("option");if(!r)return;window.removeChildren(this.ui.parent),this.ui.parent.append(r.cloneNode(!0));const i=this.selector.store.filters.parent||0;if(0!==i){const e=this.selector.store.get(i);if(e){let t=r.cloneNode(!0);t.value=e.id,t.textContent=e.name,this.ui.parent.append(t)}}const n=[];this.selector.store.getFiltered().forEach((e=>{e.taxonomy===t&&e.parent===i&&n.push(e)})),n.sort(((e,t)=>e.name.localeCompare(t.name))),n.forEach((e=>{let t=r.cloneNode(!0);t.id=`select-parent-${e.id}`,t.value=e.id,t.textContent=" — "+e.name,this.ui.parent.append(t)}))}async createTerm(e){if(e.name&&void 0!==e.parent&&e.taxonomy)try{const t=await fetch(`${jvbSettings.api}terms`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify(e)});if(!t.ok)throw new Error(`Server error: ${t.status}`);return await t.json()}catch(e){throw console.error("Error creating term:",e),e}}clearForm(){this.ui.name&&(this.ui.name.value="")}destroy(){this.clickHandler&&document.removeEventListener("click",this.clickHandler),this.ui.loading&&(this.ui.loading.hidden=!0)}};
\ No newline at end of file
diff --git a/assets/js/min/selector.min.js b/assets/js/min/selector.min.js
index 74ecd45..24e49c7 100644
--- a/assets/js/min/selector.min.js
+++ b/assets/js/min/selector.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.index=-1,this.isInitializing=!0,this.taxonomiesToFetch=new Set,this.subscribers=new Set;const e=window.jvbStore.register("taxonomies",{storeName:"terms",keyPath:"id",showLoading:!1,indexes:[{name:"taxonomy",keyPath:"taxonomy"},{name:"parent",keyPath:"parent"},{name:"slug",keyPath:"slug",unique:!0},{name:"count",keyPath:"count"}],endpoint:"terms",TTL:12e4,filters:{taxonomy:"",page:1,search:"",parent:0},required:"taxonomy",delayFetch:!0});this.store=e.terms,this.fields=new Map,this.selectedTerms=new Map,this.activeField=null,this.currentConfig=null,this.disabled=!1,this.searchContexts=new Map,this.init()}init(){this.initModal(),this.scanExistingFields(),this.initGlobalListeners(),this.needsCreator()&&window.jvbTaxCreator&&(this.creator=new window.jvbTaxCreator(this)),this.store.subscribe(this.handleStoreEvent.bind(this)),this.isInitializing=!1,this.batchFetchTaxonomies()}needsCreator(){return Array.from(this.fields.values()).some((e=>e.canCreate||e.hasAutocomplete))}handleStoreEvent(e,t){const i={"data-loaded":()=>this.handleDataLoaded(t),"filters-changed":()=>this.handleFiltersChanged(t),"fetch-error":()=>this.handleFetchError(t.error)};i[e]?.()}handleDataLoaded(e){const t=this.store.filters.taxonomy;if(t){(t.includes(",")?t.split(",").map((e=>e.trim())):[t]).forEach((e=>this.updateFieldsForTaxonomy(e)))}this.isInitializing&&this.fields.forEach(((e,t)=>{e.selectedTerms.size>0&&this.initFieldDisplay(t)})),this.renderSearchResults(e)}renderSearchResults(e){const t=this.getActiveSearchContext();"modal"===t?this.renderModalResults(e):"autocomplete"===t&&this.renderAutocompleteResults(e)}getActiveSearchContext(){return this.modal?.open?"modal":this.activeField&&this.searchContexts.has(this.activeField)?this.searchContexts.get(this.activeField):null}renderModalResults(e){this.hideLoading();const t=this.store.getFiltered(),i=this.store.lastResponse?.page||{},s=e.filters?.search?.length>0,o=i.page>1;this.notify("terms-loaded",{terms:t,filters:e.filters}),0===t.length?(o||this.showEmptyState(s?"No results found.":"No items available."),this.observer.unobserve(this.ui.sentinel)):(this.renderTerms(t,o,s),i.has_more?this.observer.observe(this.ui.sentinel):this.observer.unobserve(this.ui.sentinel)),this.a11y?.announce(t.length,o)}renderAutocompleteResults(e){const t=this.fields.get(this.activeField);if(!t?.autocompleteDropdown)return;const i=this.store.getFiltered(),s=e.filters?.search||"";this.showAutocompleteResults(t,i,s),this.searchContexts.delete(this.activeField)}handleFiltersChanged(e){this.modal?.open&&this.showLoading()}handleFetchError(e){this.hideLoading();"autocomplete"===this.getActiveSearchContext()?(this.showAutocompleteError(this.activeField),this.searchContexts.delete(this.activeField)):this.handleError(e,"fetch")}updateFieldsForTaxonomy(e){this.getFieldsForTaxonomy(e).forEach((e=>{this.updateFieldButtonState(e.id)}))}updateFieldButtonState(e){const t=this.fields.get(e);if(!t)return;const i=Array.from(this.store.data.values()).some((e=>e.taxonomy===t.taxonomy));t.toggle&&(t.toggle.disabled=!i&&!t.canCreate,t.toggle.title=i?`Select ${this.getLabel(t.taxonomy,"plural")}`:`No ${this.getLabel(t.taxonomy,"single")} available`)}getFieldsForTaxonomy(e){return Array.from(this.fields.values()).filter((t=>t.taxonomy===e))}scanExistingFields(e=document.body){e.querySelectorAll(".field.taxonomy, .field.post").forEach((e=>{try{this.registerField(e)}catch(t){this.handleError(t,"scanExistingFields",e.dataset.name)}}))}registerField(e){const t=e.querySelector("input[type=hidden]");if(!t)return!1;const i=this.createFieldId(e);e.dataset.fieldId=i;const s=e.querySelector("button.taxonomy-toggle"),o={id:i,input:t,container:e,taxonomy:s.dataset.taxonomy,name:e.dataset.field,maxSelection:parseInt(s.dataset.max)||0,canSearch:"search"in s.dataset,hasAutocomplete:"autocomplete"in s.dataset,autocompleteDropdown:e.querySelector(".autocomplete-dropdown")||null,canCreate:"creatable"in s.dataset,isRequired:"required"in s.dataset,selectedTerms:new Set,toggle:s,selectedContainer:e.querySelector(".selected-items")},a=t.value.trim();return a&&a.split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e))).forEach((e=>o.selectedTerms.add(e))),this.fields.set(i,o),this.isInitializing&&this.taxonomiesToFetch.add(o.taxonomy),o.selectedTerms.size>0&&this.initFieldDisplay(i),i}createFieldId(e){return this.index++,"selector-"+this.index}async initFieldDisplay(e){const t=this.fields.get(e);t&&0!==t.selectedTerms.size&&Array.from(t.selectedTerms).forEach((t=>{const i=this.store.get(t);i&&this.addTermDisplay(t,i.name,i.path,"field",e)}))}initModal(){this.modal=document.querySelector("dialog#jvb-selector"),this.modal?(this.initModalElements(),this.modalInstance=new window.jvbModal(this.modal,{handleForm:!1}),this.modalInstance.subscribe((e=>{"modal-open"===e&&this.openModal(),"modal-close"===e&&this.closeModal()}))):console.warn("Taxonomy selector modal not found")}initModalElements(){this.ui=window.uiFromSelectors({search:{input:"[type=search]",container:".search-wrapper"},termsList:".items-container",termsWrap:".items-wrap",breadcrumbs:{nav:"nav.term-navigation",back:".back-to-parent"},loading:{loading:".loading",text:".loading span"},selectedTerms:".selected-items",sentinel:".scroll-sentinel",modal:{title:"#modal-title"},create:{details:".create-new-term",summary:".create-new-term summary",label:{name:"[for=term_name]",parent:"[for=select_parent]"}}}),this.observer=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&this.loadMoreTerms()}))}),{root:this.ui.termsWrap,threshold:.5})}initGlobalListeners(){document.addEventListener("click",this.handleClick.bind(this)),document.addEventListener("change",this.handleChange.bind(this)),document.addEventListener("input",this.handleInput.bind(this)),document.addEventListener("focus",this.handleFocus.bind(this),!0),document.addEventListener("blur",this.handleBlur.bind(this),!0)}handleClick(e){if(window.targetCheck(e,".taxonomy-toggle")){e.preventDefault();const t=this.getFieldId(e.target);return void(this.fields.get(t)&&this.setActiveField(t,!0))}const t=window.targetCheck(e,"button.remove-item");if(t&&e.target.closest(".jvb-selector")){const e=this.getFieldId(t),i=t.closest(".selected-item").dataset.id;this.removeSelectedTerm(e,i)}else e.target.matches(".modal-close")?this.modalInstance?.handleClose():this.modal?.contains(e.target)&&this.handleModalClick(e)}handleChange(e){if(window.targetCheck(e,".taxonomy.field, .post.field")&&"hidden"===e.target.type){const t=this.getFieldId(e.target);this.updateFieldFromInput(t)}else this.modal?.contains(e.target)&&this.handleModalChange(e)}handleInput(e){if(this.modal?.contains(e.target)&&"search"===e.target.type)this.performSearch(e.target.value.trim(),"modal");else if("autocomplete"in e.target.dataset){const t=this.getFieldId(e.target),i=this.fields.get(t);i?.hasAutocomplete&&this.performSearch(e.target.value.trim(),"autocomplete",t)}}handleFocus(e){if(!("autocomplete"in e.target.dataset))return;const t=this.getFieldId(e.target),i=this.fields.get(t);i?.hasAutocomplete&&this.preloadTaxonomy(i.taxonomy)}handleBlur(e){"autocomplete"in e.target.dataset&&setTimeout((()=>{const t=this.getFieldId(e.target),i=this.fields.get(t);i?.autocompleteDropdown&&(i.autocompleteDropdown.hidden=!0),this.searchContexts.delete(t)}),200)}performSearch(e,t="modal",i=null){const s="autocomplete"===t?this.fields.get(i):this.currentConfig;if(s){if("autocomplete"===t){if(s.currentAutocompleteQuery=e,e.length<2)return void(s.autocompleteDropdown&&(s.autocompleteDropdown.hidden=!0));this.searchContexts.set(i,"autocomplete"),this.activeField=i,s.autocompleteDropdown&&(s.autocompleteDropdown.hidden=!1)}window.debouncer.schedule(`taxonomy-search-${t}-${i||"modal"}`,(async()=>{await this.store.setFilters({taxonomy:s.taxonomy,search:e,page:1,parent:e?0:this.store.filters.parent||0}),"modal"===t&&window.removeChildren(this.ui.termsList)}),300)}}setActiveField(e,t=!1){this.activeField=e,this.currentConfig=this.fields.get(e),t&&this.modalInstance.handleOpen(),this.store.setFilter("taxonomy",this.currentConfig.taxonomy),this.selectedTerms.clear(),this.currentConfig.selectedTerms.forEach((e=>{const t=this.store.get(e);t&&this.selectedTerms.set(e,{id:e,name:t.name,path:t.path})}))}handleModalClick(e){if(window.targetCheck(e,".remove-item")){const t=window.targetCheck(e,".selected-item");t&&this.removeSelectedTermFromModal(t.dataset.id)}else if(window.targetCheck(e,".back-to-parent"))this.navigateToParent();else if(window.targetCheck(e,".toggle-children")){const t=e.target.closest("li");this.navigateToChild(parseInt(t.dataset.id),t.querySelector(".term-name").textContent)}else if(window.targetCheck(e,".path-level")){const t=window.targetCheck(e,".path-level");this.navigateToPath(parseInt(t.dataset.id)||0)}}handleModalChange(e){if("checkbox"!==e.target.type)return;e.preventDefault(),e.stopPropagation();const t=parseInt(e.target.closest("li").dataset.id),i=e.target.closest("li").querySelector("label");e.target.checked?this.addSelectedTermToModal(t,i.title,i.dataset.path):this.removeSelectedTermFromModal(t)}openModal(){this.currentConfig?(this.updateModalUI(),this.updateModalSelections(),window.removeChildren(this.ui.termsList),this.showLoading()):console.error("No active field set")}closeModal(){this.observer.unobserve(this.ui.sentinel),window.removeChildren(this.ui.termsList),this.notify("selected-terms",{terms:this.selectedTerms,taxonomy:this.currentConfig.taxonomy}),this.activeField&&this.saveSelectionsToField(this.activeField),this.activeField=null,this.currentConfig=null}updateModalUI(){const e=this.getLabel(this.currentConfig.taxonomy,"single"),t=this.getLabel(this.currentConfig.taxonomy,"plural");this.ui.modal.title.textContent=`Select ${t}`,this.ui.search.container&&(this.ui.search.container.style.display=this.currentConfig.canSearch?"block":"none"),this.ui.create.details&&(this.ui.create.details.style.display=this.currentConfig.canCreate?"block":"none",this.ui.create.details.hidden=!this.currentConfig.canCreate,this.ui.create.summary&&(this.ui.create.summary.textContent=`Add new ${e}`),this.ui.create.label.name&&(this.ui.create.label.name.textContent=`Name this ${e}`),this.ui.create.label.parent&&(this.ui.create.label.parent.textContent="Nest it under")),this.a11y?.announce(`Opened ${e} selection. Choose from checkboxes or search to filter results.`)}updateModalSelections(){window.removeChildren(this.ui.selectedTerms),this.selectedTerms.forEach(((e,t)=>{this.addTermDisplay(t,e.name,e.path,"modal")})),this.checkSelectionLimits()}addSelectedTermToModal(e,t,i){this.selectedTerms.set(e,{id:e,name:t,path:i}),this.addTermDisplay(e,t,i,"modal"),this.checkSelectionLimits();const s=this.ui.termsList.querySelector(`input[value="${e}"]`);s&&(s.checked=!0)}removeSelectedTermFromModal(e){this.selectedTerms.delete(parseInt(e));const t=this.ui.selectedTerms.querySelector(`[data-id="${e}"]`);t&&t.remove();const i=this.ui.termsList.querySelector(`input[value="${e}"]`);i&&(i.checked=!1),this.checkSelectionLimits()}checkSelectionLimits(){this.currentConfig&&0!==this.currentConfig.maxSelection&&(this.disabled=this.selectedTerms.size>=this.currentConfig.maxSelection,this.ui.termsList.querySelectorAll('input[type="checkbox"]').forEach((e=>{e.checked||(e.disabled=this.disabled)})))}saveSelectionsToField(e){const t=this.fields.get(e);t&&(t.selectedTerms.clear(),window.removeChildren(t.selectedContainer),this.selectedTerms.forEach(((i,s)=>{t.selectedTerms.add(s),this.addTermDisplay(s,i.name,i.path,"field",e)})),t.input.value=Array.from(t.selectedTerms).join(","),t.input.dispatchEvent(new Event("change",{bubbles:!0})))}addTermDisplay(e,t,i,s="field",o=null){const a="field"===s?this.fields.get(o):this.currentConfig,n="field"===s?a.selectedContainer:this.ui.selectedTerms;if(n.querySelector(`[data-id="${e}"]`))return;const r=window.getTemplate("selectedTerm");if(r.dataset.id=e,r.dataset.path=i,r.dataset.name=t,r.dataset.taxonomy=a.taxonomy,r.querySelector(".item-name").textContent=i,r.querySelector("button").title=`Remove ${t}`,n.appendChild(r),"modal"===s){const t=this.ui.termsList.querySelector(`input[value="${e}"]`);t&&(t.checked=!0)}}removeSelectedTerm(e,t){const i=this.fields.get(e);if(!i)return;i.selectedTerms.delete(parseInt(t));const s=i.selectedContainer.querySelector(`[data-id="${t}"]`);s&&s.remove(),i.input.value=Array.from(i.selectedTerms).join(","),i.input.dispatchEvent(new Event("change",{bubbles:!0}))}updateFieldFromInput(e){const t=this.fields.get(e);if(!t)return;const i=t.input.value.trim();t.selectedTerms.clear(),window.removeChildren(t.selectedContainer),i&&(i.split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e))).forEach((e=>t.selectedTerms.add(e))),this.initFieldDisplay(e))}navigateToParent(){this.store.setFilters({parent:0,page:1}),window.removeChildren(this.ui.termsList),this.ui.breadcrumbs.back.hidden=!0}navigateToChild(e,t){this.store.setFilters({parent:e,page:1}),window.removeChildren(this.ui.termsList),this.updateBreadcrumbs(e,t),this.ui.breadcrumbs.back.hidden=!1}navigateToPath(e){this.store.setFilters({parent:e,page:1}),window.removeChildren(this.ui.termsList),this.ui.breadcrumbs.back.hidden=0===e}loadMoreTerms(){const e=this.store.filters.page||1;this.store.setFilter("page",e+1)}updateBreadcrumbs(e,t){const i=window.getTemplate("termBreadcrumb");i.dataset.id=e,i.textContent=t,i.title=t;const s=this.ui.breadcrumbs.nav.querySelector(`[data-id="${e}"]`);if(s)for(;s.nextElementSibling;)s.nextElementSibling.remove();else this.ui.breadcrumbs.nav.appendChild(i)}renderTerms(e=null,t=!1,i=!1){if(e||(e=this.store.getFiltered()),t||window.removeChildren(this.ui.termsList),0===e.length)return void(t||this.showEmptyState());const s=this.store.filters.parent||0;this.ui.breadcrumbs.back.hidden=0===s;const o=document.createDocumentFragment();e.forEach((e=>{const t=this.createTermElement({id:parseInt(e.id),name:e.name,hasChildren:e.hasChildren,path:e.path||null,show:i});t&&o.appendChild(t)})),this.ui.termsList.appendChild(o)}createTermElement(e){if(!e?.name)return null;const t=window.getTemplate("termListItem");t.dataset.id=e.id;const i=this.selectedTerms.has(e.id),s=t.querySelector("input"),o=t.querySelector("label"),a=t.querySelector(".term-name");if(s.id=`${this.currentConfig.container.id}${e.id}`,s.name=`${this.currentConfig.container.id}${this.currentConfig.taxonomy}-select`,s.value=e.id,s.disabled=!i&&this.disabled,s.checked=i,o.htmlFor=s.id,o.title=e.path||e.name,o.dataset.path=e.path,a.textContent=e.show?e.path:e.name,e.hasChildren){const i=window.getTemplate("termChildrenToggle");i.ariaLabel=`View sub-terms of ${e.name}`,t.appendChild(i)}return t}showAutocompleteResults(e,t,i){if(!e?.autocompleteDropdown)return;const s=e.autocompleteDropdown;if(window.removeChildren(s),0===t.length)this.showEmptyState("No items found.",s);else{const i=document.createDocumentFragment();t.forEach((t=>{const s=this.createAutocompleteItem(e,t);s&&i.appendChild(s)})),s.appendChild(i)}const o=e.currentAutocompleteQuery||i;if(e.canCreate&&o){t.find((e=>e.name.toLowerCase()===o.toLowerCase()))||s.appendChild(this.createAutocompleteCreateButton(o))}s.hidden=!1}createAutocompleteItem(e,t){const i=document.createElement("button");return i.type="button",i.className="autocomplete-item",i.dataset.id=t.id,i.dataset.name=t.name,i.dataset.path=t.path||t.name,i.textContent=t.path||t.name,i.addEventListener("click",(()=>{e.selectedTerms.add(parseInt(t.id)),this.addTermDisplay(t.id,t.name,t.path,"field",e.id),e.input.value=Array.from(e.selectedTerms).join(","),e.input.dispatchEvent(new Event("change",{bubbles:!0})),e.autocompleteDropdown.hidden=!0;const i=e.container.querySelector("input[data-autocomplete]");i&&(i.value="")})),i}createAutocompleteCreateButton(e){const t=document.createElement("button");t.type="button",t.className="autocomplete-item create-term",t.dataset.query=e;const i=document.createElement("strong");return i.textContent="Create: ",t.appendChild(i),t.appendChild(document.createTextNode(`"${e}"`)),t}showAutocompleteError(e){const t=this.fields.get(e);t?.autocompleteDropdown&&(window.removeChildren(t.autocompleteDropdown),this.showEmptyState("Hmmm... something went wrong",t.autocompleteDropdown))}showLoading(){this.ui.loading.loading.hidden=!1,this.modal.classList.add("loading");const e=this.store.filters.search||"",t=this.store.filters.parent||0,i=e?`searching for "${e}" items`:0===t?"loading items":"loading child items";window.typeLoop?this.stopTyping=window.typeLoop(this.ui.loading.text,i):this.ui.loading.text.textContent=i}hideLoading(){this.ui.loading.loading.hidden=!0,this.modal.classList.remove("loading"),this.stopTyping&&this.stopTyping()}showEmptyState(e="No items found.",t=null){t||(t=this.ui.termsList);const i=window.getTemplate("noResults"),s=i.querySelector("span");e&&s&&(s.textContent=e),t.appendChild(i)}getFieldId(e){if(e.dataset.fieldId)return e.dataset.fieldId;const t=e.closest("[data-field-id]");return t?.dataset.fieldId||null}getLabel(e,t="single"){return jvbSettings.labels[e]?.[t]||e}async batchFetchTaxonomies(){if(0===this.taxonomiesToFetch.size)return;const e=Array.from(this.taxonomiesToFetch);this.taxonomiesToFetch.clear(),this.store.setFilters({taxonomy:e.join(","),page:1,search:"",parent:0})}async preloadTaxonomy(e){await this.store.setFilters({taxonomy:e,page:1,search:"",parent:0})}handleError(e,t,i=null){console.error(`Taxonomy ${t} error:`,e,i),this.error?.log&&this.error.log(e,{component:"TaxonomySelector",action:t,detail:i}),this.modal?.open&&this.showEmptyState("Error loading. Please try again.")}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t={}){this.subscribers.forEach((i=>{try{i(e,t)}catch(e){console.error("Subscriber error:",e)}}))}destroy(){document.removeEventListener("click",this.handleClick),document.removeEventListener("change",this.handleChange),document.removeEventListener("input",this.handleInput),document.removeEventListener("focus",this.handleFocus),document.removeEventListener("blur",this.handleBlur),this.observer?.disconnect(),this.store.destroy(),this.subscribers.clear(),this.fields.clear(),this.selectedTerms.clear(),this.searchContexts.clear()}}document.addEventListener("DOMContentLoaded",(()=>{window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbSelector=new e)}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.container=document.querySelector("dialog#jvb-selector"),this.container&&(this.a11y=window.jvbA11y,this.error=window.jvbError,this.subscribers=new Set,this.fields=new Map,this.selectedTerms=new Map,this.loadedTaxonomies=new Set,this.batchFetch=new Set,this.activeField=null,this.isInitializing=!0,this.init())}init(){this.initStore(),this.initElements(),this.initModal(),this.scanExistingFields(),this.initListeners(),this.needsCreator()&&window.jvbTaxCreator&&(this.creator=new window.jvbTaxCreator(this)),this.isInitializing=!1,this.batchFetchTaxonomies().then((()=>{}))}initStore(){const e=window.jvbStore.register("taxonomies",{storeName:"terms",keyPath:"id",showLoading:!1,indexes:[{name:"taxonomy",keyPath:"taxonomy"},{name:"parent",keyPath:"parent"},{name:"slug",keyPath:"slug",unique:!0},{name:"count",keyPath:"count"}],endpoint:"terms",TTL:12e4,filters:{taxonomy:"",page:1,search:"",parent:0},required:"taxonomy",delayFetch:!0});this.store=e.terms,this.store.subscribe(this.handleStoreEvent.bind(this))}initElements(){this.selectors={search:{input:"[type=search]",clear:".clear-search",container:".search-wrapper",results:".search-results"},terms:{list:".items-container",wrap:".items-wrap",sentinel:".scroll-sentinel"},nav:{nav:"nav.term-navigation",back:".back-to-parent",child:".toggle-children",pathLevel:".path-level"},loading:{loading:".loading",text:".loading span"},selected:".selected-items",modal:{title:"#modal-title",content:".modal-content",count:".selection-count"},favourites:".favourite-terms",field:{toggle:"button.taxonomy-toggle",value:'input[type="hidden"]',selected:".selected-items",dropdown:".search-results",search:"[data-autocomplete]"}},this.ui=window.uiFromSelectors(this.selectors)}initListeners(){this.observer=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&this.nextPage()}))}),{root:this.ui.terms.sentinel,threshold:.5}),this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.inputHandler=this.handleInput.bind(this),this.focusHandler=this.handleFocus.bind(this),this.blurHandler=this.handleBlur.bind(this),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),document.addEventListener("input",this.inputHandler),document.addEventListener("focus",this.focusHandler,!0),document.addEventListener("blur",this.blurHandler,!0)}handleClick(e){const t=this.getFieldId(e.target),i=this.fields.get(t);if(!t||!i)return;const s=window.targetCheck(e,"[data-autocomplete-select]");if(s){let e=parseInt(s.dataset.id);this.addSelected(e,t),i.ui.dropdown&&(i.ui.dropdown.hidden=!0),i.ui.search&&(i.ui.search.value="")}if(window.targetCheck(e,i.ui.toggle))return e.preventDefault(),void this.openModal(t);const n=window.targetCheck(e,"button.remove-item");if(n){const e=this.getFieldId(n),t=n.closest(".selected-item").dataset.id??!1;return void(e&&t&&this.removeSelected(t,e))}if(e.target.matches(".modal-close"))return void this.modal?.handleClose();if(window.targetCheck(e,this.selectors.nav.back))return void this.navigateToParent();if(window.targetCheck(e,this.selectors.nav.child)){const t=e.target.closest("li"),i=parseInt(t.dataset.id);return void(i&&this.navigateTo(i))}const r=window.targetCheck(e,this.selectors.nav.pathLevel);if(r){const e=parseInt(r.dataset.id)??0;this.navigateTo(e)}if(window.targetCheck(e,i.selectors.dropdown))return void this.scheduleHideDropdown(t);if(window.targetCheck(e,this.selectors.search.clear)){const e=this.currentField();e&&e.ui.search&&(e.ui.search.value="",this.store.setFilters({search:"",page:1,parent:this.store.filters.parent||0})),this.ui.search.input&&(this.ui.search.input.value="")}}handleChange(e){if(!this.container.contains(e.target))return;if("checkbox"!==e.target.type)return;e.preventDefault(),e.stopPropagation();const t=parseInt(e.target.dataset.id);let i=this.getFieldId(e.target);e.target.checked?this.addSelected(t,i):this.removeSelected(t,i)}handleInput(e){let t=this.getFieldId(e.target)??this.activeField;if(!t)return;const i=this.fields.get(t);if(!i)return;this.container.open||(this.activeField=t);const s=e.target.value.trim();window.debouncer.schedule(`${t}-search`,(async()=>{await this.store.setFilters({taxonomy:i.taxonomy,search:s,page:1,parent:s?0:this.store.filters.parent||0}),this.container.open&&window.removeChildren(this.ui.terms.list)}),100)}handleFocus(e){const t=this.getFieldId(e.target),i=this.fields.get(t);t&&i&&(i.hasAutocomplete||i.hasSearch)&&(window.debouncer.cancel(`${t}-search-results`),this.container.open||(this.activeField=t,this.preloadTaxonomy(i.taxonomy)))}handleBlur(e){const t=this.getFieldId(e.target),i=this.fields.get(t);t&&i&&i.hasAutocomplete&&this.scheduleHideDropdown(t)}scheduleHideDropdown(e){const t=this.fields.get(e);t&&window.debouncer.schedule(`${e}-search-results`,(()=>{this.activeField=null,t.ui.dropdown.hidden=!0}),1500)}initModal(){this.modalID="dialog#jvb-selector",this.container=document.querySelector(this.modalID),this.modal=new window.jvbModal(this.container,{handleForm:!1,save:null,open:null}),this.modal.subscribe(((e,t)=>{e}))}toggleModal(e,t=!0){this.fields.get(e)&&(t?this.openModal(e):this.closeModal())}openModal(e){const t=this.fields.get(e);if(!t)return;this.activeField=e,this.ui.modal.title.textContent=`Select ${t.plural}`,this.ui.search.container&&(this.ui.search.container.hidden=!t.canSearch),this.ui.create.details&&(this.ui.create.details.hidden=!t.canCreate,this.ui.create.summary&&(this.ui.create.summary.textContent=`Add new ${t.singular}`),this.ui.create.label.name&&(this.ui.create.label.name.textContent=`Name this ${t.singular}`),this.ui.create.label.parent&&(this.ui.create.label.parent.textContent="Nest it under"));let i=`Opened ${t.singular} selection. Choose from checkboxes, or search to filter results.`;window.removeChildren(this.ui.terms.list),this.modal.handleOpen(),this.setLoading(),this.store.setFilters({taxonomy:t.taxonomy,page:1,search:"",parent:0}),this.a11y.announce(i)}closeModal(){this.modal.handleClose();const e=this.fields.get(this.activeField);if(!e)return;this.observer.unobserve(this.ui.terms.sentinel),window.removeChildren(this.ui.terms.list),this.notify("selected-terms",{terms:this.selectedTerms.get(this.activeField),taxonomy:e.taxonomy}),this.activeField=null;let t=`Closed ${e.singular} selector.`;this.a11y.announce(t)}navigateToParent(){const e=this.store.filters.parent;if(0===e)return;let t=this.store.get(parseInt(e));if(!t)return;let i=t.parent;this.navigateTo(parseInt(i))}navigateTo(e=0){e=parseInt(e)??0,this.store.setFilters({parent:e,page:1}),window.removeChildren(this.ui.terms.list),this.updateBreadcrumbs(e)}nextPage(){let e=this.store.filters.page,t=Math.min(e++,this.store.lastResponse.total);this.store.setFilters({page:t})}prevPage(){let e=this.store.filters.page,t=Math.max(e-1,1);this.store.setFilters({page:t})}addTermToModal(e){const t=this.store.get(e);if(!t)return;const i=window.getTemplate("selectedTerm");i.dataset.id=e,i.querySelector("span").textContent=t.path,i.querySelector("button").title=`Remove ${name}`,this.ui.selected.append(i)}scanExistingFields(e=document.body){e.querySelectorAll('[data-type="selector"]').forEach((e=>{try{this.registerField(e)}catch(t){this.error.log(t,{component:"TaxonomySelector",action:"scanExistingFields",container:e.dataset.name})}}))}registerField(e,t={}){let i=e.querySelector('input[type="hidden"]');if(!i)return void console.warn("TaxonomySelector: No hidden input found for field",e);"fieldId"in e.dataset||(e.dataset.fieldId=window.generateID("selector"));const s=e.dataset.fieldId;let n=this.selectors.field,r=e.querySelector("button.taxonomy-toggle");if(0===t.size){if(!r)return;if(0===(t=r.dataset).size)return}else Object.hasOwn(t,"toggle")&&(r=document.querySelector(t.toggle),n.toggle=t.toggle);const a={id:s,value:i,element:e,taxonomy:t.taxonomy??!1,singular:t.single??"",plural:t.plural??"",name:e.dataset.field,canSearch:Object.hasOwn(t,"search"),limit:t.limit??0,hasAutocomplete:Object.hasOwn(t,"autocomplete"),canCreate:Object.hasOwn(t,"creatable"),isRequired:Object.hasOwn(t,"required"),toggle:r,selectors:n,ui:window.uiFromSelectors(n,e),checked:!1};if(!a.taxonomy)return;this.fields.set(s,a);let o=new Set;return i.value.value.trim().split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e))).forEach((e=>o.add(e))),this.selectedTerms.set(s,o),this.isInitializing&&this.batchFetch.add(a.taxonomy),this.updateFieldUI(s),s}addSelected(e,t=null){t||(t=this.activeField);const i=this.fields.get(t),s=this.store.get(e);if(!i||!s)return;const n=this.selectedTerms.get(t);0!==i.limit&&n.size>=i.limit||(n.add(parseInt(e)),this.addTermToDisplay(e,t),this.updateFieldValue(t),this.checkLimits(t))}removeSelected(e,t=null){t||(t=this.activeField);const i=this.fields.get(t),s=this.store.get(e);if(!i||!s)return;this.selectedTerms.get(t).delete(parseInt(e));const n=i.ui.selected.querySelector(`[data-i"${e}"]`);if(n&&n.remove(),this.container.open){let t=this.ui.selected.querySelector(`[data-id="${e}"]`);t&&t.remove()}this.updateFieldValue(t),this.checkLimits(t)}updateFieldValue(e){const t=this.fields.get(e);if(!t)return;let i=Array.from(this.selectedTerms.get(e));t.ui.value=i.join(",")}checkLimits(e){if(!this.container.open)return;const t=this.fields.get(e);if(!t||0===t.limit)return;const i=this.selectedTerms.get(e).size>=t.limit;this.setCheckboxes(i)}updateFieldUI(e){const t=this.fields.get(e);let i=this.selectedTerms.get(e);t&&0!==i.size&&Array.from(i).forEach((t=>{this.addTermToDisplay(t,e)}))}updateFieldsForTaxonomy(e){let t=Array.from(this.fields.values()).filter((t=>!t.checked&&t.taxonomy===e));const i=Array.from(this.store.data.values()).some((t=>t.taxonomy===e));t.forEach((e=>{e.ui.toggle.disabled=!i&&!e.canCreate,e.ui.toggle.title=i?`Select ${e.plural}`:`No ${e.singular} available`,e.checked=!0}))}showModalTerms(e=!0,t=!1){const i=this.store.getFiltered();if(0===i.size)return;e||window.removeChildren(this.ui.terms.list);const s=this.store.filters.parent??0;this.ui.nav.back.hidden=0===s;const n=document.createDocumentFragment();i.forEach((e=>{const i=this.createTermElement({show:t,...e});i&&n.appendChild(i)})),this.ui.terms.list.append(n)}createTermElement(e){if(!e||!e.name)return null;const t=window.getTemplate("termListItem");t.dataset.id=e.id;const i=this.selectedTerms.get(this.activeField).has(e.id);let[s,n,r]=[t.querySelector("input"),t.querySelector("label"),t.querySelector("span, .term-name")],a=this.currentField(),o=a.limit>0&&this.selectedTerms.get(this.activeField).size>=a.limit;if(s&&n&&r&&([s.id,s.name,s.value,s.disabled,s.checked,n.htmlFor,n.title,n.dataset.path,r.textContent]=[`${a.element.id}-${e.id}`,`${a.container.id}-${a.taxonomy}-select`,e.id,!i&&o,i,`${a.element.id}-${e.id}`,e.path??e.name,e.path,e.show?e.path:e.name],e.hasChildren)){const i=window.getTemplate("termChildrenToggle");i&&(i.ariaLabel=`View ${a.plural} nested under ${e.name}`,t.append(i))}return t}showAutocompleteTerms(){const e=this.currentField(),t=this.currentTerms();if(!e||0===t.size)return;const i=e.ui.dropdown;window.removeChildren(i),0===t.length?this.showEmptyState(`No ${e.plural} found.`,i):t.forEach((e=>{const t=this.createAutocompleteTerm(e);t&&i.append(t)}));const s=e.ui.search?.value;if(e.canCreate&&s.length>=2&&this.creator){const e=this.createTermButton(s);e&&i.append(e)}i.hidden=!1}createAutocompleteTerm(e){const t=window.getTemplate("autocompleteItem");if(t)return t.dataset.id=e.id,t.textContent=e.path||e.name,t}addTermToDisplay(e,t){const i=this.store.get(e),s=this.fields.get(t);if(!i||!s)return;if(s.ui.selected.querySelector(`[data-id="${e}"]`))return;const n=window.getTemplate("selectedTerm");if(n&&(n.dataset.id=e,n.dataset.taxonomy=s.taxonomy,n.querySelector(".item-name").textContent=i.path,n.querySelector("button").title=`Remove ${i.name}`,s.ui.selected.append(n),this.container.open)){this.addTermToModal(e);const t=this.ui.terms.list.querySelector(`input[value="${e}"]`);t&&(t.checked=!0)}}createTermButton(e){const t=window.getTemplate("autocompleteButton");if(!t)return;return t.querySelector("span").textContent=`"${e}"`,t}updateBreadcrumbs(e){const t=this.ui.nav.nav;if(!t)return;const i=Array.from(t.children).find((t=>parseInt(t.dataset.id)===e));if(i){let e=i.nextElementSibling;for(;e;){const t=e;e=e.nextElementSibling,t.remove()}}else{const i=this.store.get(e);if(!i)return;const s=window.getTemplate("termBreadcrumb");if(!s)return;s.dataset.id=e,s.textContent=i.name,s.title=i.name,t.append(s)}}updateSelectionCount(){if(!this.container.open)return;const e=this.fields.get(this.activeField);if(e&&this.ui.modal.count){const t=this.selectedTerms.get(this.activeField).size;this.ui.modal.count.textContent=e.limit>0?`${t} of ${e.limit} ${e.plural} selected`:`${t} ${e.plural} selected`}}currentField(){return this.fields.get(this.activeField)??!1}currentTerms(){return this.store.getFiltered()}needsCreator(){return Array.from(this.fields.values()).some((e=>e.canCreate||e.hasAutocomplete))}getFieldId(e){if(e.dataset.fieldId)return e.dataset.fieldId;const t=e.closest("[data-field-id]");return t?.dataset.fieldId||null}setCheckboxes(e){this.ui.terms.list.querySelectorAll("input[type=checkbox]").forEach((t=>{t.checked||(t.disabled=e)}))}handleStoreEvent(e,t){const i={"data-loaded":()=>this.handleDataLoaded(),"filters-changed":()=>this.handleFiltersChanged(),"fetch-error":()=>this.handleFetchError()};i[e]?.()}handleDataLoaded(){const e=this.store.filters.taxonomy;if(e?.includes(",")){e.split(",").map((e=>e.trim())).forEach((e=>this.updateFieldsForTaxonomy(e)))}this.container.open?this.showResults():this.activeField&&this.showResults(!0)}showResults(e=!1){this.setLoading(!1);const t=this.store.getFiltered(),i=this.store.filters,s=this.store.lastResponse?.page||{},n=i.search&&i.search.length>0,r=i.page>1,a=this.currentField();this.notify("terms-loaded",{terms:t,filters:i}),0===t.length?(r||this.showEmptyState(n?`No matching ${a.plural}.`:`No ${a.plural} available.`),this.observer.unobserve(this.ui.terms.sentinel)):e?this.showAutocompleteTerms():(this.showModalTerms(r,n),s.has_more?this.observer.observe(this.ui.terms.sentinel):this.observer.unobserve(this.ui.terms.sentinel)),this.a11y.announce(t.length,r)}handleFiltersChanged(){}handleFetchError(e){this.setLoading(!1)}async batchFetchTaxonomies(){if(0===this.batchFetch.size)return;const e=Array.from(this.batchFetch);e.forEach((e=>this.loadedTaxonomies.add(e))),this.batchFetch.clear();try{e.forEach((e=>this.loadedTaxonomies.add(e))),await this.store.setFilters({taxonomy:e.join(","),page:1,search:"",parent:0})}catch(e){console.error("Failed to batch fetch taxonomies:",e)}}preloadTaxonomy(e){this.loadedTaxonomies.has(e)||(this.store.setFilters({taxonomy:e,page:1,search:"",parent:0}),this.loadedTaxonomies.add(e))}setLoading(e=!0){if(this.ui.loading.loading.hidden=e,this.modal.classList.toggle("loading",e),e){let e=this.store.filters.search||"";e=""!==e&&e;const t=this.store.filters.parent||0,i=e?`Searching for "${e} items`:0===t?"loading items":"loading child items";window.typeLoop&&this.ui.loading.text?this.stopTyping=window.typeLoop(this.ui.loading.text,i):this.ui.loading.text.textContenet=i}else this.stopTyping&&(this.stopTyping(),this.stopTyping=null)}showEmptyState(e="No items found.",t=null){t||(t=this.ui.terms.list);const i=window.getTemplate("noTermResults"),s=i.querySelector("span");e&&s&&(s.textContent=e),t.append(i)}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t={}){this.subscribers.forEach((i=>{try{i(e,t)}catch(e){console.error("Subscriber error:",e)}}))}destroy(){document.removeEventListener("click",this.clickHandler),document.removeEventListener("change",this.changeHandler),document.removeEventListener("input",this.inputHandler),document.removeEventListener("focus",this.focusHandler),document.removeEventListener("blur",this.blurHandler),this.observer?.disconnect(),this.subscribers.clear(),this.fields.clear(),this.selectedTerms.clear()}}document.addEventListener("DOMContentLoaded",(function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbSelector=new e)}))}))})();
\ No newline at end of file
diff --git a/inc/forms/TaxonomySelector.php b/inc/forms/TaxonomySelector.php
index ab3d7d5..8156ebb 100644
--- a/inc/forms/TaxonomySelector.php
+++ b/inc/forms/TaxonomySelector.php
@@ -144,10 +144,10 @@
</div>
<!-- Create new term section -->
- <details class="create-new-term" hidden>
+ <details class="create-term" hidden>
<summary class="row btw">Add New Term</summary>
<div class="create-new-term-section">
- <form class="create-term-form" data-nocache data-form-id="create-term" data-save="terms">
+ <form class="create-term" data-nocache data-form-id="create-term" data-save="terms">
<div class="form-group">
<label for="term_name">Term Name:</label>
<input type="text" name="term_name" id="term_name" required>
@@ -162,7 +162,7 @@
<button type="button" class="submit-term">Add Term</button>
</form>
-
+ <div class="term-suggestions" hidden><h4></h4><ul class="term-suggestion-list"></ul></div>
<div class="loading-message create-term" hidden>
<span id="typed-text"></span>
<span class="cursor">|</span>
@@ -175,7 +175,13 @@
<template class="loadingItems">
<p>{ <span>loading items</span> }</p>
</template>
- <template class="noResults">
+ <template class="autocompleteButton">
+ <button class="autocomplete submit-term" type="button"><strong>Create: </strong><span></span></button>
+ </template>
+ <template class="autocompleteItem">
+ <button class="autocomplete item" type="button" data-autocomplete-select></button>
+ </template>
+ <template class="noTermResults">
<p>{ <span>nothing found</span> }</p>
</template>
<template class="termListItem">
@@ -246,7 +252,7 @@
</button>
<?php if ($hasAutocomplete !== '') { ?>
<input type="text" id="<?= $this->base ?><?= esc_attr($this->config['name']) ?>-autocomplete" autocomplete="off" data-ignore data-autocomplete>
- <ul class="autocomplete-dropdown" hidden>
+ <ul class="search-results" hidden>
</ul>
<?php } ?>
</div>
diff --git a/inc/meta/MetaForm.php b/inc/meta/MetaForm.php
index aa537c0..51959aa 100644
--- a/inc/meta/MetaForm.php
+++ b/inc/meta/MetaForm.php
@@ -1408,9 +1408,10 @@
}
?>
- <div class="field <?= esc_attr($type) ?> <?= esc_attr($name) ?>"
+ <div class="field selector <?= esc_attr($type) ?> <?= esc_attr($name) ?>"
<?= $conditional ?>
data-field="<?= esc_attr($name) ?>"
+ data-type="selector" data-subtype="<?= esc_attr($type)?>"
<?= $validationAttrs ?>
<?= $describedBy ?>>
--
Gitblit v1.10.0