/** * LiveComponents - Zero-Dependency Interactive Component System * Part of Custom PHP Framework */ class LiveComponentManager { constructor() { this.components = new Map(); this.pollingIntervals = new Map(); this.init(); } init() { // Find all live components document.querySelectorAll('[data-live-component]').forEach(el => { const id = el.dataset.liveComponent; const state = JSON.parse(el.dataset.componentState || '{}'); this.components.set(id, { element: el, state: state }); // Setup event listeners this.setupListeners(el, id); }); } setupListeners(element, componentId) { // Action buttons and links element.querySelectorAll('[data-live-action]').forEach(btn => { btn.addEventListener('click', async (e) => { const action = btn.dataset.liveAction; const params = this.getActionParams(btn); if (btn.dataset.livePrevent !== undefined) { e.preventDefault(); } await this.callAction(componentId, action, params); }); }); // Forms with data-live-action element.querySelectorAll('form[data-live-action]').forEach(form => { form.addEventListener('submit', async (e) => { e.preventDefault(); const action = form.dataset.liveAction; const formData = new FormData(form); const params = Object.fromEntries(formData); await this.callAction(componentId, action, params); }); }); // Upload forms element.querySelectorAll('form[data-live-upload]').forEach(form => { form.addEventListener('submit', async (e) => { e.preventDefault(); const action = form.dataset.liveUpload; const fileInput = form.querySelector('input[type="file"]'); const file = fileInput?.files[0]; if (!file) return; // Validate file size const maxSize = parseInt(fileInput.dataset.maxSize || '0'); if (maxSize > 0 && file.size > maxSize) { alert(`File too large. Max ${(maxSize / 1024 / 1024).toFixed(2)}MB`); return; } await this.uploadFile(componentId, action, file, form); }); }); // Setup polling if interval is set const pollInterval = element.dataset.pollInterval; if (pollInterval) { this.startPolling(componentId, parseInt(pollInterval)); } } async callAction(componentId, method, params = {}) { const component = this.components.get(componentId); if (!component) return; try { // URL-encode the component ID to handle special characters (backslashes, colons) const encodedId = encodeURIComponent(componentId); const response = await fetch(`/live-component/${encodedId}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, body: JSON.stringify({ component_id: componentId, method: method, params: params, state: component.state.data || {} }) }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const data = await response.json(); // Update DOM component.element.innerHTML = data.html; // Update state if (data.state) { component.state = JSON.parse(data.state); component.element.dataset.componentState = data.state; } // Re-setup listeners this.setupListeners(component.element, componentId); // Dispatch custom events data.events?.forEach(event => { document.dispatchEvent(new CustomEvent(event.name, { detail: event.data })); }); } catch (error) { console.error('LiveComponent error:', error); } } async uploadFile(componentId, action, file, form) { const component = this.components.get(componentId); if (!component) return; // Create FormData const formData = new FormData(); formData.append('file', file); formData.append('component_id', componentId); formData.append('method', action); formData.append('state', JSON.stringify(component.state.data || {})); try { // Show upload progress this.updateUploadProgress(componentId, 0); const xhr = new XMLHttpRequest(); // Track upload progress xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) { const percentage = (e.loaded / e.total) * 100; this.updateUploadProgress(componentId, percentage); } }); // Handle completion xhr.addEventListener('load', () => { if (xhr.status === 200) { const data = JSON.parse(xhr.responseText); // Update component component.element.innerHTML = data.html; if (data.state) { component.state = JSON.parse(data.state); component.element.dataset.componentState = data.state; } this.setupListeners(component.element, componentId); this.updateUploadProgress(componentId, 100); // Reset form form.reset(); } else { alert('Upload failed'); } }); xhr.addEventListener('error', () => { alert('Upload error'); }); // URL-encode the component ID to handle special characters const encodedId = encodeURIComponent(componentId); xhr.open('POST', `/live-component/${encodedId}/upload`); xhr.send(formData); } catch (error) { console.error('Upload error:', error); alert('Upload failed'); } } updateUploadProgress(componentId, percentage) { const component = this.components.get(componentId); if (!component) return; const progressBar = component.element.querySelector('.progress-bar'); const progressText = component.element.querySelector('.upload-progress span'); if (progressBar) { progressBar.style.width = `${percentage}%`; } if (progressText) { progressText.textContent = `${Math.round(percentage)}%`; } } startPolling(componentId, interval) { // Clear existing interval this.stopPolling(componentId); // Start new interval const intervalId = setInterval(async () => { await this.callAction(componentId, 'poll', {}); }, interval); this.pollingIntervals.set(componentId, intervalId); } stopPolling(componentId) { const intervalId = this.pollingIntervals.get(componentId); if (intervalId) { clearInterval(intervalId); this.pollingIntervals.delete(componentId); } } removeComponent(componentId) { this.stopPolling(componentId); this.components.delete(componentId); } getActionParams(element) { // Extract params from data attributes const params = {}; Object.keys(element.dataset).forEach(key => { if (key.startsWith('param')) { const paramName = key.replace('param', '').toLowerCase(); params[paramName] = element.dataset[key]; } }); return params; } // Debounce utility debounce(func, wait) { let timeout; return (...args) => { clearTimeout(timeout); timeout = setTimeout(() => func.apply(this, args), wait); }; } } // Auto-init with error handling try { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { window.liveComponents = new LiveComponentManager(); console.log('LiveComponentManager initialized (DOMContentLoaded)'); }); } else { window.liveComponents = new LiveComponentManager(); console.log('LiveComponentManager initialized (immediate)'); } } catch (error) { console.error('Failed to initialize LiveComponentManager:', error); window.liveComponents = null; }