/**
|
* FeedTaxonomySelector - Adapts TaxonomySelector for use in the Feed Block
|
*
|
* This component provides the glue between the Feed Block filtering UI
|
* and the existing TaxonomySelector used in the dashboard.
|
*/
|
import { debounce } from '../utils/formatters';
|
import cache from '../utils/cache';
|
class FeedTaxonomySelector {
|
constructor(container, options = {}) {
|
this.container = container;
|
this.options = {
|
taxonomy: '',
|
contentType: '',
|
onChange: null,
|
apiUrl: window.feedSettings?.apiUrl || '',
|
nonce: window.feedSettings?.nonce || '',
|
...options
|
};
|
|
// Initialize state
|
this.selectedTerms = [];
|
this.isInitialized = false;
|
this.taxonomy = this.options.taxonomy;
|
this.contentType = this.options.contentType;
|
|
// Create selector container if it doesn't exist
|
this.selectorContainer = this.container.querySelector('.taxonomy-selector-container');
|
if (!this.selectorContainer) {
|
this.selectorContainer = document.createElement('div');
|
this.selectorContainer.className = 'taxonomy-selector-container';
|
this.container.appendChild(this.selectorContainer);
|
}
|
|
// Initialize when contentType is set
|
if (this.contentType) {
|
this.init();
|
}
|
}
|
|
/**
|
* Initialize the selector with the current content type
|
*/
|
init() {
|
// Skip if already initialized with this content type
|
if (this.isInitialized && this.contentType === this._lastContentType) {
|
return;
|
}
|
|
// Store content type
|
this._lastContentType = this.contentType;
|
|
// Set up the container with necessary data attributes
|
this.selectorContainer.dataset.taxonomy = this.taxonomy;
|
this.selectorContainer.dataset.config = JSON.stringify({
|
multiple: true,
|
hierarchical: true,
|
search: true,
|
createNew: false,
|
required: false,
|
name: this.taxonomy,
|
base: BASE || 'jvb_'
|
});
|
|
// Create the selector markup
|
this.createSelectorMarkup();
|
|
// Initialize the TaxonomySelector
|
this.selector = new TaxonomySelector(this.selectorContainer, {
|
onSuccess: () => {
|
// When selection changes, notify parent component
|
if (typeof this.options.onChange === 'function') {
|
this.options.onChange(this.getSelectedTerms());
|
}
|
}
|
});
|
|
// Load terms for the current content type
|
this.loadTermsForContentType();
|
|
this.isInitialized = true;
|
}
|
|
/**
|
* Create the selector markup
|
*/
|
createSelectorMarkup() {
|
this.selectorContainer.innerHTML = `
|
<div class="selected-items"></div>
|
<dialog class="selector-modal">
|
<div class="modal-content">
|
<header class="modal-header">
|
<h3>Select ${this.getTaxonomyLabel()}</h3>
|
<button type="button" class="cancel" aria-label="Close">×</button>
|
</header>
|
|
<div class="items-wrap">
|
<details class="favourite-terms">
|
<summary class="title">Common ${this.getTaxonomyLabel()}</summary>
|
<ul></ul>
|
</details>
|
<p class="pagination-info"></p>
|
<nav class="term-navigation"></nav>
|
<ul class="items-container"></ul>
|
<div class="scroll-sentinel"></div>
|
</div>
|
|
<div class="search-wrapper">
|
<div class="search-bar">
|
<input type="search" placeholder="Search..." aria-label="Search ${this.getTaxonomyLabel()}">
|
</div>
|
</div>
|
</div>
|
</dialog>
|
<button type="button" class="add-item-btn">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<path d="M12 4V20M4 12H20" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
</svg>
|
Add ${this.getTaxonomyLabel()}
|
</button>
|
`;
|
}
|
|
/**
|
* Get a user-friendly label for the taxonomy
|
*/
|
getTaxonomyLabel() {
|
const labels = {
|
'style': 'Style',
|
'theme': 'Theme',
|
'city': 'Location',
|
'shop': 'Shop',
|
'artstyle': 'Art Style',
|
'arttheme': 'Art Theme',
|
'pstyle': 'Piercing Style',
|
'placement': 'Placement'
|
};
|
|
return labels[this.taxonomy] || this.taxonomy.charAt(0).toUpperCase() + this.taxonomy.slice(1);
|
}
|
|
/**
|
* Load terms specific to the current content type
|
*/
|
async loadTermsForContentType() {
|
try {
|
const params = new URLSearchParams({
|
page: 1,
|
per_page: 50,
|
min_count: 1
|
});
|
|
const url = `${this.options.apiUrl}terms/for/${this.contentType}/${this.taxonomy}?${params.toString()}`;
|
|
const response = await fetch(url, {
|
headers: {
|
'X-WP-Nonce': this.options.nonce
|
}
|
});
|
|
if (!response.ok) {
|
throw new Error(`HTTP error! status: ${response.status}`);
|
}
|
|
const data = await response.json();
|
|
// Also load popular terms
|
const popularTerms = await this.loadPopularTerms();
|
|
// Set the terms and common terms in the selector
|
if (this.selector) {
|
// Update the common terms
|
this.selector.commonTerms = new Map(
|
Object.entries(popularTerms.terms).map(([id, term]) => [id, term])
|
);
|
|
// Update the current terms
|
this.selector.currentTerms = new Map(
|
Object.entries(data.terms).map(([id, term]) => [id, term.name])
|
);
|
|
// Reset parent options
|
this.selector.resetParentOptions();
|
|
// Render common terms
|
const commonTermsList = this.selectorContainer.querySelector('details.favourite-terms ul');
|
if (commonTermsList) {
|
commonTermsList.innerHTML = '';
|
Object.entries(popularTerms.terms).forEach(([id, term]) => {
|
const li = document.createElement('li');
|
li.dataset.id = id;
|
|
const isSelected = this.selector.selectedItems[id];
|
|
li.innerHTML = `
|
<input type="${this.selector.config.multiple ? 'checkbox' : 'radio'}"
|
id="${this.selector.config.base}${id}"
|
name="${this.selector.config.name}"
|
value="${id}"
|
${isSelected ? 'checked' : ''}>
|
<label for="${this.selector.config.base}${id}" title="${this.escapeHtml(term.name)}">
|
<span class="term-name">${this.escapeHtml(term.name)}</span>
|
</label>
|
`;
|
|
commonTermsList.appendChild(li);
|
});
|
}
|
|
// Render all terms
|
const termsContainer = this.selectorContainer.querySelector('.items-container');
|
if (termsContainer) {
|
termsContainer.innerHTML = '';
|
Object.entries(data.terms).forEach(([id, term]) => {
|
const li = document.createElement('li');
|
li.dataset.id = id;
|
|
const isSelected = this.selector.selectedItems[id];
|
|
li.innerHTML = `
|
<input type="${this.selector.config.multiple ? 'checkbox' : 'radio'}"
|
id="${this.selector.config.base}${term.id}"
|
name="${this.selector.config.name}"
|
value="${term.id}"
|
${isSelected ? 'checked' : ''}>
|
<label for="${this.selector.config.base}${term.id}" title="${this.escapeHtml(term.path || term.name)}">
|
<span class="term-name">${this.escapeHtml(term.name)}</span>
|
<span class="count">(${term.count})</span>
|
</label>
|
`;
|
|
termsContainer.appendChild(li);
|
});
|
}
|
}
|
|
} catch (error) {
|
console.error('Error loading terms for content type:', error);
|
}
|
}
|
|
/**
|
* Load popular terms for this taxonomy
|
*/
|
async loadPopularTerms() {
|
try {
|
const url = `${this.options.apiUrl}terms/popular/${this.taxonomy}?limit=10`;
|
|
const response = await fetch(url, {
|
headers: {
|
'X-WP-Nonce': this.options.nonce
|
}
|
});
|
|
if (!response.ok) {
|
throw new Error(`HTTP error! status: ${response.status}`);
|
}
|
|
return await response.json();
|
|
} catch (error) {
|
console.error('Error loading popular terms:', error);
|
return {terms: []};
|
}
|
}
|
|
/**
|
* Update the content type and reload terms
|
*/
|
setContentType(contentType) {
|
if (this.contentType === contentType) return;
|
|
this.contentType = contentType;
|
|
// If already initialized, reload terms for new content type
|
if (this.isInitialized) {
|
this.loadTermsForContentType();
|
} else {
|
this.init();
|
}
|
}
|
|
/**
|
* Get selected terms
|
*/
|
getSelectedTerms() {
|
if (!this.selector) return [];
|
|
return Object.keys(this.selector.selectedItems).map(id => parseInt(id));
|
}
|
|
/**
|
* Set selected terms
|
*/
|
setSelectedTerms(termIds) {
|
if (!this.selector) return;
|
|
// Clear current selections
|
this.selector.selectedItems = {};
|
|
// Add new selections
|
termIds.forEach(id => {
|
const termElement = this.selectorContainer.querySelector(`li[data-id="${id}"]`);
|
if (termElement) {
|
const name = termElement.querySelector('.term-name').textContent;
|
this.selector.selectedItems[id] = name;
|
}
|
});
|
|
// Update UI
|
this.selector.updateSelected();
|
}
|
|
/**
|
* Escape HTML special characters to prevent XSS
|
*/
|
escapeHtml(text) {
|
if (!text) return '';
|
|
return text
|
.replace(/&/g, "&")
|
.replace(/</g, "<")
|
.replace(/>/g, ">")
|
.replace(/"/g, """)
|
.replace(/'/g, "'");
|
}
|
}
|