PHP Classes

File: aksara/Modules/Cms/assets/js/pagebuilder.min.js

Recommend this page to a friend!
  Packages of Aby Dahana   Aksara   aksara/Modules/Cms/assets/js/pagebuilder.min.js   Download  
File: aksara/Modules/Cms/assets/js/pagebuilder.min.js
Role: Auxiliary data
Content type: text/plain
Description: Auxiliary data
Class: Aksara
A CodeIgniter based API and CRUD generator
Author: By
Last change:
Date: 1 month ago
Size: 80,137 bytes
 

Contents

Class file image Download
/** * Aksara Page Builder v2 * A modern, modular, and immersive page building engine for Aksara CMS. */ (function() { 'use strict'; /** * Main Page Builder Class * @param {Object} options Configuration options */ window.AksaraPageBuilder = function(options) { this.options = options; this.container = document.querySelector(options.el || '#page-builder'); this.inputElement = document.querySelector(options.input || '#page_content'); this.componentDefinitions = options.components || {}; this.categories = options.categories || {}; this.layout = { version: '1.0', framework: 'bootstrap5', components: [] }; this.selectedId = null; this.history = []; this.historyIndex = -1; this.idCounter = 0; this.debounceTimer = null; if (this.container) { this.initialize(); } }; const Prototype = AksaraPageBuilder.prototype; /** * Initialize the Page Builder */ Prototype.initialize = function() { this.buildUserInterface(); this.bindEvents(); this.loadFromInput(); this.render(); this.initTooltips(); }; /** * Initialize Bootstrap Tooltips */ Prototype.initTooltips = function() { if (window.bootstrap && bootstrap.Tooltip) { // Clean up orphaned tooltip elements from the DOM document.querySelectorAll('.tooltip').forEach(el => el.remove()); const tooltips = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); tooltips.forEach(function(tooltipTriggerEl) { // Dispose existing instance to prevent duplicates/leaks const instance = bootstrap.Tooltip.getInstance(tooltipTriggerEl); if (instance) instance.dispose(); new bootstrap.Tooltip(tooltipTriggerEl); }); } }; /** * Generate a unique ID for components */ Prototype.generateUniqueId = function() { return 'pb_' + (++this.idCounter) + '_' + Date.now().toString(36); }; /** * Build the main UI structure */ Prototype.buildUserInterface = function() { // Check if toolbar exists outside the container this.toolbar = document.querySelector('.pb-toolbar'); // Check if wrapper exists, if not create it if (!this.container.querySelector('.pb-wrapper')) { this.container.innerHTML = '<div class="pb-wrapper">' + '<div class="pb-sidebar" id="pb-sidebar"></div>' + '<div class="pb-canvas-wrapper"><div class="pb-canvas" id="pb-canvas"></div></div>' + '<div class="pb-properties" id="pb-props">' + '<div class="pb-props-empty">' + '<i class="mdi mdi-cursor-default-click mdi-3x d-block mb-2"></i>' + phrase('Select a component to edit its properties') + '</div>' + '</div>' + '</div>'; } this.sidebarElement = this.container.querySelector('#pb-sidebar'); this.canvasElement = this.container.querySelector('#pb-canvas'); this.propertiesElement = this.container.querySelector('#pb-props'); this.buildSidebar(); }; /** * Build the components sidebar */ Prototype.buildSidebar = function() { let html = ''; const categories = this.categories; const components = this.componentDefinitions; for (const categoryId in categories) { html += '<div class="pb-sidebar-title">' + phrase(categories[categoryId]) + '</div>'; for (const componentType in components) { if (components[componentType].category === categoryId) { let group = 'pb-content'; if (componentType === 'section') group = 'pb-sec'; else if (componentType === 'container') group = 'pb-con'; else if (componentType === 'row') group = 'pb-row'; else if (componentType === 'column') group = 'pb-col'; html += '<div class="pb-component-item" data-type="' + componentType + '" data-group="' + group + '">' + '<i class="' + (components[componentType].icon || 'mdi mdi-square-outline') + '"></i>' + '<span>' + phrase(components[componentType].label || componentType) + '</span>' + '</div>'; } } } this.sidebarElement.innerHTML = html; }; /** * Bind all global events */ Prototype.bindEvents = function() { const self = this; // Sidebar items: make them draggable this.sidebarElement.querySelectorAll('.pb-component-item').forEach(function(item) { item.setAttribute('draggable', 'true'); item.addEventListener('dragstart', function(event) { self.draggedType = item.dataset.type; event.dataTransfer.setData('text/plain', item.dataset.type); event.dataTransfer.effectAllowed = 'copy'; }); item.addEventListener('dragend', function() { self.draggedType = null; }); }); // Canvas clicks and interactions this.canvasElement.addEventListener('click', function(event) { const actionButton = event.target.closest('[data-act]'); if (actionButton) { const block = actionButton.closest('.pb-block'); if (!block) return; const id = block.dataset.id; const action = actionButton.dataset.act; event.stopPropagation(); if (action === 'del') self.deleteComponent(id); else if (action === 'dup') self.duplicateComponent(id); else if (action === 'up') self.moveComponent(id, -1); else if (action === 'down') self.moveComponent(id, 1); return; } const block = event.target.closest('.pb-block'); if (block) { self.selectedId = block.dataset.id; self.renderProperties(); self.highlightComponent(); event.stopPropagation(); } else { self.selectedId = null; self.renderProperties(); self.highlightComponent(); } }); // Toolbar interactions if (this.toolbar) { const undoBtn = this.toolbar.querySelector('.pb-undo'); if (undoBtn) undoBtn.onclick = function() { self.undo(); }; const redoBtn = this.toolbar.querySelector('.pb-redo'); if (redoBtn) redoBtn.onclick = function() { self.redo(); }; const previewBtn = this.toolbar.querySelector('.pb-preview-btn'); if (previewBtn) previewBtn.onclick = function(event) { event.preventDefault(); self.preview(); }; const saveBtn = this.toolbar.querySelector('.pb-save-btn'); if (saveBtn) saveBtn.onclick = function() { self.submitForm(this); }; this.toolbar.querySelectorAll('.pb-device-btn').forEach(function(btn) { btn.onclick = function() { self.toolbar.querySelectorAll('.pb-device-btn').forEach(function(x) { x.classList.remove('active'); }); btn.classList.add('active'); const canvas = self.canvasElement; canvas.classList.remove('pb-tablet', 'pb-mobile'); if (btn.dataset.device === 'tablet') canvas.classList.add('pb-tablet'); else if (btn.dataset.device === 'mobile') canvas.classList.add('pb-mobile'); }; }); } }; /** * Create a new component object */ Prototype.createComponent = function(type, overrides, isChild) { const definition = this.componentDefinitions[type]; if (!definition) return null; const props = JSON.parse(JSON.stringify(definition.defaults || {})); if (overrides) { for (const key in overrides) props[key] = overrides[key]; } const component = { type: type, id: this.generateUniqueId(), props: props }; if (definition.children) { component.children = []; if (type === 'row') { component.children = [this.createComponent('column', { size: { md: 12 } }, true)]; } } return component; }; /** * Render the entire canvas */ Prototype.render = function() { let html = '<div class="pb-drop-root" data-type="root">'; if (!this.layout.components.length) { html += '<div class="pb-canvas-empty">' + '<i class="mdi mdi-drag-variant"></i>' + '<div>' + phrase('Drag components here to start building') + '</div>' + '</div>'; } else { html += this.renderBlocks(this.layout.components); } html += '</div>'; this.canvasElement.innerHTML = html; this.initializeSortable(); this.initializeColumnResizing(); this.syncToInput(); this.highlightComponent(); this.initTooltips(); }; /** * Initialize SortableJS on all dropzones */ Prototype.initializeSortable = function() { const self = this; const zones = this.canvasElement.querySelectorAll('.pb-dropzone'); zones.forEach(function(zone) { if (zone._sortable) zone._sortable.destroy(); zone._sortable = new Sortable(zone, { group: { name: (zone.dataset.type === 'row' ? 'pb-col' : (zone.dataset.type === 'column' ? 'pb-content' : (zone.dataset.type === 'container' ? 'pb-row' : (zone.dataset.type === 'section' ? 'pb-con' : 'pb-sec')))), pull: true, put: function(to, from, dragged) { const targetEl = to.el; const targetType = targetEl.dataset.type || targetEl.getAttribute('data-type'); const draggedType = dragged.dataset.type || dragged.getAttribute('data-type') || self.draggedType; if (!draggedType) return false; // STRICT ROW LOCK: Only allow columns if (targetType === 'row') { return draggedType === 'column'; } // STRICT COLUMN LOCK: Allow content and rows if (targetType === 'column') { const isStructural = ['section', 'container', 'row', 'column'].includes(draggedType); return !isStructural || draggedType === 'row'; } // ROOT / SECTION / CONTAINER LOCKS if (targetType === 'root') return draggedType === 'section'; if (targetType === 'section') return draggedType === 'container'; if (targetType === 'container') return draggedType === 'row' || !['section', 'container', 'row', 'column'].includes(draggedType); // GENERIC COMPONENT LOCK (e.g. Card, Accordion items) // Allow everything EXCEPT structural components const isStructural = ['section', 'container', 'row', 'column'].includes(draggedType); return !isStructural; } }, animation: 150, fallbackOnBody: true, swapThreshold: 0.65, draggable: '.pb-block', onStart: function(event) { const type = event.item.dataset.type || event.item.getAttribute('data-type'); self.draggedType = type; // Activate Mechanical Gatekeeper via classes if (type === 'row') { self.canvasElement.classList.add('pb-dragging-row'); } else if (type === 'column') { self.canvasElement.classList.add('pb-dragging-column'); } else { self.canvasElement.classList.add('pb-dragging-content'); } }, onMove: function(event) { const container = event.to; if (!container) return false; const targetType = container.getAttribute('data-type') || container.dataset.type; const dragged = event.dragged; const draggedType = dragged.getAttribute('data-type') || (dragged.dataset ? dragged.dataset.type : null) || self.draggedType; if (!draggedType) return true; const isStructural = ['section', 'container', 'row', 'column'].includes(draggedType); // ABSOLUTE VETO FOR ROW: Row only accepts Columns if (targetType === 'row' && draggedType !== 'column') return false; // COLUMN FLEXIBILITY: Column accepts Content and Rows if (targetType === 'column' && isStructural && draggedType !== 'row') return false; // HIERARCHICAL PLACEHOLDER LOCK if (draggedType === 'column' && targetType !== 'row') return false; if (draggedType === 'row' && targetType !== 'container' && targetType !== 'column') return false; if (draggedType === 'container' && targetType !== 'section') return false; if (draggedType === 'section' && targetType !== 'root') return false; return true; }, onEnd: function(event) { self.draggedType = null; self.canvasElement.classList.remove('pb-dragging-column', 'pb-dragging-content', 'pb-dragging-row'); }, onAdd: function(event) { const itemElement = event.item; const container = event.to; const parentId = container.dataset.parent; const parentInfo = parentId ? self.findComponent(parentId) : { component: { children: self.layout.components }, list: self.layout.components }; // Calculate actual index among blocks let actualIndex = 0; const children = Array.from(container.children); for (let i = 0; i < children.indexOf(itemElement); i++) { if (children[i].classList.contains('pb-block')) actualIndex++; } if (itemElement.classList.contains('pb-component-item')) { const type = itemElement.dataset.type; const targetType = container.dataset.type || container.getAttribute('data-type'); // FINAL GATEKEEPER: Last-second validation before adding if (targetType === 'row' && type !== 'column') { itemElement.remove(); // Remove the invalid sidebar clone return; } if (targetType === 'column' && ['section', 'container', 'row', 'column'].includes(type) && type !== 'row') { itemElement.remove(); return; } let component = null; if (type === 'column') { if (!parentInfo || parentInfo.component.type !== 'row') { component = self.createComponent('row'); const column = self.createComponent('column', { size: { md: 12 } }, true); component.children = [column]; } else { let sum = 0; (parentInfo.component.children || []).forEach(function(child) { if (child.type === 'column' && child.props && child.props.size) { sum += parseInt(child.props.size.md || 12); } }); let remaining = 12 - (sum % 12); if (remaining === 0) remaining = 12; component = self.createComponent('column', { size: { md: remaining } }); } } else { component = self.createComponent(type); } if (component) { if (parentInfo) { // Ensure children array exists if component is a container if (!parentInfo.component.children) parentInfo.component.children = []; parentInfo.component.children.splice(actualIndex, 0, component); self.saveToHistory(); self.render(); self.selectComponent(component.id); } } } else { const id = itemElement.dataset.id; if (!id) return; const info = self.findComponent(id); if (!info) return; // Remove from old location info.list.splice(info.index, 1); // Add to new location if (parentInfo) { // Ensure children array exists if component is a container if (!parentInfo.component.children) parentInfo.component.children = []; parentInfo.component.children.splice(actualIndex, 0, info.component); } self.saveToHistory(); self.render(); self.selectComponent(id); } }, onEnd: function(event) { self.draggedType = null; self.canvasElement.classList.remove('pb-dragging-column', 'pb-dragging-content', 'pb-dragging-row'); if (event.from === event.to) { const parentId = event.from.dataset.parent; const info = parentId ? self.findComponent(parentId) : { component: { children: self.layout.components } }; const list = parentId ? (info ? info.component.children : null) : self.layout.components; if (list) { const item = list.splice(event.oldIndex, 1)[0]; list.splice(event.newIndex, 0, item); self.saveToHistory(); self.render(); } } } }); }); // Root level sortable const root = this.canvasElement.querySelector('.pb-drop-root'); if (root) { if (root._sortable) root._sortable.destroy(); root._sortable = new Sortable(root, { group: { name: 'pb-sec', put: function(to, from, dragged) { const draggedType = dragged.dataset.type || dragged.getAttribute('data-type') || self.draggedType; return draggedType === 'section'; } }, animation: 150, fallbackOnBody: true, swapThreshold: 0.65, draggable: '.pb-block', onAdd: function(event) { const itemElement = event.item; const container = event.to; const type = itemElement.dataset.type || itemElement.getAttribute('data-type') || self.draggedType; // ROOT GATEKEEPER: Only sections in root (Strict) if (type !== 'section') { itemElement.remove(); return; } // Calculate actual index among blocks let actualIndex = 0; const children = Array.from(container.children); for (let i = 0; i < children.indexOf(itemElement); i++) { if (children[i].classList.contains('pb-block')) actualIndex++; } if (itemElement.classList.contains('pb-component-item')) { const type = itemElement.dataset.type; let component = null; if (type === 'column') { component = self.createComponent('row'); const column = self.createComponent('column', { size: { md: 12 } }, true); component.children = [column]; } else { component = self.createComponent(type); } if (component) { self.layout.components.splice(actualIndex, 0, component); self.saveToHistory(); self.render(); self.selectComponent(component.id); } } else { const id = itemElement.dataset.id; if (!id) return; const info = self.findComponent(id); if (!info) return; // Remove from old location info.list.splice(info.index, 1); // Add to root at correct index self.layout.components.splice(actualIndex, 0, info.component); self.saveToHistory(); self.render(); self.selectComponent(id); } }, onEnd: function(event) { if (event.from === event.to) { const list = self.layout.components; const item = list.splice(event.oldIndex, 1)[0]; list.splice(event.newIndex, 0, item); self.saveToHistory(); self.render(); } } }); } // Sidebar Sortable (Source only) if (!this.sidebarElement._sortable) { this.sidebarElement._sortable = new Sortable(this.sidebarElement, { group: { name: 'pb-sidebar', pull: 'clone', put: false }, sort: false, draggable: '.pb-component-item', animation: 150 }); } }; /** * Initialize Column Resizing handles */ Prototype.initializeColumnResizing = function() { const self = this; this.canvasElement.querySelectorAll('.pb-col-resize').forEach(function(handle) { handle.addEventListener('mousedown', function(event) { event.preventDefault(); event.stopPropagation(); const columnId = handle.dataset.colId; const columnBlock = handle.closest('.pb-block[data-type="column"]'); const rowDropzone = columnBlock.parentElement; const columnBlocks = Array.from(rowDropzone.querySelectorAll(':scope > .pb-block[data-type="column"]')); const columnIdx = columnBlocks.indexOf(columnBlock); if (columnIdx < 0) return; const rowWidth = rowDropzone.getBoundingClientRect().width; const columnUnit = rowWidth / 12; const startX = event.clientX; const currentInfo = self.findComponent(columnId); if (!currentInfo) return; const startSize = (currentInfo.component.props.size && currentInfo.component.props.size.md) || 6; const sizeBadge = columnBlock.querySelector('.pb-col-size-badge'); document.body.classList.add('pb-resizing-active'); handle.classList.add('pb-resizing'); const onMouseMove = function(moveEvent) { const deltaX = moveEvent.clientX - startX; let newSize = Math.round(startSize + (deltaX / columnUnit)); newSize = Math.max(1, Math.min(12, newSize)); columnBlock.style.flex = '0 0 ' + (newSize / 12 * 100).toFixed(2) + '%'; columnBlock.style.maxWidth = (newSize / 12 * 100).toFixed(2) + '%'; if (sizeBadge) sizeBadge.textContent = newSize + '/12'; handle._pendingSize = newSize; }; const onMouseUp = function() { document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); document.body.classList.remove('pb-resizing-active'); handle.classList.remove('pb-resizing'); const finalSize = handle._pendingSize; if (finalSize) { if (!currentInfo.component.props.size) currentInfo.component.props.size = {}; currentInfo.component.props.size.md = finalSize; self.saveToHistory(); self.render(); if (self.selectedId === columnId) self.renderProperties(); } }; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); }); }); }; /** * Render a list of components recursively */ Prototype.renderBlocks = function(components) { let html = ''; if (!components || !Array.isArray(components)) return html; for (let i = 0; i < components.length; i++) { if (components[i]) html += this.renderBlock(components[i]); } return html; }; /** * Render a single component block */ Prototype.renderBlock = function(component) { if (!component || !component.type) return ''; const definition = this.componentDefinitions[component.type] || {}; const isSelected = this.selectedId === component.id ? ' pb-selected' : ''; let inlineStyle = ''; let classes = 'pb-block' + isSelected; const props = component.props || {}; if (component.type === 'column') { const mdSize = props.size && props.size.md ? props.size.md : 0; if (mdSize > 0 && mdSize <= 12) { const percentage = (mdSize / 12 * 100).toFixed(4); inlineStyle = ' style="flex:0 0 ' + percentage + '%;max-width:' + percentage + '%"'; } if (props.align_self) classes += ' ' + props.align_self; } if (props['class']) classes += ' ' + props['class']; let group = 'pb-content'; if (component.type === 'section') group = 'pb-sec'; else if (component.type === 'container') group = 'pb-con'; else if (component.type === 'row') group = 'pb-row'; else if (component.type === 'column') group = 'pb-col'; let html = '<div class="' + classes + '" data-type="' + component.type + '" data-id="' + component.id + '" data-group="' + group + '"' + inlineStyle + '>'; // Label and actions html += '<span class="pb-block-label"><i class="' + (definition.icon || '') + ' me-1"></i>' + phrase(definition.label || component.type) + '</span>'; html += '<div class="pb-block-actions">' + '<button data-act="up" data-bs-toggle="tooltip" title="' + phrase('Move Up') + '"><i class="mdi mdi-chevron-up"></i></button>' + '<button data-act="down" data-bs-toggle="tooltip" title="' + phrase('Move Down') + '"><i class="mdi mdi-chevron-down"></i></button>' + '<button data-act="dup" data-bs-toggle="tooltip" class="pb-act-dup" title="' + phrase('Duplicate') + '"><i class="mdi mdi-content-copy"></i></button>' + '<button data-act="del" data-bs-toggle="tooltip" class="pb-act-del" title="' + phrase('Delete') + '"><i class="mdi mdi-delete"></i></button>' + '</div>'; // Column specific controls if (component.type === 'column') { const sizeValue = component.props && component.props.size && component.props.size.md ? component.props.size.md : 'auto'; html += '<span class="pb-col-size-badge">' + sizeValue + '/12</span>'; html += '<div class="pb-col-resize" data-col-id="' + component.id + '"></div>'; } // Content preview html += '<div class="pb-block-content">' + this.renderContent(component) + '</div>'; // Children dropzone if (definition.children) { let dropzoneClasses = 'pb-dropzone'; if (component.type === 'row') { dropzoneClasses += ' pb-drop-row'; if (props.align_items) dropzoneClasses += ' ' + props.align_items; if (props.justify_content) dropzoneClasses += ' ' + props.justify_content; } else if (component.type === 'column') { dropzoneClasses += ' pb-drop-col'; } html += '<div class="' + dropzoneClasses + '" data-parent="' + component.id + '" data-type="' + component.type + '">'; if (component.children && component.children.length) { html += this.renderBlocks(component.children); } html += '</div>'; } html += '</div>'; return html; }; /** * Render internal content of a component for preview */ Prototype.renderContent = function(component) { const props = component.props || {}; switch (component.type) { case 'heading': return '<h' + (props.level || 2) + ' style="margin:4px 0;text-align:' + (props.alignment || 'left') + '">' + phrase(props.text || 'Heading') + '</h' + (props.level || 2) + '>'; case 'paragraph': return '<div style="text-align:' + (props.alignment || 'left') + '">' + this.markdownToHtml(props.text || 'Paragraph text...') + '</div>'; case 'alert': return '<div class="alert alert-' + (props.style || 'primary') + ' m-0">' + this.markdownToHtml(props.text || 'Alert message...') + '</div>'; case 'divider': return '<hr/>'; case 'button': const icon = props.icon ? '<i class="' + props.icon + (props.text ? (props.icon_placement === 'suffix' ? ' ms-2' : ' me-2') : '') + '"></i>' : ''; const btnText = phrase(props.text || 'Button'); const btnContent = props.icon_placement === 'suffix' ? btnText + icon : icon + btnText; return '<span class="btn btn-' + (props.style || 'primary') + ' btn-sm' + (props.rounded ? ' rounded-pill' : '') + ' ' + (props.class || '') + '" style="pointer-events:none">' + btnContent + '</span>'; case 'image': return props.src ? '<img src="' + props.src + '" alt="' + phrase(props.alt || '') + '" class="img-fluid" style="border-radius:8px"/>' : '<div class="text-center text-muted py-3"><i class="mdi mdi-image mdi-3x"></i><br><small>' + phrase('No image selected') + '</small></div>'; case 'video': return '<div class="text-center text-muted py-3"><i class="mdi mdi-video mdi-3x"></i><br><small>' + phrase(props.url || 'No Video URL') + '</small></div>'; case 'alert': return '<div class="alert alert-' + (props.style || 'info') + ' mb-0 py-1">' + phrase(props.text || 'Alert Content') + '</div>'; case 'hero': const heroBg = props.background ? (props.background.startsWith('#') || props.background.startsWith('rgb') ? props.background : 'url(' + props.background + ') center/cover') : 'linear-gradient(135deg,#667eea,#764ba2)'; return '<div class="text-center py-4 position-relative overflow-hidden" style="background:' + heroBg + ';border-radius:8px;color:#fff;padding:24px">' + (props.background && props.overlay !== false ? '<div style="position:absolute;inset:0;background:rgba(0,0,0,0.5);z-index:1"></div>' : '') + '<div class="position-relative" style="z-index:2"><h3 class="fw-bold">' + phrase(props.title || 'Hero Title') + '</h3><p class="mb-2">' + (this.markdownToHtml(props.subtitle) || '') + '</p>' + (props.button_text ? '<span class="btn btn-light btn-sm rounded-pill">' + phrase(props.button_text) + '</span>' : '') + '</div></div>'; case 'feature_box': return '<div class="text-center py-2"><i class="' + (props.icon || 'mdi mdi-star') + ' mdi-2x d-block mb-1"></i><strong>' + phrase(props.title || 'Feature') + '</strong><br><small class="text-muted">' + phrase(props.text || '') + '</small></div>'; case 'card': let cardImg = props.image ? '<img src="' + props.image + '" class="img-fluid rounded-top" style="width:100%;height:150px;object-fit:cover"/>' : ''; return cardImg + '<div class="p-3"><strong>' + phrase(props.title || 'Card Title') + '</strong><div class="text-muted small">' + (this.markdownToHtml(props.text) || '') + '</div></div>'; case 'spacer': return '<div style="height:' + (props.height || 40) + 'px;background:repeating-linear-gradient(45deg,transparent,transparent 5px,var(--pb-spacer-pattern) 5px,var(--pb-spacer-pattern) 10px);border-radius:4px"></div>'; case 'accordion': let accHtml = ''; (props.items || []).forEach(item => { accHtml += '<div class="border rounded p-2 mb-1"><strong>' + phrase(item.title) + '</strong><br><small class="text-muted">' + phrase(this.markdownToHtml(item.body)) + '</small></div>'; }); return accHtml; case 'carousel': let carHtml = '<div class="bg-light rounded p-3 text-center"><i class="mdi mdi-view-carousel-outline mdi-2x"></i><strong>' + phrase('Carousel Slider') + '</strong><div class="mt-2 small">'; (props.items || []).forEach(function(item, i) { carHtml += '<span class="badge bg-secondary me-1">' + (i + 1) + '</span>'; }); return carHtml + '</div></div>'; case 'tabs': let tabHtml = '<div class="bg-light rounded p-2"><strong>' + phrase('Tabs') + ' (' + phrase(props.alignment || 'horizontal') + ')</strong><div class="d-flex gap-1 mt-1">'; (props.items || []).forEach(item => { tabHtml += '<div class="border rounded px-2 py-1 small bg-light">' + phrase(item.title) + '</div>'; }); return tabHtml + '</div></div>'; case 'pricing': const priceFeatured = props.featured ? ' border-primary' : ''; return '<div class="border rounded p-3 text-center bg-light' + priceFeatured + '"><h5 class="fw-bold mb-1">' + phrase(props.title || 'Plan') + '</h5><h4 class="mb-0">' + (props.price || '$0') + '</h4><small class="text-muted d-block mb-2">' + phrase(props.period || '') + '</small><span class="btn btn-primary btn-sm rounded-pill">' + phrase(props.btn_text || 'Buy Now') + '</span></div>'; case 'testimonial': const testImg = props.image ? '<img src="' + props.image + '" class="rounded-circle me-2" style="width:30px;height:30px;object-fit:cover"/>' : '<div class="rounded-circle bg-secondary me-2" style="width:30px;height:30px"></div>'; return '<div class="bg-light rounded p-3 italic">' + phrase(this.markdownToHtml(props.quote) || '...') + '<div class="mt-2 d-flex align-items-center">' + testImg + '<strong>' + phrase(props.author || '') + '</strong></div></div>'; case 'team_member': const memberImg = props.image ? '<img src="' + props.image + '" class="rounded-circle mx-auto mb-2" style="width:60px;height:60px;object-fit:cover;display:block"/>' : '<div class="rounded-circle bg-secondary mx-auto mb-2" style="width:60px;height:60px"></div>'; return '<div class="text-center">' + memberImg + '<strong>' + phrase(props.name || '') + '</strong><br><small class="text-primary">' + phrase(props.role || '') + '</small></div>'; case 'cta': const ctaBg = props.background === 'primary' ? 'linear-gradient(135deg,#0d6efd,#0b5ed7)' : (props.background === 'dark' ? '#212529' : '#f8f9fa'); const ctaColor = props.background === 'light' ? '#212529' : '#fff'; const ctaRounding = (props.class && props.class.indexOf('rounded') > -1) ? '' : 'rounded-4'; return '<div class="d-flex justify-content-between align-items-center py-4 px-5 text-start ' + ctaRounding + ' ' + (props.class || '') + '" style="background:' + ctaBg + ';color:' + ctaColor + ';min-height:120px">' + '<div><h4 class="fw-bold mb-1">' + phrase(props.title || 'CTA Title') + '</h4><p class="mb-0 opacity-75">' + phrase(props.text || 'Join thousands of satisfied customers.') + '</p></div>' + '<div><span class="btn btn-' + (props.background === 'light' ? 'primary' : 'light') + ' rounded-pill px-4 py-2 fw-bold">' + phrase(props.button_text || 'Join Now') + '</span></div>' + '</div>'; default: if (['section', 'container', 'row', 'column'].indexOf(component.type) !== -1) return ''; return '<div class="text-muted text-center py-2"><small>' + phrase(component.type) + '</small></div>'; } }; /** * Simple HTML to Markdown converter */ Prototype.htmlToMarkdown = function(html) { if (!html) return ''; let md = html; md = md.replace(/<b>(.*?)<\/b>|<strong>(.*?)<\/strong>/gi, '**$1$2**'); md = md.replace(/<i>(.*?)<\/i>|<em>(.*?)<\/em>/gi, '_$1$2_'); md = md.replace(/<u>(.*?)<\/u>/gi, '<u>$1</u>'); // HTML fallback for underline md = md.replace(/<strike>(.*?)<\/strike>|<s>(.*?)<\/s>|<del>(.*?)<\/del>/gi, '~~$1$2$3~~'); md = md.replace(/<a.*?href="(.*?)".*?>(.*?)<\/a>/gi, '[$2]($1)'); md = md.replace(/<ul>(.*?)<\/ul>/gi, (m, c) => c.replace(/<li>(.*?)<\/li>/gi, (m2, c2) => '* ' + c2 + '\n')); md = md.replace(/<ol>(.*?)<\/ol>/gi, (m, c) => { let i = 1; return c.replace(/<li>(.*?)<\/li>/gi, (m2, c2) => (i++) + '. ' + c2 + '\n'); }); md = md.replace(/<br\s*\/?>/gi, '\n'); md = md.replace(/<p>(.*?)<\/p>/gi, '$1\n\n'); md = md.replace(/&nbsp;/g, ' '); md = md.replace(/<.*?>/g, ''); // Strip remaining tags return md.trim(); }; /** * Simple Markdown to HTML converter */ Prototype.markdownToHtml = function(md) { if (!md) return ''; let html = md; html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>'); html = html.replace(/_(.*?)_/g, '<em>$1</em>'); html = html.replace(/~~(.*?)~~/g, '<del>$1</del>'); html = html.replace(/\[(.*?)\]\((.*?)\)/g, (match, text, url) => { const cleanUrl = url.trim(); if (/^(javascript|data|vbscript|file):/i.test(cleanUrl)) return '<a href="#">' + text + '</a>'; return '<a href="' + cleanUrl + '" target="_blank">' + text + '</a>'; }); // Group Unordered Lists html = html.replace(/^\s?\*\s*(.*?)$/gm, '<li class="_ul">$1</li>'); html = html.replace(/(?:<li class="_ul">.*?<\/li>\n?)+/g, (m) => '<ul>' + m.replace(/ class="_ul"/g, '').replace(/\n/g, '') + '</ul>'); // Group Ordered Lists html = html.replace(/^\s?(\d+)\.\s*(.*?)$/gm, '<li class="_ol">$2</li>'); html = html.replace(/(?:<li class="_ol">.*?<\/li>\n?)+/g, (m) => '<ol>' + m.replace(/ class="_ol"/g, '').replace(/\n/g, '') + '</ol>'); html = html.replace(/\n\n/g, '</p><p>').replace(/\n/g, '<br>'); if (html.indexOf('<p>') === -1 && html.length > 0) html = '<p>' + html + '</p>'; return html; }; /** * Find a component and its context (parent, index) by ID */ Prototype.findComponent = function(id, components, parent) { components = components || this.layout.components; for (let i = 0; i < components.length; i++) { if (!components[i]) continue; if (components[i].id === id) { return { component: components[i], list: components, index: i, parent: parent }; } if (components[i].children) { const result = this.findComponent(id, components[i].children, components[i]); if (result) return result; } } return null; }; /** * Find a component object by ID anywhere in the layout */ Prototype.findAnywhere = function(id) { const info = this.findComponent(id); return info ? info.component : null; }; /** * Remove a component from its parent list */ Prototype.removeFromParent = function(id, parentId) { if (parentId) { const parentInfo = this.findComponent(parentId); if (parentInfo && parentInfo.component.children) { const list = parentInfo.component.children; for (let i = 0; i < list.length; i++) { if (list[i].id === id) { list.splice(i, 1); return; } } } } else { const list = this.layout.components; for (let i = 0; i < list.length; i++) { if (list[i].id === id) { list.splice(i, 1); return; } } } }; /** * Select a component by ID and show its properties */ Prototype.selectComponent = function(id) { this.selectedId = id; this.renderProperties(); this.highlightComponent(); }; /** * Highlight the selected component in the canvas */ Prototype.highlightComponent = function() { this.canvasElement.querySelectorAll('.pb-block').forEach(function(block) { block.classList.remove('pb-selected'); }); if (this.selectedId) { const element = this.canvasElement.querySelector('[data-id="' + this.selectedId + '"]'); if (element) element.classList.add('pb-selected'); } }; /** * Delete a component by ID */ Prototype.deleteComponent = function(id) { const info = this.findComponent(id); if (info) { info.list.splice(info.index, 1); this.selectedId = null; this.saveToHistory(); this.render(); this.renderProperties(); } }; /** * Duplicate a component by ID */ Prototype.duplicateComponent = function(id) { const info = this.findComponent(id); if (!info) return; const clone = JSON.parse(JSON.stringify(info.component)); this.regenerateAllIds(clone); info.list.splice(info.index + 1, 0, clone); this.saveToHistory(); this.render(); this.selectComponent(clone.id); }; /** * Regenerate IDs for a component and all its children */ Prototype.regenerateAllIds = function(component) { component.id = this.generateUniqueId(); if (component.children) { component.children.forEach(child => this.regenerateAllIds(child)); } }; /** * Move a component within its current list (up/down) */ Prototype.moveComponent = function(id, direction) { const info = this.findComponent(id); if (!info) return; const newIndex = info.index + direction; if (newIndex < 0 || newIndex >= info.list.length) return; const temp = info.list[info.index]; info.list[info.index] = info.list[newIndex]; info.list[newIndex] = temp; this.saveToHistory(); this.render(); }; /** * Move a component from one parent to another (or same) at specific index */ Prototype.transferComponent = function(itemId, fromParentId, toParentId, oldIndex, newIndex) { const info = this.findComponent(itemId); if (!info) return; const item = info.list.splice(info.index, 1)[0]; if (toParentId) { const targetParentInfo = this.findComponent(toParentId); if (targetParentInfo && targetParentInfo.component.children) { targetParentInfo.component.children.splice(newIndex, 0, item); } } else { this.layout.components.splice(newIndex, 0, item); } this.saveToHistory(); this.render(); }; /** * Render the properties panel for the selected component */ Prototype.renderProperties = function() { if (!this.selectedId) { this.propertiesElement.innerHTML = '<div class="pb-props-empty">' + '<i class="mdi mdi-cursor-default-click mdi-3x d-block mb-2"></i>' + phrase('Select a component to edit its properties') + '</div>'; return; } const info = this.findComponent(this.selectedId); if (!info) { this.propertiesElement.innerHTML = ''; return; } const component = info.component; if (!component.props) component.props = {}; const definition = this.componentDefinitions[component.type] || {}; const grouping = definition.grouping || []; const defaults = definition.defaults || {}; const props = component.props || {}; let html = '<div class="pb-props-header">' + '<i class="' + (definition.icon || '') + ' me-2"></i>' + phrase(definition.label || component.type) + '</div>'; const renderedKeys = new Set(); // Render Groups first grouping.forEach(group => { html += '<div class="row g-2 px-3 mt-2">'; group.forEach(key => { if (key in defaults) { const value = props[key] !== undefined ? props[key] : defaults[key]; const options = definition.options ? definition.options[key] : null; html += '<div class="col">' + this.renderPropertyField(key, value, options, true, component.type) + '</div>'; renderedKeys.add(key); } }); html += '</div>'; }); // Render remaining fields for (const key in defaults) { if (key === 'items' || renderedKeys.has(key)) continue; const value = props[key] !== undefined ? props[key] : defaults[key]; const options = definition.options ? definition.options[key] : null; html += this.renderPropertyField(key, value, options, false, component.type); } if (defaults.items) html += this.renderItemsManager(component); if (!('class' in defaults) && !renderedKeys.has('class')) html += this.renderPropertyField('class', props['class'] || ''); this.propertiesElement.innerHTML = html; this.bindPropertyEvents(component); this.initializeWysiwyg(component); this.initTooltips(); // Reactivate plugins (Icon Picker, etc) if (typeof reactivate === 'function') { reactivate(['iconpicker']); } }; /** * Render a single property input field */ Prototype.renderPropertyField = function(key, value, options, isInsideRow, componentType) { const label = phrase(key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())); let html = isInsideRow ? '<div class="mb-2">' : '<div class="pb-props-group">'; html += '<label>' + label + '</label>'; if (options === 'colorpicker') { html += '<input type="color" data-key="' + key + '" class="pb-property-input form-control form-control-color w-100" value="' + (value || '#000000') + '">'; } else if (options && typeof options === 'object') { html += '<select data-key="' + key + '" class="pb-property-input">'; for (const optKey in options) { html += '<option value="' + optKey + '"' + (value == optKey ? ' selected' : '') + '>' + phrase(options[optKey]) + '</option>'; } html += '</select>'; } else if (options === 'boolean' || typeof value === 'boolean') { html += '<select data-key="' + key + '" class="pb-property-input">' + '<option value="1"' + (value ? ' selected' : '') + '>' + phrase('Yes') + '</option>' + '<option value="0"' + (!value ? ' selected' : '') + '>' + phrase('No') + '</option>' + '</select>'; } else if (typeof value === 'object' && !Array.isArray(value)) { html += '<div class="d-flex gap-1">'; ['sm', 'md', 'lg'].forEach(bp => { html += '<input type="number" data-key="' + key + '.' + bp + '" class="pb-property-input" placeholder="' + bp.toUpperCase() + '" value="' + (value[bp] || '') + '" min="1" max="12" style="width:60px">'; }); html += '</div>'; } else if (options === 'textarea' || key === 'description') { html += '<textarea data-key="' + key + '" class="pb-property-input">' + (value || '') + '</textarea>'; } else if ((['text', 'subtitle', 'body', 'bio', 'quote'].indexOf(key) > -1) && componentType !== 'button') { html += '<div class="pb-wysiwyg" data-key="' + key + '">' + '<div class="pb-wysiwyg-toolbar">' + '<button type="button" data-cmd="bold" data-bs-toggle="tooltip" title="' + phrase('Bold') + '"><i class="mdi mdi-format-bold"></i></button>' + '<button type="button" data-cmd="italic" data-bs-toggle="tooltip" title="' + phrase('Italic') + '"><i class="mdi mdi-format-italic"></i></button>' + '<button type="button" data-cmd="underline" data-bs-toggle="tooltip" title="' + phrase('Underline') + '"><i class="mdi mdi-format-underline"></i></button>' + '<button type="button" data-cmd="strikeThrough" data-bs-toggle="tooltip" title="' + phrase('Strikethrough') + '"><i class="mdi mdi-format-strikethrough"></i></button>' + '<span class="pb-wys-sep"></span>' + '<button type="button" data-cmd="createLink" data-bs-toggle="tooltip" title="' + phrase('Insert Link') + '"><i class="mdi mdi-link-variant"></i></button>' + '<button type="button" data-cmd="unlink" data-bs-toggle="tooltip" title="' + phrase('Remove Link') + '"><i class="mdi mdi-link-variant-off"></i></button>' + '<span class="pb-wys-sep"></span>' + '<button type="button" data-cmd="insertUnorderedList" data-bs-toggle="tooltip" title="' + phrase('Bullet List') + '"><i class="mdi mdi-format-list-bulleted"></i></button>' + '<button type="button" data-cmd="insertOrderedList" data-bs-toggle="tooltip" title="' + phrase('Numbered List') + '"><i class="mdi mdi-format-list-numbered"></i></button>' + '<span class="pb-wys-sep"></span>' + '<button type="button" data-cmd="removeFormat" data-bs-toggle="tooltip" title="' + phrase('Clear Formatting') + '"><i class="mdi mdi-format-clear"></i></button>' + '</div>' + '<div class="pb-wysiwyg-editor" contenteditable="true" data-placeholder="' + phrase('Type here...') + '">' + (this.markdownToHtml(value) || '') + '</div>' + '</div>'; } else if (options === 'iconpicker' || key === 'icon') { const sanitizedVal = String(value || '').replace(/"/g, '&quot;'); html += '<div class="input-group input-group-sm">' + '<button type="button" class="btn btn-secondary pb-icon-picker-btn" role="iconpicker" data-iconset="materialdesign" data-icon="' + (value || 'mdi mdi-radiobox-blank') + '" data-key="' + key + '"></button>' + '<input type="text" data-key="' + key + '" class="pb-property-input form-control" value="' + sanitizedVal + '" placeholder="mdi mdi-star" readonly>' + '</div>'; } else if (key === 'src' || key === 'image' || key === 'background') { const sanitizedVal = String(value || '').replace(/"/g, '&quot;'); const placeholder = phrase(key.charAt(0).toUpperCase() + key.slice(1) + ' URL'); html += '<div class="pb-prop-img-input">' + '<input type="text" data-key="' + key + '" class="pb-property-input" value="' + sanitizedVal + '" placeholder="' + placeholder + '">' + '<button type="button" class="btn btn-sm btn-outline-secondary pb-browse-img" data-bs-toggle="tooltip" title="' + phrase('Browse') + '">' + '<i class="mdi mdi-folder-open"></i>' + '</button>' + '</div>'; } else { const sanitizedVal = String(value || '').replace(/"/g, '&quot;'); html += '<input type="text" data-key="' + key + '" class="pb-property-input" value="' + sanitizedVal + '">'; } html += '</div>'; return html; }; /** * Bind events to property inputs */ Prototype.bindPropertyEvents = function(component) { const self = this; this.propertiesElement.querySelectorAll('.pb-property-input, .pb-icon-picker-btn').forEach(function(input) { const updateValue = function() { if (!component.props) component.props = {}; const key = input.dataset.key; let value = input.value; // Handle iconpicker button which uses data-icon or value if (input.getAttribute('role') === 'iconpicker') { value = input.querySelector('input') ? input.querySelector('input').value : (input.value || input.dataset.icon); } if (key.indexOf('.') > -1) { const parts = key.split('.'); if (!component.props[parts[0]]) component.props[parts[0]] = {}; if (value) component.props[parts[0]][parts[1]] = parseInt(value); else delete component.props[parts[0]][parts[1]]; } else { if (input.tagName === 'SELECT' && (value === '1' || value === '0') && typeof component.props[key] === 'boolean') { value = value === '1'; } component.props[key] = value; } self.saveToHistory(); clearTimeout(self.debounceTimer); self.debounceTimer = setTimeout(function() { const structuralProps = ['class', 'align_items', 'justify_content', 'align_self', 'id']; if (structuralProps.indexOf(key) > -1) { self.render(); } else { self.refreshBlockPreview(component); } self.syncToInput(); }, 150); }; if (input.tagName === 'SELECT') input.addEventListener('change', updateValue); else { input.addEventListener('input', updateValue); input.addEventListener('change', updateValue); } // Specialized listener for iconpicker which might not trigger standard events if (input.getAttribute('role') === 'iconpicker') { $(input).on('change', function(e) { if (e.icon) { input.value = e.icon; input.dataset.icon = e.icon; // Sync adjacent text input const textInput = input.nextElementSibling; if (textInput && textInput.tagName === 'INPUT') { textInput.value = e.icon; } updateValue(); } }); } }); this.propertiesElement.querySelectorAll('.pb-browse-img').forEach(function(btn) { btn.addEventListener('click', function() { const input = btn.previousElementSibling; self.openImageBrowser(input); }); }); this.bindItemsManager(component); }; /** * Open the Image Browser modal */ Prototype.openImageBrowser = function(targetInput) { const self = this; const modalId = 'pb-image-modal'; let modal = document.getElementById(modalId); if (!modal) { modal = document.createElement('div'); modal.id = modalId; modal.className = 'modal fade'; modal.setAttribute('tabindex', '-1'); modal.innerHTML = '<div class="modal-dialog modal-lg modal-dialog-centered">' + '<div class="modal-content border-0 shadow-lg rounded-4">' + '<div class="modal-header border-0">' + '<h5 class="modal-title fw-bold">' + phrase('Browse Images') + '</h5>' + '<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="' + phrase('Close') + '"></button>' + '</div>' + '<div class="modal-body p-4"></div>' + '</div>' + '</div>'; document.body.appendChild(modal); } const titleElement = modal.querySelector('.modal-title'); const bodyElement = modal.querySelector('.modal-body'); // Use jQuery to show the modal (more robust in Aksara environment) const $modal = $(modal); $modal.modal('show'); // Ensure backdrop cleanup $modal.on('hidden.bs.modal', function() { $('.modal-backdrop').remove(); $('body').removeClass('modal-open').css({ overflow: '', paddingRight: '' }); }); // Reset State const state = { q: '', sort: 'newest', page: 1, view: 'grid' }; titleElement.textContent = phrase('Browse Images'); bodyElement.innerHTML = '<div class="pb-upload-zone" id="pb-upload-zone"><i class="mdi mdi-cloud-upload"></i><div>' + phrase('Click or Drop image here to upload') + '</div><input type="file" id="pb-file-input" hidden accept="image/*"></div>' + '<div class="pb-img-filters">' + '<div class="pb-img-search"><i class="mdi mdi-magnify"></i><input type="text" class="form-control form-control-sm" placeholder="' + phrase('Search images...') + '" id="pb-img-q"></div>' + '<select class="form-select form-select-sm" style="width:140px" id="pb-img-sort">' + '<option value="newest">' + phrase('Newest First') + '</option><option value="oldest">' + phrase('Oldest First') + '</option><option value="name_asc">' + phrase('Name A-Z') + '</option><option value="name_desc">' + phrase('Name Z-A') + '</option>' + '</select>' + '<div class="pb-view-toggle">' + '<div class="pb-view-btn active" data-view="grid" title="' + phrase('Grid View') + '"><i class="mdi mdi-view-grid"></i></div>' + '<div class="pb-view-btn" data-view="list" title="' + phrase('List View') + '"><i class="mdi mdi-view-list"></i></div>' + '</div>' + '</div>' + '<div id="pb-img-content"></div>' + '<div class="pb-pagination" id="pb-pagination"></div>'; const contentDiv = bodyElement.querySelector('#pb-img-content'); const paginationDiv = bodyElement.querySelector('#pb-pagination'); const uploadZone = bodyElement.querySelector('#pb-upload-zone'); const fileInput = bodyElement.querySelector('#pb-file-input'); const loadImages = function() { contentDiv.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>'; paginationDiv.innerHTML = ''; const url = (self.options.images_url || 'builderImages') + window.location.search + '&q=' + encodeURIComponent(state.q) + '&sort=' + state.sort + '&page=' + state.page; fetch(url).then(res => res.json()).then(data => { if (!data.images || !data.images.length) { contentDiv.innerHTML = '<div class="text-center py-5 text-muted">' + phrase('No images found') + '</div>'; return; } if (state.view === 'grid') { let h = '<div class="pb-img-grid">'; data.images.forEach(img => { h += '<div class="pb-img-item" data-url="' + img.url + '" data-name="' + img.name + '"><img src="' + (img.thumb || img.url) + '"><div class="pb-img-name text-truncate" title="' + img.name + '">' + img.name + '</div><button type="button" class="pb-img-del"><i class="mdi mdi-close"></i></button></div>'; }); contentDiv.innerHTML = h + '</div>'; } else { let h = '<div class="pb-img-list">'; data.images.forEach(img => { h += '<div class="pb-img-list-item" data-url="' + img.url + '" data-name="' + img.name + '"><img src="' + (img.thumb || img.url) + '"><div class="pb-img-list-info"><div class="pb-img-list-name text-truncate" title="' + img.name + '">' + img.name + '</div><div class="pb-img-list-meta">' + img.formatted_size + ' &bull; ' + img.formatted_time + '</div></div><button type="button" class="pb-img-del"><i class="mdi mdi-close"></i></button></div>'; }); contentDiv.innerHTML = h + '</div>'; } contentDiv.querySelectorAll('.pb-img-item, .pb-img-list-item').forEach(item => { item.onclick = function(e) { const delBtn = e.target.closest('.pb-img-del'); if (delBtn) { e.stopPropagation(); if (confirm(phrase('Are you sure you want to delete this image?'))) { const formData = new FormData(); formData.append('file', item.dataset.name); fetch((self.options.delete_url || 'builder-delete') + window.location.search, { method: 'POST', body: formData }) .then(res => res.json()) .then(res => { if (res.error) alert(res.error); else loadImages(); }); } return; } targetInput.value = item.dataset.url; targetInput.dispatchEvent(new Event('input')); $modal.modal('hide'); }; }); if (data.total_pages > 1) { let ph = '<div class="pb-page-btn ' + (state.page === 1 ? 'disabled' : '') + '" data-page="' + (state.page - 1) + '"><i class="mdi mdi-chevron-left"></i></div>'; for (let i = 1; i <= data.total_pages; i++) { if (i === 1 || i === data.total_pages || (i >= state.page - 1 && i <= state.page + 1)) { ph += '<div class="pb-page-btn ' + (i === state.page ? 'active' : '') + '" data-page="' + i + '">' + i + '</div>'; } else if (i === state.page - 2 || i === state.page + 2) { ph += '<div class="pb-page-btn disabled">...</div>'; } } ph += '<div class="pb-page-btn ' + (state.page === data.total_pages ? 'disabled' : '') + '" data-page="' + (state.page + 1) + '"><i class="mdi mdi-chevron-right"></i></div>'; paginationDiv.innerHTML = ph; paginationDiv.querySelectorAll('.pb-page-btn:not(.disabled)').forEach(btn => { btn.onclick = function() { state.page = parseInt(btn.dataset.page); loadImages(); }; }); } }); }; loadImages(); // Filters and search let searchTimer = null; bodyElement.querySelector('#pb-img-q').oninput = function(e) { clearTimeout(searchTimer); searchTimer = setTimeout(() => { state.q = e.target.value; state.page = 1; loadImages(); }, 300); }; bodyElement.querySelector('#pb-img-sort').onchange = function(e) { state.sort = e.target.value; state.page = 1; loadImages(); }; bodyElement.querySelectorAll('.pb-view-btn').forEach(btn => { btn.onclick = function() { bodyElement.querySelectorAll('.pb-view-btn').forEach(x => x.classList.remove('active')); btn.classList.add('active'); state.view = btn.dataset.view; loadImages(); }; }); // Upload logic const showError = function(msg) { let alert = bodyElement.querySelector('#pb-upload-alert'); if (!alert) { alert = document.createElement('div'); alert.id = 'pb-upload-alert'; alert.className = 'alert alert-danger alert-dismissible fade show my-3 mx-0 py-2 small'; alert.innerHTML = '<i class="mdi mdi-alert-circle-outline me-2"></i><span class="msg"></span><button type="button" class="btn-close" data-bs-dismiss="alert" style="padding:0.75rem"></button>'; uploadZone.after(alert); } alert.querySelector('.msg').textContent = msg; }; uploadZone.onclick = function() { fileInput.click(); }; fileInput.onchange = function() { if (!fileInput.files.length) return; const alert = bodyElement.querySelector('#pb-upload-alert'); if (alert) alert.remove(); const formData = new FormData(); formData.append('file', fileInput.files[0]); uploadZone.innerHTML = '<div class="spinner-border text-primary mb-2"></div><div>' + phrase('Uploading...') + '</div>'; fetch((self.options.upload_url || 'builderUpload') + window.location.search, { method: 'POST', body: formData }) .then(res => res.json()) .then(data => { uploadZone.innerHTML = '<i class="mdi mdi-cloud-upload"></i><div>' + phrase('Click or Drop image here to upload') + '</div>'; if (data.error) { showError(data.error); return; } state.page = 1; loadImages(); }) .catch(err => { uploadZone.innerHTML = '<i class="mdi mdi-cloud-upload"></i><div>' + phrase('Click or Drop image here to upload') + '</div>'; showError(phrase('An error occurred during upload.')); }); }; }; /** * Initialize WYSIWYG editors */ Prototype.initializeWysiwyg = function(component) { if (!component.props) component.props = {}; const self = this; this.propertiesElement.querySelectorAll('.pb-wysiwyg').forEach(function(container) { const key = container.dataset.key; const editor = container.querySelector('.pb-wysiwyg-editor'); const toolbar = container.querySelector('.pb-wysiwyg-toolbar'); toolbar.addEventListener('mousedown', e => e.preventDefault()); toolbar.addEventListener('click', function(e) { const btn = e.target.closest('[data-cmd]'); if (!btn) return; const cmd = btn.dataset.cmd; const idx = container.dataset.itemIdx; editor.focus(); if (cmd === 'createLink') { const url = prompt(phrase('Enter URL:'), 'https://'); if (url) document.execCommand('createLink', false, url); } else { document.execCommand(cmd, false, null); } setTimeout(() => self.updateWysiwygToolbar(toolbar), 10); if (idx !== undefined) { if (!component.props.items) component.props.items = []; component.props.items[idx][key] = self.htmlToMarkdown(editor.innerHTML); } else { component.props[key] = self.htmlToMarkdown(editor.innerHTML); } self.saveToHistory(); self.refreshBlockPreview(component); }); let debounce = null; editor.addEventListener('input', function() { const idx = container.dataset.itemIdx; if (idx !== undefined) { if (!component.props.items) component.props.items = []; component.props.items[idx][key] = self.htmlToMarkdown(editor.innerHTML); } else { component.props[key] = self.htmlToMarkdown(editor.innerHTML); } self.saveToHistory(); clearTimeout(debounce); debounce = setTimeout(() => self.refreshBlockPreview(component), 200); }); // Ensure new lines use <p> tags document.execCommand('defaultParagraphSeparator', false, 'p'); editor.addEventListener('keydown', function(e) { if (e.key === ' ') { const selection = window.getSelection(); if (selection.rangeCount > 0) { const range = selection.getRangeAt(0); const node = range.startContainer; // Only trigger if we are at the beginning of a text node and it matches our list pattern if (node.nodeType === 3) { const text = node.textContent; const match = text.match(/^(\d+\.|-|\*)$/); // Match '1.' or '-' without space yet if (match && range.startOffset === text.length) { e.preventDefault(); // Manually create list elements to avoid merging previous content const listType = match[1].includes('.') ? 'ol' : 'ul'; const list = document.createElement(listType); const li = document.createElement('li'); li.innerHTML = '<br>'; list.appendChild(li); // Find the containing block (p, div, etc.) or the node itself let block = node; while (block.parentNode && block.parentNode !== editor && !['P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6'].includes(block.parentNode.tagName)) { block = block.parentNode; } // Replace the block or node with our new list const parent = block.parentNode || editor; parent.replaceChild(list, block); // Move cursor into the new list item const sel = window.getSelection(); const newRange = document.createRange(); newRange.setStart(li, 0); newRange.collapse(true); sel.removeAllRanges(); sel.addRange(newRange); } } } } }); editor.addEventListener('keyup', () => self.updateWysiwygToolbar(toolbar)); editor.addEventListener('mouseup', () => self.updateWysiwygToolbar(toolbar)); }); }; /** * Update WYSIWYG toolbar button states */ Prototype.updateWysiwygToolbar = function(toolbar) { const commands = ['bold', 'italic', 'underline', 'strikeThrough']; toolbar.querySelectorAll('[data-cmd]').forEach(btn => { if (commands.indexOf(btn.dataset.cmd) > -1) { btn.classList.toggle('active', document.queryCommandState(btn.dataset.cmd)); } }); }; /** * Refresh a single block's preview on the canvas without full render */ Prototype.refreshBlockPreview = function(component) { const blockElement = this.canvasElement.querySelector('[data-id="' + component.id + '"]'); if (!blockElement) return; const wrapper = document.createElement('div'); wrapper.innerHTML = this.renderContent(component); // Clear only content nodes (not controls or dropzones) const children = Array.from(blockElement.childNodes); children.forEach(function(node) { const isControl = (node.nodeType === 1 && ( node.classList.contains('pb-block-label') || node.classList.contains('pb-block-actions') || node.classList.contains('pb-dropzone') || node.classList.contains('pb-block') || node.classList.contains('pb-col-resize') || node.classList.contains('pb-col-size-badge') )); if (!isControl) node.remove(); }); const actionsDiv = blockElement.querySelector('.pb-block-actions'); const refNode = actionsDiv ? actionsDiv.nextSibling : blockElement.firstChild; while (wrapper.firstChild) blockElement.insertBefore(wrapper.firstChild, refNode); this.syncToInput(); }; /** * Save current state to history for undo/redo */ Prototype.saveToHistory = function() { this.history = this.history.slice(0, this.historyIndex + 1); this.history.push(JSON.stringify(this.layout)); if (this.history.length > 50) this.history.shift(); this.historyIndex = this.history.length - 1; }; /** * Undo last action */ Prototype.undo = function() { if (this.historyIndex > 0) { this.historyIndex--; this.layout = JSON.parse(this.history[this.historyIndex]); this.render(); this.renderProperties(); } }; /** * Redo action */ Prototype.redo = function() { if (this.historyIndex < this.history.length - 1) { this.historyIndex++; this.layout = JSON.parse(this.history[this.historyIndex]); this.render(); this.renderProperties(); } }; /** * Sync layout data to hidden input */ Prototype.syncToInput = function() { if (this.inputElement) this.inputElement.value = JSON.stringify(this.layout); }; /** * Load layout data from hidden input */ Prototype.loadFromInput = function() { if (this.inputElement && this.inputElement.value) { try { const data = JSON.parse(this.inputElement.value); if (data && data.components) { this.layout = data; this.saveToHistory(); } } catch (e) { this.saveToHistory(); } } else { this.saveToHistory(); } }; /** * Open standalone preview */ Prototype.preview = function() { const form = document.createElement('form'); form.method = 'POST'; form.action = this.options.preview_url || window.location.href.replace(/builder.*/, 'builderPreview'); form.target = '_blank'; const input = document.createElement('input'); input.type = 'hidden'; input.name = 'layout'; input.value = JSON.stringify(this.layout); form.appendChild(input); document.body.appendChild(form); form.submit(); document.body.removeChild(form); }; /** * Trigger form submission */ Prototype.submitForm = function(button) { const form = this.container.closest('form'); if (form) { const event = new Event('submit', { bubbles: true, cancelable: true }); form.dispatchEvent(event); } }; /** * Render Items Manager for list-based components (carousel, etc) */ Prototype.renderItemsManager = function(component) { const definition = this.componentDefinitions[component.type] || {}; const items = component.props.items || definition.defaults.items || []; const labelMap = { carousel: 'Slide', tabs: 'Tab', accordion: 'Accordion Item' }; const itemLabel = phrase(labelMap[component.type] || 'Item'); let html = '<div class="pb-items-manager"><label class="d-block mb-2 fw-bold">' + phrase('Items') + '</label>'; items.forEach((item, index) => { html += '<div class="pb-item-row" data-idx="' + index + '">' + '<div class="d-flex justify-content-between align-items-center mb-3 border-bottom pb-2">' + '<span class="small fw-bold text-primary">' + itemLabel + ' #' + (index + 1) + '</span>' + '<div class="d-flex gap-1">'; if (index > 0) html += '<button type="button" class="btn btn-sm btn-link text-secondary pb-item-up p-0" data-bs-toggle="tooltip" title="' + phrase('Move Up') + '"><i class="mdi mdi-arrow-up-circle mdi-18px"></i></button>'; if (index < items.length - 1) html += '<button type="button" class="btn btn-sm btn-link text-secondary pb-item-down p-0" data-bs-toggle="tooltip" title="' + phrase('Move Down') + '"><i class="mdi mdi-arrow-down-circle mdi-18px"></i></button>'; html += '<button type="button" class="btn btn-sm btn-link text-danger pb-item-del p-0 ms-1" data-bs-toggle="tooltip" title="' + phrase('Remove') + '"><i class="mdi mdi-close-circle mdi-18px"></i></button>' + '</div>' + '</div>'; for (const key in item) { const fieldLabel = phrase(key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())); html += '<div class="mb-3"><label class="small text-muted d-block mb-1">' + fieldLabel + '</label>'; if (key === 'src' || key === 'image') { html += '<div class="pb-prop-img-input">' + '<input type="text" data-key="' + key + '" class="pb-item-input form-control form-control-sm" value="' + String(item[key] || '').replace(/"/g, '&quot;') + '" placeholder="' + phrase(key.charAt(0).toUpperCase() + key.slice(1) + ' URL') + '">' + '<button type="button" class="btn btn-sm btn-outline-secondary pb-browse-img" data-bs-toggle="tooltip" title="' + phrase('Browse') + '"><i class="mdi mdi-folder-open"></i></button>' + '</div>'; } else if (key === 'body' || key === 'content' || key === 'subtitle' || key === 'bio' || key === 'quote') { html += '<div class="pb-wysiwyg mt-1" data-key="' + key + '" data-item-idx="' + index + '">' + '<div class="pb-wysiwyg-toolbar">' + '<button type="button" data-cmd="bold" data-bs-toggle="tooltip" title="' + phrase('Bold') + '"><i class="mdi mdi-format-bold"></i></button>' + '<button type="button" data-cmd="italic" data-bs-toggle="tooltip" title="' + phrase('Italic') + '"><i class="mdi mdi-format-italic"></i></button>' + '<button type="button" data-cmd="underline" data-bs-toggle="tooltip" title="' + phrase('Underline') + '"><i class="mdi mdi-format-underline"></i></button>' + '<button type="button" data-cmd="strikeThrough" data-bs-toggle="tooltip" title="' + phrase('Strikethrough') + '"><i class="mdi mdi-format-strikethrough"></i></button>' + '<span class="pb-wys-sep"></span>' + '<button type="button" data-cmd="createLink" data-bs-toggle="tooltip" title="' + phrase('Insert Link') + '"><i class="mdi mdi-link-variant"></i></button>' + '<button type="button" data-cmd="unlink" data-bs-toggle="tooltip" title="' + phrase('Remove Link') + '"><i class="mdi mdi-link-variant-off"></i></button>' + '<span class="pb-wys-sep"></span>' + '<button type="button" data-cmd="insertUnorderedList" data-bs-toggle="tooltip" title="' + phrase('Bullet List') + '"><i class="mdi mdi-format-list-bulleted"></i></button>' + '<button type="button" data-cmd="insertOrderedList" data-bs-toggle="tooltip" title="' + phrase('Numbered List') + '"><i class="mdi mdi-format-list-numbered"></i></button>' + '<span class="pb-wys-sep"></span>' + '<button type="button" data-cmd="removeFormat" data-bs-toggle="tooltip" title="' + phrase('Clear Formatting') + '"><i class="mdi mdi-format-clear"></i></button>' + '</div>' + '<div class="pb-wysiwyg-editor bg-white" contenteditable="true" style="min-height:120px;max-height:300px;overflow-y:auto;padding:12px 14px;font-size:14px;line-height:1.4;outline:none">' + (this.markdownToHtml(item[key]) || '') + '</div>' + '</div>'; } else { html += '<input type="text" data-key="' + key + '" class="pb-item-input form-control form-control-sm" value="' + String(item[key] || '').replace(/"/g, '&quot;') + '">'; } html += '</div>'; } html += '</div>'; }); html += '<button type="button" class="btn btn-sm btn-outline-primary w-100 pb-item-add border-dashed py-2"><i class="mdi mdi-plus-circle-outline me-1"></i>' + phrase('Add') + ' ' + itemLabel + '</button></div>'; return html; }; /** * Bind events to Items Manager */ Prototype.bindItemsManager = function(component) { if (!component.props) component.props = {}; const self = this; const definition = this.componentDefinitions[component.type] || {}; this.propertiesElement.querySelectorAll('.pb-item-row').forEach(function(row) { const index = parseInt(row.dataset.idx); row.querySelectorAll('.pb-item-input').forEach(function(input) { input.addEventListener('input', function() { if (!component.props.items) component.props.items = JSON.parse(JSON.stringify(definition.defaults.items)); component.props.items[index][input.dataset.key] = input.value; self.saveToHistory(); clearTimeout(self.debounceTimer); self.debounceTimer = setTimeout(() => { self.render(); self.syncToInput(); }, 150); }); }); const upBtn = row.querySelector('.pb-item-up'); if (upBtn) upBtn.onclick = function() { const items = component.props.items || JSON.parse(JSON.stringify(definition.defaults.items)); const temp = items[index]; items[index] = items[index - 1]; items[index - 1] = temp; component.props.items = items; self.saveToHistory(); self.render(); self.renderProperties(); self.syncToInput(); }; const downBtn = row.querySelector('.pb-item-down'); if (downBtn) downBtn.onclick = function() { const items = component.props.items || JSON.parse(JSON.stringify(definition.defaults.items)); const temp = items[index]; items[index] = items[index + 1]; items[index + 1] = temp; component.props.items = items; self.saveToHistory(); self.render(); self.renderProperties(); self.syncToInput(); }; row.querySelector('.pb-item-del').onclick = function() { const items = component.props.items || JSON.parse(JSON.stringify(definition.defaults.items)); items.splice(index, 1); component.props.items = items; self.saveToHistory(); self.render(); self.renderProperties(); self.syncToInput(); }; row.querySelectorAll('.pb-browse-img').forEach(function(btn) { btn.onclick = function() { self.openImageBrowser(btn.previousElementSibling); }; }); }); const addBtn = this.propertiesElement.querySelector('.pb-item-add'); if (addBtn) { addBtn.onclick = function() { if (!component.props.items) component.props.items = JSON.parse(JSON.stringify(definition.defaults.items)); const items = component.props.items; const lastItem = items[items.length - 1] || definition.defaults.items[0]; const newItem = JSON.parse(JSON.stringify(lastItem)); for (const key in newItem) newItem[key] = ''; items.push(newItem); self.saveToHistory(); self.render(); self.renderProperties(); self.syncToInput(); }; } }; /** * Open the Page Settings modal * @param {string} sourceSelector Selector for the hidden metadata fields */ Prototype.openSettings = function(sourceSelector) { const self = this; const modalId = 'pb-settings-modal'; const source = document.querySelector(sourceSelector); if (!source) return; let modal = document.getElementById(modalId); if (modal) { $(modal).modal('dispose'); modal.remove(); } modal = document.createElement('div'); modal.id = modalId; modal.className = 'modal fade'; modal.setAttribute('tabindex', '-1'); modal.innerHTML = '<div class="modal-dialog modal-dialog-centered">' + '<div class="modal-content border-0 shadow-lg rounded-4">' + '<div class="modal-header border-0 pb-0">' + '<h5 class="modal-title fw-bold">' + phrase('Page Settings') + '</h5>' + '<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="' + phrase('Close') + '"></button>' + '</div>' + '<div class="modal-body p-4"></div>' + '<div class="modal-footer border-0 pt-0">' + '<button type="button" class="btn btn-primary w-100 rounded-pill" data-bs-dismiss="modal">' + phrase('Done') + '</button>' + '</div>' + '</div>' + '</div>'; document.body.appendChild(modal); const body = modal.querySelector('.modal-body'); // Move fields to modal while (source.firstChild) body.appendChild(source.firstChild); const $modal = $(modal); $modal.modal('show'); $modal.on('hidden.bs.modal', function() { // Move fields back to source while (body.firstChild) source.appendChild(body.firstChild); // Cleanup $('.modal-backdrop').remove(); $('body').removeClass('modal-open').css({ overflow: '', paddingRight: '' }); $modal.modal('dispose'); modal.remove(); }); }; window.AksaraPageBuilder = AksaraPageBuilder; })();