/**
* Builder Material Handler
* Handles material upload and management for lesson popup in course builder.
* Pure JavaScript implementation with optimizations
*
* @since 4.3.0
* @version 1.0.0
*/
export class BuilderMaterial {
constructor( container = null ) {
this.container = container;
this.initialized = false;
this.eventsBound = false;
this.sortable = null;
// Store references for cleanup
this.boundHandlers = {
handleAddMaterial: null,
handleChange: null,
handleClick: null,
handleSaveAll: null,
handleDelete: null,
handleDragStart: null,
handleDragOver: null,
handleDrop: null,
handleDragEnd: null,
};
// Cache DOM elements
this.elements = {};
if ( this.container ) {
this.init();
}
}
/**
* Reinitialize with new container (called from BuilderPopup)
*/
reinit( container ) {
if ( this.initialized ) {
this.destroy();
}
this.container = container;
if ( this.container ) {
this.init();
}
}
/**
* Cache all DOM elements for better performance
*/
cacheElements() {
const el = this.elements;
el.postId = this.container.querySelector( '#current-material-post-id' );
el.maxFileSize = this.container.querySelector( '#material-max-file-size' );
el.uploadField = this.container.querySelector( '.lp-material--field-upload' );
el.canUpload = this.container.querySelector( '#available-to-upload' );
el.addBtn = this.container.querySelector( '#btn-lp--add-material' );
el.groupTemplate = this.container.querySelector( '#lp-material--add-material-template' );
el.groupContainer = this.container.querySelector( '#lp-material--group-container' );
el.materialTab = this.container.querySelector( '#lp-material-container' ) || this.container;
el.saveBtn = this.container.querySelector( '#btn-lp--save-material' );
el.uploadTemplate = this.container.querySelector( '#lp-material--upload-field-template' );
el.externalTemplate = this.container.querySelector( '#lp-material--external-field-template' );
el.deleteText = this.container.querySelector( '#delete-material-row-text' );
el.deleteMessage = this.container.querySelector( '#delete-material-message' );
el.materialTable = this.container.querySelector( '.lp-material--table' );
el.tbody = el.materialTable?.querySelector( 'tbody' );
el.thead = el.materialTable?.querySelector( 'thead' );
}
init() {
if ( ! this.container ) return;
// Cache all elements
this.cacheElements();
const { postId, materialTab } = this.elements;
// Validate required elements
if ( ! postId || ! materialTab ) return;
// Store config values
this.postID = postId.value;
this.maxFileSize = this.elements.maxFileSize?.value || 10;
this.acceptFile = this.elements.uploadField
? this.elements.uploadField
.getAttribute( 'accept' )
?.split( ',' )
.map( ( s ) => s.trim() ) || []
: [];
// Load existing materials
this.loadMaterials();
// Bind events
this.bindEvents();
// Initialize native drag & drop sortable
this.initSortable();
this.initialized = true;
}
/**
* Load materials from API with better error handling
*/
async loadMaterials() {
const { materialTab, tbody } = this.elements;
if ( ! materialTab || ! this.postID || ! tbody ) return;
try {
const restUrl = this.getRestUrl();
const url = `${ restUrl }lp/v1/material/item-materials/${ this.postID }`;
const response = await fetch( url, {
method: 'GET',
headers: {
'X-WP-Nonce': this.getNonce(),
'Content-Type': 'application/json',
},
} );
if ( ! response.ok ) {
throw new Error( `HTTP error! status: ${ response.status }` );
}
const result = await response.json();
const { data, status } = result;
if ( status !== 'success' ) {
console.error( result.message );
return;
}
if ( data?.items?.length > 0 ) {
// Remove skeleton loader
const skeleton = materialTab.querySelector( '.lp-skeleton-animation' );
skeleton?.remove();
// Use DocumentFragment for better performance
const fragment = document.createDocumentFragment();
data.items.forEach( ( item ) => {
const row = this.createRow( item );
fragment.appendChild( row );
} );
tbody.appendChild( fragment );
// Reinit sortable after loading data
this.initSortable();
}
} catch ( error ) {
console.error( 'Load materials error:', error.message );
}
}
/**
* Create a table row element (more efficient than insertAdjacentHTML)
*/
createRow( data ) {
const tr = document.createElement( 'tr' );
tr.dataset.id = data.file_id;
tr.dataset.sort = data.orders;
tr.draggable = true;
const deleteBtnText = this.elements.deleteText?.value || 'Delete';
tr.innerHTML = `
${ this.escapeHtml( data.file_name ) }
|
${ this.capitalizeFirstChar( data.method ) } |
${ deleteBtnText }
|
`;
return tr;
}
/**
* Escape HTML to prevent XSS
*/
escapeHtml( text ) {
const div = document.createElement( 'div' );
div.textContent = text;
return div.innerHTML;
}
capitalizeFirstChar( str ) {
return str.charAt( 0 ).toUpperCase() + str.substring( 1 );
}
/**
* Bind all events with delegation for better performance
*/
bindEvents() {
if ( this.eventsBound ) return;
const { addBtn, materialTab, saveBtn } = this.elements;
// Create bound handlers for later removal
this.boundHandlers.handleAddMaterial = () => this.handleAddMaterial();
this.boundHandlers.handleChange = ( e ) => this.handleChange( e );
this.boundHandlers.handleClick = ( e ) => this.handleClick( e );
this.boundHandlers.handleSaveAll = () => this.handleSaveAll();
this.boundHandlers.handleDelete = ( e ) => this.handleDelete( e );
// Add material button
addBtn?.addEventListener( 'click', this.boundHandlers.handleAddMaterial );
// Use event delegation on materialTab
if ( materialTab ) {
materialTab.addEventListener( 'change', this.boundHandlers.handleChange );
materialTab.addEventListener( 'click', this.boundHandlers.handleClick );
}
// Save all button
saveBtn?.addEventListener( 'click', this.boundHandlers.handleSaveAll );
// Delete material (use event delegation on container)
this.container.addEventListener( 'click', this.boundHandlers.handleDelete );
this.eventsBound = true;
}
/**
* Handle add material button click
*/
handleAddMaterial() {
const { addBtn, groupContainer, groupTemplate } = this.elements;
if ( ! addBtn || ! groupContainer || ! groupTemplate ) return;
const canUploadData = parseInt( addBtn.getAttribute( 'can-upload' ) ) || 0;
const groups = groupContainer.querySelectorAll( '.lp-material--group' ).length;
if ( groups >= canUploadData ) return;
groupContainer.insertAdjacentHTML( 'afterbegin', groupTemplate.innerHTML );
}
/**
* Handle change events with delegation
*/
handleChange( event ) {
const target = event.target;
// Switch between upload and external
if ( target.classList.contains( 'lp-material--field-method' ) ) {
this.handleMethodSwitch( target );
}
// File validation
if ( target.classList.contains( 'lp-material--field-upload' ) ) {
this.validateFile( target );
}
}
/**
* Handle method switch (upload/external)
*/
handleMethodSwitch( target ) {
const method = target.value;
const { uploadTemplate, externalTemplate } = this.elements;
if ( ! uploadTemplate || ! externalTemplate ) return;
const group = target.closest( '.lp-material--group' );
if ( ! group ) return;
switch ( method ) {
case 'upload':
target.parentNode.insertAdjacentHTML( 'afterend', uploadTemplate.innerHTML );
group.querySelector( '.lp-material--external-wrap' )?.remove();
break;
case 'external':
target.parentNode.insertAdjacentHTML( 'afterend', externalTemplate.innerHTML );
group.querySelector( '.lp-material--upload-wrap' )?.remove();
break;
}
}
/**
* Validate uploaded file
*/
validateFile( target ) {
if ( ! target.value || ! target.files?.length ) {
this.resetUploadLabel( target );
return;
}
const file = target.files[ 0 ];
if ( this.acceptFile.length > 0 && ! this.acceptFile.includes( file.type ) ) {
alert( 'This file is not allowed! Please choose another file!' );
target.value = '';
this.resetUploadLabel( target );
return;
}
if ( file.size > this.maxFileSize * 1024 * 1024 ) {
alert(
`This file size is greater than ${ this.maxFileSize }MB! Please choose another file!`
);
target.value = '';
this.resetUploadLabel( target );
return;
}
this.updateUploadLabel( target, file.name );
}
/**
* Update upload label to show selected file name
*/
updateUploadLabel( target, fileName ) {
if ( ! fileName ) return;
const uploadLabel = target
.closest( '.lp-material--upload-wrap' )
?.querySelector( 'label' );
if ( ! uploadLabel ) return;
uploadLabel.classList.add( 'has-selected-file' );
uploadLabel.setAttribute( 'data-file-name', fileName );
uploadLabel.setAttribute( 'title', fileName );
}
/**
* Reset upload label state
*/
resetUploadLabel( target ) {
const uploadLabel = target
?.closest( '.lp-material--upload-wrap' )
?.querySelector( 'label' );
if ( ! uploadLabel ) return;
uploadLabel.classList.remove( 'has-selected-file' );
uploadLabel.removeAttribute( 'data-file-name' );
uploadLabel.removeAttribute( 'title' );
}
/**
* Handle click events with delegation
*/
handleClick( event ) {
const target = event.target;
// Delete group
if ( target.classList.contains( 'lp-material--delete' ) && target.nodeName === 'BUTTON' ) {
target.closest( '.lp-material--group' )?.remove();
return;
}
// Save single material
if ( target.classList.contains( 'lp-material-save-field' ) ) {
const material = target.closest( '.lp-material--group' );
if ( material ) {
this.saveMaterial( [ material ], true, target );
}
}
}
/**
* Handle save all button
*/
handleSaveAll() {
const { groupContainer, saveBtn } = this.elements;
if ( ! groupContainer ) return;
const materials = Array.from( groupContainer.querySelectorAll( '.lp-material--group' ) );
if ( materials.length > 0 ) {
this.saveMaterial( materials, false, saveBtn );
}
}
/**
* Save material(s) with improved validation
*/
async saveMaterial( materials, isSingle = false, targetBtn ) {
if ( ! materials.length ) return;
const materialData = [];
const formData = new FormData();
let isValid = true;
for ( const ele of materials ) {
const label = ele.querySelector( '.lp-material--field-title' )?.value;
const method = ele.querySelector( '.lp-material--field-method' )?.value;
const externalField = ele.querySelector( '.lp-material--field-external-link' );
const uploadField = ele.querySelector( '.lp-material--field-upload' );
if ( ! label ) {
isValid = false;
break;
}
let file = '';
let link = '';
switch ( method ) {
case 'upload':
if ( uploadField?.value && uploadField.files?.length > 0 ) {
file = uploadField.files[ 0 ].name;
formData.append( 'file[]', uploadField.files[ 0 ] );
} else {
isValid = false;
}
break;
case 'external':
link = externalField?.value || '';
if ( ! link ) {
isValid = false;
}
break;
}
if ( ! isValid ) break;
materialData.push( { label, method, file, link } );
}
if ( ! isValid ) {
alert( 'Enter file title, choose file or enter file link!' );
return;
}
formData.append( 'data', JSON.stringify( materialData ) );
targetBtn?.classList.add( 'loading' );
try {
const restUrl = this.getRestUrl();
const url = `${ restUrl }lp/v1/material/item-materials/${ this.postID }`;
const response = await fetch( url, {
method: 'POST',
headers: {
'X-WP-Nonce': this.getNonce(),
},
body: formData,
} );
if ( ! response.ok ) {
throw new Error( `HTTP error! status: ${ response.status }` );
}
const text = await response.text();
let res;
try {
res = JSON.parse( text );
} catch ( e ) {
console.error( 'Response is not valid JSON:', text.substring( 0, 200 ) );
throw new Error( 'Server returned invalid response. Check console for details.' );
}
// Clear or remove materials
if ( ! isSingle ) {
materials.forEach( ( ele ) => {
ele.querySelector( '.lp-material--field-title' ).value = '';
const uploadField = ele.querySelector( '.lp-material--field-upload' );
if ( uploadField ) {
uploadField.value = '';
this.resetUploadLabel( uploadField );
}
const externalField = ele.querySelector( '.lp-material--field-external-link' );
if ( externalField ) externalField.value = '';
} );
} else {
materials[ 0 ].remove();
}
const { message, data, status } = res;
// console.log( message );
if ( status === 'success' && data?.length > 0 ) {
const { thead, tbody } = this.elements;
thead?.classList.remove( 'hidden' );
if ( tbody ) {
const fragment = document.createDocumentFragment();
data.forEach( ( row ) => {
fragment.appendChild( this.createRow( row ) );
} );
tbody.appendChild( fragment );
}
this.updateCanUploadCount( -data.length );
this.initSortable();
}
} catch ( err ) {
console.error( 'Save material error:', err );
alert( 'Error saving material: ' + err.message );
} finally {
targetBtn?.classList.remove( 'loading' );
}
}
/**
* Handle delete material
*/
async handleDelete( e ) {
const target = e.target;
if ( ! target.classList.contains( 'delete-material-row' ) || target.nodeName !== 'A' ) {
return;
}
e.preventDefault();
const rowID = target.dataset.id;
const message =
this.elements.deleteMessage?.value || 'Are you sure you want to delete this material?';
if ( ! confirm( message ) ) return;
try {
const restUrl = this.getRestUrl();
const url = `${ restUrl }lp/v1/material/${ rowID }`;
const response = await fetch( url, {
method: 'DELETE',
headers: {
'X-WP-Nonce': this.getNonce(),
'Content-Type': 'application/json',
},
body: JSON.stringify( {
item_id: this.postID,
} ),
} );
const res = await response.json();
if ( res.status !== 200 || ! res.delete ) {
alert( res.message );
} else {
target.closest( 'tr' )?.remove();
this.updateCanUploadCount( 1 );
}
} catch ( err ) {
console.error( 'Delete material error:', err );
alert( 'Error deleting material: ' + err.message );
}
}
/**
* Update can upload count
*/
updateCanUploadCount( delta ) {
const { canUpload, addBtn } = this.elements;
if ( canUpload && addBtn ) {
const newCount = parseInt( canUpload.textContent ) + delta;
canUpload.textContent = newCount;
addBtn.setAttribute( 'can-upload', newCount );
}
}
/**
* Initialize native drag & drop sortable (no jQuery)
*/
initSortable() {
const { tbody } = this.elements;
if ( ! tbody ) return;
// Remove existing listeners
this.destroySortable();
const rows = tbody.querySelectorAll( 'tr' );
// Create bound handlers
this.boundHandlers.handleDragStart = ( e ) => this.handleDragStart( e );
this.boundHandlers.handleDragOver = ( e ) => this.handleDragOver( e );
this.boundHandlers.handleDrop = ( e ) => this.handleDrop( e );
this.boundHandlers.handleDragEnd = () => this.handleDragEnd();
rows.forEach( ( row ) => {
row.draggable = true;
row.addEventListener( 'dragstart', this.boundHandlers.handleDragStart );
row.addEventListener( 'dragover', this.boundHandlers.handleDragOver );
row.addEventListener( 'drop', this.boundHandlers.handleDrop );
row.addEventListener( 'dragend', this.boundHandlers.handleDragEnd );
} );
this.sortable = { tbody, rows };
}
handleDragStart( e ) {
this.draggedElement = e.currentTarget;
e.currentTarget.style.opacity = '0.4';
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData( 'text/html', e.currentTarget.innerHTML );
}
handleDragOver( e ) {
if ( e.preventDefault ) {
e.preventDefault();
}
e.dataTransfer.dropEffect = 'move';
const target = e.currentTarget;
if ( this.draggedElement !== target ) {
const rect = target.getBoundingClientRect();
const next = ( e.clientY - rect.top ) / ( rect.bottom - rect.top ) > 0.5;
target.parentNode.insertBefore( this.draggedElement, next ? target.nextSibling : target );
}
return false;
}
handleDrop( e ) {
if ( e.stopPropagation ) {
e.stopPropagation();
}
return false;
}
handleDragEnd() {
this.draggedElement.style.opacity = '1';
this.draggedElement = null;
// Update sort order after drag ends
this.updateSort();
}
/**
* Update sort order
*/
async updateSort() {
const { tbody } = this.elements;
if ( ! tbody ) return;
const items = tbody.querySelectorAll( 'tr' );
const data = Array.from( items ).map( ( item, index ) => {
item.dataset.sort = index + 1;
return {
file_id: parseInt( item.dataset.id ),
orders: index + 1,
};
} );
try {
const restUrl = this.getRestUrl();
const url = `${ restUrl }lp/v1/material/item-materials/${ this.postID }`;
const response = await fetch( url, {
method: 'PUT',
headers: {
'X-WP-Nonce': this.getNonce(),
'Content-Type': 'application/json',
},
body: JSON.stringify( {
sort_arr: JSON.stringify( data ),
} ),
} );
const res = await response.json();
if ( res.status !== 200 ) {
console.error( 'Sort table fail.' );
}
} catch ( err ) {
console.error( 'Update sort error:', err );
}
}
/**
* Helper methods for REST URL and nonce
*/
getRestUrl() {
return window.lpGlobalSettings?.rest || window.lpData?.lp_rest_url || '/wp-json/';
}
getNonce() {
return window.lpGlobalSettings?.nonce || window.lpData?.nonce || '';
}
/**
* Destroy sortable
*/
destroySortable() {
if ( ! this.sortable ) return;
const { rows } = this.sortable;
rows?.forEach( ( row ) => {
row.removeEventListener( 'dragstart', this.boundHandlers.handleDragStart );
row.removeEventListener( 'dragover', this.boundHandlers.handleDragOver );
row.removeEventListener( 'drop', this.boundHandlers.handleDrop );
row.removeEventListener( 'dragend', this.boundHandlers.handleDragEnd );
} );
this.sortable = null;
}
/**
* Destroy instance and cleanup
*/
destroy() {
const { addBtn, materialTab, saveBtn } = this.elements;
// Remove event listeners
if ( addBtn ) {
addBtn.removeEventListener( 'click', this.boundHandlers.handleAddMaterial );
}
if ( materialTab ) {
materialTab.removeEventListener( 'change', this.boundHandlers.handleChange );
materialTab.removeEventListener( 'click', this.boundHandlers.handleClick );
}
if ( saveBtn ) {
saveBtn.removeEventListener( 'click', this.boundHandlers.handleSaveAll );
}
if ( this.container ) {
this.container.removeEventListener( 'click', this.boundHandlers.handleDelete );
}
// Destroy sortable
this.destroySortable();
// Reset bound handlers
this.boundHandlers = {
handleAddMaterial: null,
handleChange: null,
handleClick: null,
handleSaveAll: null,
handleDelete: null,
handleDragStart: null,
handleDragOver: null,
handleDrop: null,
handleDragEnd: null,
};
// Clear cached elements
this.elements = {};
this.initialized = false;
this.eventsBound = false;
}
}
export default BuilderMaterial;