PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.4.1
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.4.1
4.4.8 4.4.7 4.4.6 4.4.5 4.4.4 4.4.3 4.4.2 4.4.1 4.4.0 4.3.9.1 4.3.9 4.3.8 4.3.7 4.1.6.9 4.1.6.9.1 4.1.6.9.2 4.1.6.9.3 4.1.6.9.4 4.1.7 4.1.7.1 4.1.7.2 4.1.7.3 4.1.7.3.1 4.1.7.3.2 4.2.0 All 139 releases
learnpress / assets / src / js / frontend / course-builder / builder-lesson / builder-material.js

builder-material.js in LearnPress – WordPress LMS Plugin for Create and Sell Online Courses 4.4.1, at assets/src/js/frontend/course-builder/builder-lesson/builder-material.js

758 lines 19.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Builder Material Handler
3 * Handles material upload and management for lesson popup in course builder.
4 * Pure JavaScript implementation with optimizations
5 *
6 * @since 4.3.0
7 * @version 1.0.0
8 */
9
10 export class BuilderMaterial {
11 constructor( container = null ) {
12 this.container = container;
13 this.initialized = false;
14 this.eventsBound = false;
15 this.sortable = null;
16
17 // Store references for cleanup
18 this.boundHandlers = {
19 handleAddMaterial: null,
20 handleChange: null,
21 handleClick: null,
22 handleSaveAll: null,
23 handleDelete: null,
24 handleDragStart: null,
25 handleDragOver: null,
26 handleDrop: null,
27 handleDragEnd: null,
28 };
29
30 // Cache DOM elements
31 this.elements = {};
32
33 if ( this.container ) {
34 this.init();
35 }
36 }
37
38 /**
39 * Reinitialize with new container (called from BuilderPopup)
40 */
41 reinit( container ) {
42 if ( this.initialized ) {
43 this.destroy();
44 }
45
46 this.container = container;
47
48 if ( this.container ) {
49 this.init();
50 }
51 }
52
53 /**
54 * Cache all DOM elements for better performance
55 */
56 cacheElements() {
57 const el = this.elements;
58
59 el.postId = this.container.querySelector( '#current-material-post-id' );
60 el.maxFileSize = this.container.querySelector( '#material-max-file-size' );
61 el.uploadField = this.container.querySelector( '.lp-material--field-upload' );
62 el.canUpload = this.container.querySelector( '#available-to-upload' );
63 el.addBtn = this.container.querySelector( '#btn-lp--add-material' );
64 el.groupTemplate = this.container.querySelector( '#lp-material--add-material-template' );
65 el.groupContainer = this.container.querySelector( '#lp-material--group-container' );
66 el.materialTab = this.container.querySelector( '#lp-material-container' ) || this.container;
67 el.saveBtn = this.container.querySelector( '#btn-lp--save-material' );
68 el.uploadTemplate = this.container.querySelector( '#lp-material--upload-field-template' );
69 el.externalTemplate = this.container.querySelector( '#lp-material--external-field-template' );
70 el.deleteText = this.container.querySelector( '#delete-material-row-text' );
71 el.deleteMessage = this.container.querySelector( '#delete-material-message' );
72 el.materialTable = this.container.querySelector( '.lp-material--table' );
73 el.tbody = el.materialTable?.querySelector( 'tbody' );
74 el.thead = el.materialTable?.querySelector( 'thead' );
75 }
76
77 init() {
78 if ( ! this.container ) return;
79
80 // Cache all elements
81 this.cacheElements();
82
83 const { postId, materialTab } = this.elements;
84
85 // Validate required elements
86 if ( ! postId || ! materialTab ) return;
87
88 // Store config values
89 this.postID = postId.value;
90 this.maxFileSize = this.elements.maxFileSize?.value || 10;
91 this.acceptFile = this.elements.uploadField
92 ? this.elements.uploadField
93 .getAttribute( 'accept' )
94 ?.split( ',' )
95 .map( ( s ) => s.trim() ) || []
96 : [];
97
98 // Load existing materials
99 this.loadMaterials();
100
101 // Bind events
102 this.bindEvents();
103
104 // Initialize native drag & drop sortable
105 this.initSortable();
106
107 this.initialized = true;
108 }
109
110 /**
111 * Load materials from API with better error handling
112 */
113 async loadMaterials() {
114 const { materialTab, tbody } = this.elements;
115
116 if ( ! materialTab || ! this.postID || ! tbody ) return;
117
118 try {
119 const restUrl = this.getRestUrl();
120 const url = `${ restUrl }lp/v1/material/item-materials/${ this.postID }`;
121
122 const response = await fetch( url, {
123 method: 'GET',
124 headers: {
125 'X-WP-Nonce': this.getNonce(),
126 'Content-Type': 'application/json',
127 },
128 } );
129
130 if ( ! response.ok ) {
131 throw new Error( `HTTP error! status: ${ response.status }` );
132 }
133
134 const result = await response.json();
135 const { data, status } = result;
136
137 if ( status !== 'success' ) {
138 console.error( result.message );
139 return;
140 }
141
142 if ( data?.items?.length > 0 ) {
143 // Remove skeleton loader
144 const skeleton = materialTab.querySelector( '.lp-skeleton-animation' );
145 skeleton?.remove();
146
147 // Use DocumentFragment for better performance
148 const fragment = document.createDocumentFragment();
149 data.items.forEach( ( item ) => {
150 const row = this.createRow( item );
151 fragment.appendChild( row );
152 } );
153 tbody.appendChild( fragment );
154
155 // Reinit sortable after loading data
156 this.initSortable();
157 }
158 } catch ( error ) {
159 console.error( 'Load materials error:', error.message );
160 }
161 }
162
163 /**
164 * Create a table row element (more efficient than insertAdjacentHTML)
165 */
166 createRow( data ) {
167 const tr = document.createElement( 'tr' );
168 tr.dataset.id = data.file_id;
169 tr.dataset.sort = data.orders;
170 tr.draggable = true;
171
172 const deleteBtnText = this.elements.deleteText?.value || 'Delete';
173
174 tr.innerHTML = `
175 <td class="sort">
176 <span class="dashicons dashicons-menu"></span> ${ this.escapeHtml( data.file_name ) }
177 </td>
178 <td>${ this.capitalizeFirstChar( data.method ) }</td>
179 <td>
180 <a href="javascript:void(0)" class="delete-material-row" data-id="${ data.file_id }">
181 ${ deleteBtnText }
182 </a>
183 </td>
184 `;
185
186 return tr;
187 }
188
189 /**
190 * Escape HTML to prevent XSS
191 */
192 escapeHtml( text ) {
193 const div = document.createElement( 'div' );
194 div.textContent = text;
195 return div.innerHTML;
196 }
197
198 capitalizeFirstChar( str ) {
199 return str.charAt( 0 ).toUpperCase() + str.substring( 1 );
200 }
201
202 /**
203 * Bind all events with delegation for better performance
204 */
205 bindEvents() {
206 if ( this.eventsBound ) return;
207
208 const { addBtn, materialTab, saveBtn } = this.elements;
209
210 // Create bound handlers for later removal
211 this.boundHandlers.handleAddMaterial = () => this.handleAddMaterial();
212 this.boundHandlers.handleChange = ( e ) => this.handleChange( e );
213 this.boundHandlers.handleClick = ( e ) => this.handleClick( e );
214 this.boundHandlers.handleSaveAll = () => this.handleSaveAll();
215 this.boundHandlers.handleDelete = ( e ) => this.handleDelete( e );
216
217 // Add material button
218 addBtn?.addEventListener( 'click', this.boundHandlers.handleAddMaterial );
219
220 // Use event delegation on materialTab
221 if ( materialTab ) {
222 materialTab.addEventListener( 'change', this.boundHandlers.handleChange );
223 materialTab.addEventListener( 'click', this.boundHandlers.handleClick );
224 }
225
226 // Save all button
227 saveBtn?.addEventListener( 'click', this.boundHandlers.handleSaveAll );
228
229 // Delete material (use event delegation on container)
230 this.container.addEventListener( 'click', this.boundHandlers.handleDelete );
231
232 this.eventsBound = true;
233 }
234
235 /**
236 * Handle add material button click
237 */
238 handleAddMaterial() {
239 const { addBtn, groupContainer, groupTemplate } = this.elements;
240
241 if ( ! addBtn || ! groupContainer || ! groupTemplate ) return;
242
243 const canUploadData = parseInt( addBtn.getAttribute( 'can-upload' ) ) || 0;
244 const groups = groupContainer.querySelectorAll( '.lp-material--group' ).length;
245
246 if ( groups >= canUploadData ) return;
247
248 groupContainer.insertAdjacentHTML( 'afterbegin', groupTemplate.innerHTML );
249 }
250
251 /**
252 * Handle change events with delegation
253 */
254 handleChange( event ) {
255 const target = event.target;
256
257 // Switch between upload and external
258 if ( target.classList.contains( 'lp-material--field-method' ) ) {
259 this.handleMethodSwitch( target );
260 }
261
262 // File validation
263 if ( target.classList.contains( 'lp-material--field-upload' ) ) {
264 this.validateFile( target );
265 }
266 }
267
268 /**
269 * Handle method switch (upload/external)
270 */
271 handleMethodSwitch( target ) {
272 const method = target.value;
273 const { uploadTemplate, externalTemplate } = this.elements;
274
275 if ( ! uploadTemplate || ! externalTemplate ) return;
276
277 const group = target.closest( '.lp-material--group' );
278 if ( ! group ) return;
279
280 switch ( method ) {
281 case 'upload':
282 target.parentNode.insertAdjacentHTML( 'afterend', uploadTemplate.innerHTML );
283 group.querySelector( '.lp-material--external-wrap' )?.remove();
284 break;
285 case 'external':
286 target.parentNode.insertAdjacentHTML( 'afterend', externalTemplate.innerHTML );
287 group.querySelector( '.lp-material--upload-wrap' )?.remove();
288 break;
289 }
290 }
291
292 /**
293 * Validate uploaded file
294 */
295 validateFile( target ) {
296 if ( ! target.value || ! target.files?.length ) {
297 this.resetUploadLabel( target );
298 return;
299 }
300
301 const file = target.files[ 0 ];
302
303 if ( this.acceptFile.length > 0 && ! this.acceptFile.includes( file.type ) ) {
304 alert( 'This file is not allowed! Please choose another file!' );
305 target.value = '';
306 this.resetUploadLabel( target );
307 return;
308 }
309
310 if ( file.size > this.maxFileSize * 1024 * 1024 ) {
311 alert(
312 `This file size is greater than ${ this.maxFileSize }MB! Please choose another file!`
313 );
314 target.value = '';
315 this.resetUploadLabel( target );
316 return;
317 }
318
319 this.updateUploadLabel( target, file.name );
320 }
321
322 /**
323 * Update upload label to show selected file name
324 */
325 updateUploadLabel( target, fileName ) {
326 if ( ! fileName ) return;
327
328 const uploadLabel = target
329 .closest( '.lp-material--upload-wrap' )
330 ?.querySelector( 'label' );
331
332 if ( ! uploadLabel ) return;
333
334 uploadLabel.classList.add( 'has-selected-file' );
335 uploadLabel.setAttribute( 'data-file-name', fileName );
336 uploadLabel.setAttribute( 'title', fileName );
337 }
338
339 /**
340 * Reset upload label state
341 */
342 resetUploadLabel( target ) {
343 const uploadLabel = target
344 ?.closest( '.lp-material--upload-wrap' )
345 ?.querySelector( 'label' );
346
347 if ( ! uploadLabel ) return;
348
349 uploadLabel.classList.remove( 'has-selected-file' );
350 uploadLabel.removeAttribute( 'data-file-name' );
351 uploadLabel.removeAttribute( 'title' );
352 }
353
354 /**
355 * Handle click events with delegation
356 */
357 handleClick( event ) {
358 const target = event.target;
359
360 // Delete group
361 if ( target.classList.contains( 'lp-material--delete' ) && target.nodeName === 'BUTTON' ) {
362 target.closest( '.lp-material--group' )?.remove();
363 return;
364 }
365
366 // Save single material
367 if ( target.classList.contains( 'lp-material-save-field' ) ) {
368 const material = target.closest( '.lp-material--group' );
369 if ( material ) {
370 this.saveMaterial( [ material ], true, target );
371 }
372 }
373 }
374
375 /**
376 * Handle save all button
377 */
378 handleSaveAll() {
379 const { groupContainer, saveBtn } = this.elements;
380
381 if ( ! groupContainer ) return;
382
383 const materials = Array.from( groupContainer.querySelectorAll( '.lp-material--group' ) );
384 if ( materials.length > 0 ) {
385 this.saveMaterial( materials, false, saveBtn );
386 }
387 }
388
389 /**
390 * Save material(s) with improved validation
391 */
392 async saveMaterial( materials, isSingle = false, targetBtn ) {
393 if ( ! materials.length ) return;
394
395 const materialData = [];
396 const formData = new FormData();
397 let isValid = true;
398
399 for ( const ele of materials ) {
400 const label = ele.querySelector( '.lp-material--field-title' )?.value;
401 const method = ele.querySelector( '.lp-material--field-method' )?.value;
402 const externalField = ele.querySelector( '.lp-material--field-external-link' );
403 const uploadField = ele.querySelector( '.lp-material--field-upload' );
404
405 if ( ! label ) {
406 isValid = false;
407 break;
408 }
409
410 let file = '';
411 let link = '';
412
413 switch ( method ) {
414 case 'upload':
415 if ( uploadField?.value && uploadField.files?.length > 0 ) {
416 file = uploadField.files[ 0 ].name;
417 formData.append( 'file[]', uploadField.files[ 0 ] );
418 } else {
419 isValid = false;
420 }
421 break;
422 case 'external':
423 link = externalField?.value || '';
424 if ( ! link ) {
425 isValid = false;
426 }
427 break;
428 }
429
430 if ( ! isValid ) break;
431
432 materialData.push( { label, method, file, link } );
433 }
434
435 if ( ! isValid ) {
436 alert( 'Enter file title, choose file or enter file link!' );
437 return;
438 }
439
440 formData.append( 'data', JSON.stringify( materialData ) );
441 targetBtn?.classList.add( 'loading' );
442
443 try {
444 const restUrl = this.getRestUrl();
445 const url = `${ restUrl }lp/v1/material/item-materials/${ this.postID }`;
446
447 const response = await fetch( url, {
448 method: 'POST',
449 headers: {
450 'X-WP-Nonce': this.getNonce(),
451 },
452 body: formData,
453 } );
454
455 if ( ! response.ok ) {
456 throw new Error( `HTTP error! status: ${ response.status }` );
457 }
458
459 const text = await response.text();
460 let res;
461
462 try {
463 res = JSON.parse( text );
464 } catch ( e ) {
465 console.error( 'Response is not valid JSON:', text.substring( 0, 200 ) );
466 throw new Error( 'Server returned invalid response. Check console for details.' );
467 }
468
469 // Clear or remove materials
470 if ( ! isSingle ) {
471 materials.forEach( ( ele ) => {
472 ele.querySelector( '.lp-material--field-title' ).value = '';
473 const uploadField = ele.querySelector( '.lp-material--field-upload' );
474 if ( uploadField ) {
475 uploadField.value = '';
476 this.resetUploadLabel( uploadField );
477 }
478 const externalField = ele.querySelector( '.lp-material--field-external-link' );
479 if ( externalField ) externalField.value = '';
480 } );
481 } else {
482 materials[ 0 ].remove();
483 }
484
485 const { message, data, status } = res;
486 // console.log( message );
487
488 if ( status === 'success' && data?.length > 0 ) {
489 const { thead, tbody } = this.elements;
490
491 thead?.classList.remove( 'hidden' );
492
493 if ( tbody ) {
494 const fragment = document.createDocumentFragment();
495 data.forEach( ( row ) => {
496 fragment.appendChild( this.createRow( row ) );
497 } );
498 tbody.appendChild( fragment );
499 }
500
501 this.updateCanUploadCount( -data.length );
502 this.initSortable();
503 }
504 } catch ( err ) {
505 console.error( 'Save material error:', err );
506 alert( 'Error saving material: ' + err.message );
507 } finally {
508 targetBtn?.classList.remove( 'loading' );
509 }
510 }
511
512 /**
513 * Handle delete material
514 */
515 async handleDelete( e ) {
516 const target = e.target;
517
518 if ( ! target.classList.contains( 'delete-material-row' ) || target.nodeName !== 'A' ) {
519 return;
520 }
521
522 e.preventDefault();
523
524 const rowID = target.dataset.id;
525 const message =
526 this.elements.deleteMessage?.value || 'Are you sure you want to delete this material?';
527
528 if ( ! confirm( message ) ) return;
529
530 try {
531 const restUrl = this.getRestUrl();
532 const url = `${ restUrl }lp/v1/material/${ rowID }`;
533
534 const response = await fetch( url, {
535 method: 'DELETE',
536 headers: {
537 'X-WP-Nonce': this.getNonce(),
538 'Content-Type': 'application/json',
539 },
540 body: JSON.stringify( {
541 item_id: this.postID,
542 } ),
543 } );
544
545 const res = await response.json();
546
547 if ( res.status !== 200 || ! res.delete ) {
548 alert( res.message );
549 } else {
550 target.closest( 'tr' )?.remove();
551 this.updateCanUploadCount( 1 );
552 }
553 } catch ( err ) {
554 console.error( 'Delete material error:', err );
555 alert( 'Error deleting material: ' + err.message );
556 }
557 }
558
559 /**
560 * Update can upload count
561 */
562 updateCanUploadCount( delta ) {
563 const { canUpload, addBtn } = this.elements;
564
565 if ( canUpload && addBtn ) {
566 const newCount = parseInt( canUpload.textContent ) + delta;
567 canUpload.textContent = newCount;
568 addBtn.setAttribute( 'can-upload', newCount );
569 }
570 }
571
572 /**
573 * Initialize native drag & drop sortable (no jQuery)
574 */
575 initSortable() {
576 const { tbody } = this.elements;
577
578 if ( ! tbody ) return;
579
580 // Remove existing listeners
581 this.destroySortable();
582
583 const rows = tbody.querySelectorAll( 'tr' );
584
585 // Create bound handlers
586 this.boundHandlers.handleDragStart = ( e ) => this.handleDragStart( e );
587 this.boundHandlers.handleDragOver = ( e ) => this.handleDragOver( e );
588 this.boundHandlers.handleDrop = ( e ) => this.handleDrop( e );
589 this.boundHandlers.handleDragEnd = () => this.handleDragEnd();
590
591 rows.forEach( ( row ) => {
592 row.draggable = true;
593 row.addEventListener( 'dragstart', this.boundHandlers.handleDragStart );
594 row.addEventListener( 'dragover', this.boundHandlers.handleDragOver );
595 row.addEventListener( 'drop', this.boundHandlers.handleDrop );
596 row.addEventListener( 'dragend', this.boundHandlers.handleDragEnd );
597 } );
598
599 this.sortable = { tbody, rows };
600 }
601
602 handleDragStart( e ) {
603 this.draggedElement = e.currentTarget;
604 e.currentTarget.style.opacity = '0.4';
605 e.dataTransfer.effectAllowed = 'move';
606 e.dataTransfer.setData( 'text/html', e.currentTarget.innerHTML );
607 }
608
609 handleDragOver( e ) {
610 if ( e.preventDefault ) {
611 e.preventDefault();
612 }
613 e.dataTransfer.dropEffect = 'move';
614
615 const target = e.currentTarget;
616 if ( this.draggedElement !== target ) {
617 const rect = target.getBoundingClientRect();
618 const next = ( e.clientY - rect.top ) / ( rect.bottom - rect.top ) > 0.5;
619 target.parentNode.insertBefore( this.draggedElement, next ? target.nextSibling : target );
620 }
621
622 return false;
623 }
624
625 handleDrop( e ) {
626 if ( e.stopPropagation ) {
627 e.stopPropagation();
628 }
629 return false;
630 }
631
632 handleDragEnd() {
633 this.draggedElement.style.opacity = '1';
634 this.draggedElement = null;
635
636 // Update sort order after drag ends
637 this.updateSort();
638 }
639
640 /**
641 * Update sort order
642 */
643 async updateSort() {
644 const { tbody } = this.elements;
645 if ( ! tbody ) return;
646
647 const items = tbody.querySelectorAll( 'tr' );
648 const data = Array.from( items ).map( ( item, index ) => {
649 item.dataset.sort = index + 1;
650 return {
651 file_id: parseInt( item.dataset.id ),
652 orders: index + 1,
653 };
654 } );
655
656 try {
657 const restUrl = this.getRestUrl();
658 const url = `${ restUrl }lp/v1/material/item-materials/${ this.postID }`;
659
660 const response = await fetch( url, {
661 method: 'PUT',
662 headers: {
663 'X-WP-Nonce': this.getNonce(),
664 'Content-Type': 'application/json',
665 },
666 body: JSON.stringify( {
667 sort_arr: JSON.stringify( data ),
668 } ),
669 } );
670
671 const res = await response.json();
672
673 if ( res.status !== 200 ) {
674 console.error( 'Sort table fail.' );
675 }
676 } catch ( err ) {
677 console.error( 'Update sort error:', err );
678 }
679 }
680
681 /**
682 * Helper methods for REST URL and nonce
683 */
684 getRestUrl() {
685 return window.lpGlobalSettings?.rest || window.lpData?.lp_rest_url || '/wp-json/';
686 }
687
688 getNonce() {
689 return window.lpGlobalSettings?.nonce || window.lpData?.nonce || '';
690 }
691
692 /**
693 * Destroy sortable
694 */
695 destroySortable() {
696 if ( ! this.sortable ) return;
697
698 const { rows } = this.sortable;
699 rows?.forEach( ( row ) => {
700 row.removeEventListener( 'dragstart', this.boundHandlers.handleDragStart );
701 row.removeEventListener( 'dragover', this.boundHandlers.handleDragOver );
702 row.removeEventListener( 'drop', this.boundHandlers.handleDrop );
703 row.removeEventListener( 'dragend', this.boundHandlers.handleDragEnd );
704 } );
705
706 this.sortable = null;
707 }
708
709 /**
710 * Destroy instance and cleanup
711 */
712 destroy() {
713 const { addBtn, materialTab, saveBtn } = this.elements;
714
715 // Remove event listeners
716 if ( addBtn ) {
717 addBtn.removeEventListener( 'click', this.boundHandlers.handleAddMaterial );
718 }
719
720 if ( materialTab ) {
721 materialTab.removeEventListener( 'change', this.boundHandlers.handleChange );
722 materialTab.removeEventListener( 'click', this.boundHandlers.handleClick );
723 }
724
725 if ( saveBtn ) {
726 saveBtn.removeEventListener( 'click', this.boundHandlers.handleSaveAll );
727 }
728
729 if ( this.container ) {
730 this.container.removeEventListener( 'click', this.boundHandlers.handleDelete );
731 }
732
733 // Destroy sortable
734 this.destroySortable();
735
736 // Reset bound handlers
737 this.boundHandlers = {
738 handleAddMaterial: null,
739 handleChange: null,
740 handleClick: null,
741 handleSaveAll: null,
742 handleDelete: null,
743 handleDragStart: null,
744 handleDragOver: null,
745 handleDrop: null,
746 handleDragEnd: null,
747 };
748
749 // Clear cached elements
750 this.elements = {};
751
752 this.initialized = false;
753 this.eventsBound = false;
754 }
755 }
756
757 export default BuilderMaterial;
758