feat(Production): Complete production deployment infrastructure

- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -0,0 +1,453 @@
/**
* Accessibility Manager for LiveComponents
*
* Manages accessibility features for dynamic content updates:
* - ARIA live regions for screen reader announcements
* - Focus management with data-lc-keep-focus attribute
* - Keyboard navigation preservation
* - Screen reader-friendly update notifications
*
* WCAG 2.1 Compliance:
* - 4.1.3 Status Messages (Level AA)
* - 2.4.3 Focus Order (Level A)
* - 2.1.1 Keyboard (Level A)
*/
export class AccessibilityManager {
constructor() {
/**
* ARIA live region element for announcements
* @type {HTMLElement|null}
*/
this.liveRegion = null;
/**
* Component-specific live regions
* Map<componentId, HTMLElement>
*/
this.componentLiveRegions = new Map();
/**
* Focus tracking for restoration
* Map<componentId, FocusState>
*/
this.focusStates = new Map();
/**
* Announcement queue for throttling
* @type {Array<{message: string, priority: string}>}
*/
this.announcementQueue = [];
/**
* Announcement throttle timer
* @type {number|null}
*/
this.announceTimer = null;
/**
* Throttle delay in milliseconds
* @type {number}
*/
this.throttleDelay = 500;
}
/**
* Initialize accessibility features
*
* Creates global live region and sets up initial state.
*/
initialize() {
// Create global live region if not exists
if (!this.liveRegion) {
this.liveRegion = this.createLiveRegion('livecomponent-announcer', 'polite');
document.body.appendChild(this.liveRegion);
}
console.log('[AccessibilityManager] Initialized with ARIA live regions');
}
/**
* Create ARIA live region element
*
* @param {string} id - Element ID
* @param {string} politeness - ARIA politeness level (polite|assertive)
* @returns {HTMLElement} Live region element
*/
createLiveRegion(id, politeness = 'polite') {
const region = document.createElement('div');
region.id = id;
region.setAttribute('role', 'status');
region.setAttribute('aria-live', politeness);
region.setAttribute('aria-atomic', 'true');
region.className = 'sr-only'; // Screen reader only styling
// Add screen reader only styles
region.style.position = 'absolute';
region.style.left = '-10000px';
region.style.width = '1px';
region.style.height = '1px';
region.style.overflow = 'hidden';
return region;
}
/**
* Create component-specific live region
*
* Each component can have its own live region for isolated announcements.
*
* @param {string} componentId - Component identifier
* @param {HTMLElement} container - Component container element
* @param {string} politeness - ARIA politeness level
* @returns {HTMLElement} Component live region
*/
createComponentLiveRegion(componentId, container, politeness = 'polite') {
// Check if already exists
let liveRegion = this.componentLiveRegions.get(componentId);
if (!liveRegion) {
liveRegion = this.createLiveRegion(`livecomponent-${componentId}-announcer`, politeness);
container.appendChild(liveRegion);
this.componentLiveRegions.set(componentId, liveRegion);
}
return liveRegion;
}
/**
* Announce update to screen readers
*
* Uses ARIA live regions to announce updates without stealing focus.
* Supports throttling to prevent announcement spam.
*
* @param {string} message - Message to announce
* @param {string} priority - Priority level (polite|assertive)
* @param {string|null} componentId - Optional component-specific announcement
*/
announce(message, priority = 'polite', componentId = null) {
// Add to queue
this.announcementQueue.push({ message, priority, componentId });
// Throttle announcements
if (this.announceTimer) {
clearTimeout(this.announceTimer);
}
this.announceTimer = setTimeout(() => {
this.flushAnnouncements();
}, this.throttleDelay);
}
/**
* Flush announcement queue
*
* Processes queued announcements and clears the queue.
*/
flushAnnouncements() {
if (this.announcementQueue.length === 0) return;
// Get most recent announcement (others are outdated)
const announcement = this.announcementQueue[this.announcementQueue.length - 1];
this.announcementQueue = [];
// Determine target live region
let liveRegion = this.liveRegion;
if (announcement.componentId) {
const componentRegion = this.componentLiveRegions.get(announcement.componentId);
if (componentRegion) {
liveRegion = componentRegion;
}
}
if (!liveRegion) {
console.warn('[AccessibilityManager] No live region available for announcement');
return;
}
// Update politeness if needed
if (announcement.priority === 'assertive') {
liveRegion.setAttribute('aria-live', 'assertive');
} else {
liveRegion.setAttribute('aria-live', 'polite');
}
// Clear and set new message
liveRegion.textContent = '';
// Use setTimeout to ensure screen reader picks up the change
setTimeout(() => {
liveRegion.textContent = announcement.message;
}, 100);
console.log(`[AccessibilityManager] Announced: "${announcement.message}" (${announcement.priority})`);
}
/**
* Capture focus state before update
*
* Saves current focus state for potential restoration after update.
*
* @param {string} componentId - Component identifier
* @param {HTMLElement} container - Component container element
* @returns {FocusState} Focus state object
*/
captureFocusState(componentId, container) {
const activeElement = document.activeElement;
// Check if focus is within container
if (!activeElement || !container.contains(activeElement)) {
return null;
}
const focusState = {
selector: this.getElementSelector(activeElement, container),
tagName: activeElement.tagName,
name: activeElement.name || null,
id: activeElement.id || null,
selectionStart: activeElement.selectionStart || null,
selectionEnd: activeElement.selectionEnd || null,
scrollTop: activeElement.scrollTop || 0,
scrollLeft: activeElement.scrollLeft || 0,
keepFocus: activeElement.hasAttribute('data-lc-keep-focus')
};
this.focusStates.set(componentId, focusState);
console.log(`[AccessibilityManager] Captured focus state for ${componentId}`, focusState);
return focusState;
}
/**
* Restore focus after update
*
* Restores focus to element marked with data-lc-keep-focus or previously focused element.
*
* @param {string} componentId - Component identifier
* @param {HTMLElement} container - Component container element
* @returns {boolean} True if focus was restored
*/
restoreFocus(componentId, container) {
const focusState = this.focusStates.get(componentId);
if (!focusState) {
return false;
}
try {
// Find element to focus
let elementToFocus = null;
// Priority 1: Element with data-lc-keep-focus attribute
if (focusState.keepFocus) {
elementToFocus = container.querySelector('[data-lc-keep-focus]');
}
// Priority 2: Element matching saved selector
if (!elementToFocus && focusState.selector) {
elementToFocus = container.querySelector(focusState.selector);
}
// Priority 3: Element with same ID
if (!elementToFocus && focusState.id) {
elementToFocus = container.querySelector(`#${focusState.id}`);
}
// Priority 4: Element with same name
if (!elementToFocus && focusState.name) {
elementToFocus = container.querySelector(`[name="${focusState.name}"]`);
}
if (elementToFocus && elementToFocus.focus) {
elementToFocus.focus();
// Restore selection for inputs/textareas
if (elementToFocus.setSelectionRange &&
focusState.selectionStart !== null &&
focusState.selectionEnd !== null) {
elementToFocus.setSelectionRange(
focusState.selectionStart,
focusState.selectionEnd
);
}
// Restore scroll position
if (focusState.scrollTop > 0) {
elementToFocus.scrollTop = focusState.scrollTop;
}
if (focusState.scrollLeft > 0) {
elementToFocus.scrollLeft = focusState.scrollLeft;
}
console.log(`[AccessibilityManager] Restored focus to ${focusState.selector}`);
return true;
}
} catch (error) {
console.debug('[AccessibilityManager] Could not restore focus:', error);
} finally {
// Clean up focus state
this.focusStates.delete(componentId);
}
return false;
}
/**
* Get CSS selector for an element within a container
*
* @param {HTMLElement} element - Element to get selector for
* @param {HTMLElement} container - Container element
* @returns {string|null} CSS selector or null
*/
getElementSelector(element, container) {
// Try ID (most specific)
if (element.id) {
return `#${element.id}`;
}
// Try name attribute
if (element.name) {
return `[name="${element.name}"]`;
}
// Try data-lc-key
const lcKey = element.getAttribute('data-lc-key');
if (lcKey) {
return `[data-lc-key="${lcKey}"]`;
}
// Try to build a path from container
const path = [];
let current = element;
while (current && current !== container && current !== document.body) {
let selector = current.tagName.toLowerCase();
// Add class if available
if (current.className && typeof current.className === 'string') {
const classes = current.className.split(' ').filter(c => c.trim());
if (classes.length > 0) {
selector += '.' + classes.join('.');
}
}
// Add nth-child if needed for specificity
if (current.parentElement) {
const siblings = Array.from(current.parentElement.children);
const index = siblings.indexOf(current) + 1;
if (siblings.length > 1) {
selector += `:nth-child(${index})`;
}
}
path.unshift(selector);
current = current.parentElement;
}
return path.length > 0 ? path.join(' > ') : null;
}
/**
* Announce component update
*
* Convenience method for announcing component updates.
*
* @param {string} componentId - Component identifier
* @param {string} updateType - Type of update (fragment|full|action)
* @param {Object} metadata - Additional metadata
*/
announceUpdate(componentId, updateType, metadata = {}) {
let message = '';
switch (updateType) {
case 'fragment':
message = `Updated ${metadata.fragmentName || 'content'}`;
break;
case 'full':
message = 'Component updated';
break;
case 'action':
message = metadata.actionMessage || 'Action completed';
break;
default:
message = 'Content updated';
}
this.announce(message, 'polite', componentId);
}
/**
* Check if element should preserve keyboard navigation
*
* @param {HTMLElement} element - Element to check
* @returns {boolean} True if keyboard navigation should be preserved
*/
shouldPreserveKeyboardNav(element) {
// Interactive elements that should always preserve keyboard nav
const interactiveTags = ['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A'];
if (interactiveTags.includes(element.tagName)) {
return true;
}
// Elements with tabindex
if (element.hasAttribute('tabindex')) {
return true;
}
// Elements with role
const interactiveRoles = [
'button', 'link', 'textbox', 'searchbox',
'combobox', 'listbox', 'option', 'tab'
];
const role = element.getAttribute('role');
if (role && interactiveRoles.includes(role)) {
return true;
}
return false;
}
/**
* Cleanup component accessibility features
*
* @param {string} componentId - Component identifier
*/
cleanup(componentId) {
// Remove component live region
const liveRegion = this.componentLiveRegions.get(componentId);
if (liveRegion && liveRegion.parentElement) {
liveRegion.parentElement.removeChild(liveRegion);
}
this.componentLiveRegions.delete(componentId);
// Clear focus state
this.focusStates.delete(componentId);
console.log(`[AccessibilityManager] Cleaned up accessibility for ${componentId}`);
}
/**
* Get accessibility stats
*
* @returns {Object} Statistics about accessibility manager state
*/
getStats() {
return {
has_global_live_region: this.liveRegion !== null,
component_live_regions: this.componentLiveRegions.size,
tracked_focus_states: this.focusStates.size,
pending_announcements: this.announcementQueue.length
};
}
}
// Create singleton instance
export const accessibilityManager = new AccessibilityManager();
export default accessibilityManager;

View File

@@ -0,0 +1,701 @@
/**
* ChunkedUploader - Chunked File Upload Module for Large Files
*
* Features:
* - Break large files into manageable chunks
* - SHA-256 hashing for integrity verification
* - Resume capability for interrupted uploads
* - Real-time progress via SSE
* - Retry logic with exponential backoff
* - Parallel chunk uploads (configurable)
* - Integration with LiveComponent system
*
* @package Framework\LiveComponents
*/
import { getGlobalSseClient } from '../sse/index.js';
/**
* Chunk Upload Status
*/
const ChunkStatus = {
PENDING: 'pending',
HASHING: 'hashing',
UPLOADING: 'uploading',
COMPLETE: 'complete',
ERROR: 'error'
};
/**
* Upload Session Status
*/
const SessionStatus = {
INITIALIZING: 'initializing',
INITIALIZED: 'initialized',
UPLOADING: 'uploading',
ASSEMBLING: 'assembling',
COMPLETE: 'complete',
ABORTED: 'aborted',
ERROR: 'error'
};
/**
* Chunk Metadata - Tracks individual chunk state
*/
class ChunkMetadata {
constructor(index, size, file) {
this.index = index;
this.size = size;
this.status = ChunkStatus.PENDING;
this.hash = null;
this.uploadedBytes = 0;
this.retries = 0;
this.error = null;
this.xhr = null;
this.file = file;
}
get progress() {
return this.size > 0 ? (this.uploadedBytes / this.size) * 100 : 0;
}
reset() {
this.status = ChunkStatus.PENDING;
this.uploadedBytes = 0;
this.error = null;
this.xhr = null;
}
abort() {
if (this.xhr) {
this.xhr.abort();
this.xhr = null;
}
}
}
/**
* Upload Session - Manages complete chunked upload session
*/
class UploadSession {
constructor(file, options) {
this.file = file;
this.sessionId = null;
this.totalChunks = 0;
this.chunkSize = options.chunkSize;
this.chunks = [];
this.status = SessionStatus.INITIALIZING;
this.uploadedChunks = 0;
this.expectedFileHash = null;
this.error = null;
this.startTime = null;
this.endTime = null;
this.expiresAt = null;
}
get progress() {
if (this.totalChunks === 0) return 0;
return (this.uploadedChunks / this.totalChunks) * 100;
}
get uploadedBytes() {
return this.chunks.reduce((sum, chunk) => sum + chunk.uploadedBytes, 0);
}
get totalBytes() {
return this.file.size;
}
get isComplete() {
return this.status === SessionStatus.COMPLETE;
}
get isError() {
return this.status === SessionStatus.ERROR;
}
get isAborted() {
return this.status === SessionStatus.ABORTED;
}
get canResume() {
return this.sessionId !== null && !this.isComplete && !this.isAborted;
}
getChunk(index) {
return this.chunks[index];
}
getPendingChunks() {
return this.chunks.filter(c => c.status === ChunkStatus.PENDING || c.status === ChunkStatus.ERROR);
}
getUploadingChunks() {
return this.chunks.filter(c => c.status === ChunkStatus.UPLOADING);
}
}
/**
* ChunkedUploader - Main chunked upload manager
*/
export class ChunkedUploader {
constructor(componentId, options = {}) {
this.componentId = componentId;
// Options
this.chunkSize = options.chunkSize || 512 * 1024; // 512KB default
this.maxConcurrentChunks = options.maxConcurrentChunks || 3;
this.maxRetries = options.maxRetries || 3;
this.retryDelay = options.retryDelay || 1000; // 1s base delay
this.enableSSE = options.enableSSE !== false; // SSE enabled by default
this.apiBase = options.apiBase || '/live-component/upload';
// Callbacks
this.onInitialized = options.onInitialized || (() => {});
this.onChunkProgress = options.onChunkProgress || (() => {});
this.onProgress = options.onProgress || (() => {});
this.onComplete = options.onComplete || (() => {});
this.onError = options.onError || (() => {});
this.onAborted = options.onAborted || (() => {});
this.onSSEProgress = options.onSSEProgress || (() => {});
// State
this.sessions = new Map(); // sessionId => UploadSession
this.activeSession = null;
this.sseClient = null;
this.userId = null;
// Initialize SSE if enabled
if (this.enableSSE) {
this.initializeSSE();
}
}
/**
* Initialize SSE connection for real-time progress
*/
initializeSSE() {
try {
// Get user ID from meta tag or data attribute
this.userId = this.getUserId();
if (this.userId) {
this.sseClient = getGlobalSseClient([`user:${this.userId}`]);
// Listen for progress events
this.sseClient.on('progress', (data) => {
this.handleSSEProgress(data);
});
// Connect if not already connected
if (!this.sseClient.isConnected()) {
this.sseClient.connect();
}
}
} catch (error) {
console.warn('[ChunkedUploader] Failed to initialize SSE:', error);
}
}
/**
* Get user ID for SSE channel
*/
getUserId() {
// Try meta tag first
const meta = document.querySelector('meta[name="user-id"]');
if (meta) return meta.content;
// Try data attribute on body
if (document.body.dataset.userId) {
return document.body.dataset.userId;
}
return null;
}
/**
* Handle SSE progress updates
*/
handleSSEProgress(data) {
const sessionId = data.session_id;
const session = this.sessions.get(sessionId);
if (session && data.taskId === sessionId) {
// Update session from SSE data
if (data.data?.uploaded_chunks !== undefined) {
session.uploadedChunks = data.data.uploaded_chunks;
}
if (data.data?.phase) {
switch (data.data.phase) {
case 'initialized':
session.status = SessionStatus.INITIALIZED;
break;
case 'uploading':
session.status = SessionStatus.UPLOADING;
break;
case 'completed':
session.status = SessionStatus.COMPLETE;
break;
case 'aborted':
session.status = SessionStatus.ABORTED;
break;
case 'error':
session.status = SessionStatus.ERROR;
break;
}
}
// Callback
this.onSSEProgress({
sessionId,
percent: data.percent,
message: data.message,
data: data.data
});
}
}
/**
* Upload file with chunking
*
* @param {File} file - File to upload
* @param {string} targetPath - Target path for assembled file
* @returns {Promise<UploadSession>}
*/
async upload(file, targetPath) {
// Create upload session
const session = new UploadSession(file, {
chunkSize: this.chunkSize
});
this.activeSession = session;
try {
// Calculate chunks
session.totalChunks = Math.ceil(file.size / this.chunkSize);
for (let i = 0; i < session.totalChunks; i++) {
const start = i * this.chunkSize;
const end = Math.min(start + this.chunkSize, file.size);
const size = end - start;
session.chunks.push(new ChunkMetadata(i, size, file));
}
// Initialize session with API
await this.initializeSession(session);
// Store session
this.sessions.set(session.sessionId, session);
// Start uploading chunks
session.status = SessionStatus.UPLOADING;
session.startTime = Date.now();
await this.uploadChunks(session);
// Complete upload
await this.completeUpload(session, targetPath);
// Success
session.status = SessionStatus.COMPLETE;
session.endTime = Date.now();
this.onComplete({
sessionId: session.sessionId,
file: session.file,
totalBytes: session.totalBytes,
duration: session.endTime - session.startTime
});
return session;
} catch (error) {
session.status = SessionStatus.ERROR;
session.error = error.message;
this.onError({
sessionId: session.sessionId,
file: session.file,
error: error.message
});
throw error;
}
}
/**
* Initialize upload session with API
*/
async initializeSession(session) {
const response = await fetch(`${this.apiBase}/init`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': navigator.userAgent
},
body: JSON.stringify({
componentId: this.componentId,
fileName: session.file.name,
totalSize: session.file.size,
chunkSize: this.chunkSize
})
});
if (!response.ok) {
throw new Error(`Failed to initialize upload session: ${response.status}`);
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to initialize upload session');
}
// Update session
session.sessionId = data.session_id;
session.totalChunks = data.total_chunks;
session.expiresAt = new Date(data.expires_at);
session.status = SessionStatus.INITIALIZED;
this.onInitialized({
sessionId: session.sessionId,
totalChunks: session.totalChunks,
expiresAt: session.expiresAt
});
}
/**
* Upload all chunks with parallelization
*/
async uploadChunks(session) {
const pending = session.getPendingChunks();
// Upload chunks in parallel batches
while (pending.length > 0) {
const batch = pending.splice(0, this.maxConcurrentChunks);
await Promise.all(
batch.map(chunk => this.uploadChunk(session, chunk))
);
}
}
/**
* Upload a single chunk
*/
async uploadChunk(session, chunk) {
let retries = 0;
while (retries <= this.maxRetries) {
try {
// Hash chunk data
chunk.status = ChunkStatus.HASHING;
const chunkData = await this.readChunk(session.file, chunk);
const chunkHash = await this.hashChunk(chunkData);
chunk.hash = chunkHash;
// Upload chunk
chunk.status = ChunkStatus.UPLOADING;
await this.uploadChunkData(session, chunk, chunkData, chunkHash);
// Success
chunk.status = ChunkStatus.COMPLETE;
session.uploadedChunks++;
this.onProgress({
sessionId: session.sessionId,
progress: session.progress,
uploadedChunks: session.uploadedChunks,
totalChunks: session.totalChunks,
uploadedBytes: session.uploadedBytes,
totalBytes: session.totalBytes
});
return;
} catch (error) {
retries++;
if (retries > this.maxRetries) {
chunk.status = ChunkStatus.ERROR;
chunk.error = error.message;
throw new Error(`Chunk ${chunk.index} failed after ${this.maxRetries} retries: ${error.message}`);
}
// Exponential backoff
const delay = this.retryDelay * Math.pow(2, retries - 1);
await this.sleep(delay);
chunk.reset();
}
}
}
/**
* Read chunk data from file
*/
readChunk(file, chunk) {
return new Promise((resolve, reject) => {
const start = chunk.index * this.chunkSize;
const end = Math.min(start + chunk.size, file.size);
const blob = file.slice(start, end);
const reader = new FileReader();
reader.onload = (e) => resolve(e.target.result);
reader.onerror = (e) => reject(new Error('Failed to read chunk'));
reader.readAsArrayBuffer(blob);
});
}
/**
* Hash chunk data with SHA-256
*/
async hashChunk(chunkData) {
const hashBuffer = await crypto.subtle.digest('SHA-256', chunkData);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
/**
* Upload chunk data to server
*/
uploadChunkData(session, chunk, chunkData, chunkHash) {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append('sessionId', session.sessionId);
formData.append('chunkIndex', chunk.index);
formData.append('chunkHash', chunkHash);
formData.append('chunk', new Blob([chunkData]));
const xhr = new XMLHttpRequest();
chunk.xhr = xhr;
// Progress tracking
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
chunk.uploadedBytes = e.loaded;
this.onChunkProgress({
sessionId: session.sessionId,
chunkIndex: chunk.index,
uploadedBytes: e.loaded,
totalBytes: e.total,
progress: (e.loaded / e.total) * 100
});
}
});
// Load event
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const response = JSON.parse(xhr.responseText);
if (response.success) {
resolve(response);
} else {
reject(new Error(response.error || 'Chunk upload failed'));
}
} catch (e) {
reject(e);
}
} else {
reject(new Error(`Chunk upload failed with status ${xhr.status}`));
}
});
// Error events
xhr.addEventListener('error', () => {
reject(new Error('Network error during chunk upload'));
});
xhr.addEventListener('abort', () => {
reject(new Error('Chunk upload cancelled'));
});
// Send request
xhr.open('POST', `${this.apiBase}/chunk`);
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('User-Agent', navigator.userAgent);
xhr.send(formData);
});
}
/**
* Complete upload and assemble file
*/
async completeUpload(session, targetPath) {
session.status = SessionStatus.ASSEMBLING;
const response = await fetch(`${this.apiBase}/complete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': navigator.userAgent
},
body: JSON.stringify({
sessionId: session.sessionId,
targetPath: targetPath
})
});
if (!response.ok) {
throw new Error(`Failed to complete upload: ${response.status}`);
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to complete upload');
}
return data;
}
/**
* Abort upload
*/
async abort(sessionId) {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error('Session not found');
}
// Abort all active chunk uploads
session.chunks.forEach(chunk => chunk.abort());
// Notify server
try {
await fetch(`${this.apiBase}/abort`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': navigator.userAgent
},
body: JSON.stringify({
sessionId: sessionId,
reason: 'User cancelled'
})
});
} catch (error) {
console.warn('[ChunkedUploader] Failed to notify server of abort:', error);
}
// Update session
session.status = SessionStatus.ABORTED;
this.onAborted({
sessionId,
uploadedChunks: session.uploadedChunks,
totalChunks: session.totalChunks
});
}
/**
* Get upload status
*/
async getStatus(sessionId) {
const response = await fetch(`${this.apiBase}/status/${sessionId}`, {
headers: {
'Accept': 'application/json',
'User-Agent': navigator.userAgent
}
});
if (!response.ok) {
throw new Error(`Failed to get upload status: ${response.status}`);
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to get upload status');
}
return data;
}
/**
* Resume interrupted upload
*/
async resume(sessionId, file, targetPath) {
// Get current status from server
const status = await this.getStatus(sessionId);
// Recreate session
const session = new UploadSession(file, {
chunkSize: this.chunkSize
});
session.sessionId = sessionId;
session.totalChunks = status.total_chunks;
session.uploadedChunks = status.uploaded_chunks;
session.status = SessionStatus.UPLOADING;
// Recreate chunks
for (let i = 0; i < session.totalChunks; i++) {
const start = i * this.chunkSize;
const end = Math.min(start + this.chunkSize, file.size);
const size = end - start;
const chunk = new ChunkMetadata(i, size, file);
// Mark already uploaded chunks as complete
if (i < status.uploaded_chunks) {
chunk.status = ChunkStatus.COMPLETE;
chunk.uploadedBytes = chunk.size;
}
session.chunks.push(chunk);
}
// Store and set as active
this.sessions.set(sessionId, session);
this.activeSession = session;
// Resume uploading
session.startTime = Date.now();
await this.uploadChunks(session);
await this.completeUpload(session, targetPath);
session.status = SessionStatus.COMPLETE;
session.endTime = Date.now();
this.onComplete({
sessionId: session.sessionId,
file: session.file,
totalBytes: session.totalBytes,
duration: session.endTime - session.startTime,
resumed: true
});
return session;
}
/**
* Helper: Sleep for delay
*/
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Cleanup
*/
destroy() {
// Abort all active sessions
this.sessions.forEach(session => {
if (session.canResume) {
session.chunks.forEach(chunk => chunk.abort());
}
});
this.sessions.clear();
this.activeSession = null;
}
}
export default ChunkedUploader;

View File

@@ -0,0 +1,638 @@
/**
* ComponentFileUploader - File Upload Module for LiveComponents
*
* Features:
* - Drag & Drop support
* - Multi-file uploads with queue management
* - Progress tracking (per-file and overall)
* - Image/document previews
* - Client-side validation
* - CSRF protection
* - Integration with LiveComponent state management
*
* @package Framework\LiveComponents
*/
/**
* File Upload Progress Tracker
*/
export class UploadProgress {
constructor(file, fileId) {
this.file = file;
this.fileId = fileId;
this.uploadedBytes = 0;
this.totalBytes = file.size;
this.status = 'pending'; // pending, uploading, processing, complete, error
this.error = null;
this.xhr = null;
this.startTime = null;
this.endTime = null;
}
get percentage() {
if (this.totalBytes === 0) return 100;
return Math.round((this.uploadedBytes / this.totalBytes) * 100);
}
get isComplete() {
return this.status === 'complete';
}
get hasError() {
return this.status === 'error';
}
get isUploading() {
return this.status === 'uploading';
}
get uploadSpeed() {
if (!this.startTime || !this.isUploading) return 0;
const elapsed = (Date.now() - this.startTime) / 1000; // seconds
return elapsed > 0 ? this.uploadedBytes / elapsed : 0; // bytes per second
}
get remainingTime() {
const speed = this.uploadSpeed;
if (speed === 0) return 0;
const remaining = this.totalBytes - this.uploadedBytes;
return remaining / speed; // seconds
}
updateProgress(loaded, total) {
this.uploadedBytes = loaded;
this.totalBytes = total;
}
setStatus(status, error = null) {
this.status = status;
this.error = error;
if (status === 'uploading' && !this.startTime) {
this.startTime = Date.now();
}
if (status === 'complete' || status === 'error') {
this.endTime = Date.now();
}
}
abort() {
if (this.xhr) {
this.xhr.abort();
this.setStatus('error', 'Upload cancelled');
}
}
toObject() {
return {
fileId: this.fileId,
fileName: this.file.name,
fileSize: this.totalBytes,
uploadedBytes: this.uploadedBytes,
percentage: this.percentage,
status: this.status,
error: this.error,
uploadSpeed: this.uploadSpeed,
remainingTime: this.remainingTime
};
}
}
/**
* File Validator - Client-side validation
*/
export class FileValidator {
constructor(options = {}) {
this.maxFileSize = options.maxFileSize || 10 * 1024 * 1024; // 10MB default
this.allowedMimeTypes = options.allowedMimeTypes || [];
this.allowedExtensions = options.allowedExtensions || [];
this.minFileSize = options.minFileSize || 1; // 1 byte minimum
}
validate(file) {
const errors = [];
// File size validation
if (file.size > this.maxFileSize) {
errors.push(`File size (${this.formatBytes(file.size)}) exceeds maximum allowed size (${this.formatBytes(this.maxFileSize)})`);
}
if (file.size < this.minFileSize) {
errors.push(`File size is too small (minimum: ${this.formatBytes(this.minFileSize)})`);
}
// MIME type validation
if (this.allowedMimeTypes.length > 0 && !this.allowedMimeTypes.includes(file.type)) {
errors.push(`File type "${file.type}" is not allowed. Allowed types: ${this.allowedMimeTypes.join(', ')}`);
}
// Extension validation
if (this.allowedExtensions.length > 0) {
const extension = file.name.split('.').pop().toLowerCase();
if (!this.allowedExtensions.includes(extension)) {
errors.push(`File extension ".${extension}" is not allowed. Allowed extensions: ${this.allowedExtensions.join(', ')}`);
}
}
// File name validation
if (file.name.length > 255) {
errors.push('File name is too long (maximum 255 characters)');
}
return errors;
}
isValid(file) {
return this.validate(file).length === 0;
}
formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
}
/**
* Drag & Drop Zone Manager
*/
export class DragDropZone {
constructor(element, callbacks = {}) {
this.element = element;
this.onFilesDropped = callbacks.onFilesDropped || (() => {});
this.onDragEnter = callbacks.onDragEnter || (() => {});
this.onDragLeave = callbacks.onDragLeave || (() => {});
this.dragCounter = 0; // Track nested drag events
this.isActive = false;
this.bindEvents();
}
bindEvents() {
// Prevent default browser behavior for drag events
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
this.element.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
});
});
// Handle drag enter
this.element.addEventListener('dragenter', (e) => {
this.dragCounter++;
if (this.dragCounter === 1) {
this.isActive = true;
this.element.classList.add('drag-over');
this.onDragEnter(e);
}
});
// Handle drag leave
this.element.addEventListener('dragleave', (e) => {
this.dragCounter--;
if (this.dragCounter === 0) {
this.isActive = false;
this.element.classList.remove('drag-over');
this.onDragLeave(e);
}
});
// Handle drag over
this.element.addEventListener('dragover', (e) => {
// Required to allow drop
});
// Handle drop
this.element.addEventListener('drop', (e) => {
this.dragCounter = 0;
this.isActive = false;
this.element.classList.remove('drag-over');
const files = Array.from(e.dataTransfer?.files || []);
if (files.length > 0) {
this.onFilesDropped(files);
}
});
}
destroy() {
this.element.classList.remove('drag-over');
// Event listeners are automatically removed when element is removed
}
}
/**
* Main ComponentFileUploader Class
*/
export class ComponentFileUploader {
constructor(componentElement, options = {}) {
this.componentElement = componentElement;
this.componentId = componentElement.dataset.liveId;
// Options
this.maxFileSize = options.maxFileSize || 10 * 1024 * 1024; // 10MB
this.allowedMimeTypes = options.allowedMimeTypes || [];
this.allowedExtensions = options.allowedExtensions || [];
this.maxFiles = options.maxFiles || 10;
this.autoUpload = options.autoUpload !== false; // default: true
this.multiple = options.multiple !== false; // default: true
this.endpoint = options.endpoint || `/live-component/${this.componentId}/upload`;
// Callbacks
this.onFileAdded = options.onFileAdded || (() => {});
this.onFileRemoved = options.onFileRemoved || (() => {});
this.onUploadStart = options.onUploadStart || (() => {});
this.onUploadProgress = options.onUploadProgress || (() => {});
this.onUploadComplete = options.onUploadComplete || (() => {});
this.onUploadError = options.onUploadError || (() => {});
this.onAllUploadsComplete = options.onAllUploadsComplete || (() => {});
// State
this.files = new Map(); // Map<fileId, UploadProgress>
this.uploadQueue = [];
this.activeUploads = 0;
this.maxConcurrentUploads = options.maxConcurrentUploads || 2;
// Validator
this.validator = new FileValidator({
maxFileSize: this.maxFileSize,
allowedMimeTypes: this.allowedMimeTypes,
allowedExtensions: this.allowedExtensions
});
// UI Elements (optional)
this.dropZoneElement = options.dropZone;
this.fileInputElement = options.fileInput;
this.initialize();
}
initialize() {
// Setup drag & drop if dropZone provided
if (this.dropZoneElement) {
this.dragDropZone = new DragDropZone(this.dropZoneElement, {
onFilesDropped: (files) => this.addFiles(files),
onDragEnter: () => this.dropZoneElement.classList.add('drag-active'),
onDragLeave: () => this.dropZoneElement.classList.remove('drag-active')
});
}
// Setup file input if provided
if (this.fileInputElement) {
this.fileInputElement.addEventListener('change', (e) => {
const files = Array.from(e.target.files || []);
this.addFiles(files);
e.target.value = ''; // Reset input
});
}
}
/**
* Add files to upload queue
*/
addFiles(files) {
const filesToAdd = Array.isArray(files) ? files : [files];
// Check max files limit
if (this.files.size + filesToAdd.length > this.maxFiles) {
const error = `Cannot add files. Maximum ${this.maxFiles} files allowed.`;
this.onUploadError({ error, fileCount: filesToAdd.length });
return;
}
for (const file of filesToAdd) {
// Generate unique file ID
const fileId = this.generateFileId(file);
// Validate file
const validationErrors = this.validator.validate(file);
const progress = new UploadProgress(file, fileId);
if (validationErrors.length > 0) {
progress.setStatus('error', validationErrors.join(', '));
this.files.set(fileId, progress);
this.onUploadError({
fileId,
file,
errors: validationErrors
});
continue;
}
// Add to files map
this.files.set(fileId, progress);
this.uploadQueue.push(fileId);
// Callback
this.onFileAdded({
fileId,
file,
progress: progress.toObject()
});
}
// Auto-upload if enabled
if (this.autoUpload) {
this.processQueue();
}
}
/**
* Remove file from queue
*/
removeFile(fileId) {
const progress = this.files.get(fileId);
if (!progress) return;
// Abort if uploading
if (progress.isUploading) {
progress.abort();
}
// Remove from queue
const queueIndex = this.uploadQueue.indexOf(fileId);
if (queueIndex !== -1) {
this.uploadQueue.splice(queueIndex, 1);
}
// Remove from files map
this.files.delete(fileId);
// Callback
this.onFileRemoved({ fileId, file: progress.file });
}
/**
* Start uploading all queued files
*/
uploadAll() {
this.processQueue();
}
/**
* Process upload queue
*/
async processQueue() {
while (this.uploadQueue.length > 0 && this.activeUploads < this.maxConcurrentUploads) {
const fileId = this.uploadQueue.shift();
const progress = this.files.get(fileId);
if (!progress || progress.status !== 'pending') continue;
this.activeUploads++;
this.uploadFile(fileId).finally(() => {
this.activeUploads--;
this.processQueue(); // Process next file
// Check if all uploads are complete
if (this.activeUploads === 0 && this.uploadQueue.length === 0) {
this.onAllUploadsComplete({
totalFiles: this.files.size,
successCount: Array.from(this.files.values()).filter(p => p.isComplete).length,
errorCount: Array.from(this.files.values()).filter(p => p.hasError).length
});
}
});
}
}
/**
* Upload a single file
*/
async uploadFile(fileId) {
const progress = this.files.get(fileId);
if (!progress) return;
try {
// Get current component state
const componentState = this.getComponentState();
// Get CSRF tokens
const csrfTokens = await this.getCsrfTokens();
// Create FormData
const formData = new FormData();
formData.append('file', progress.file);
formData.append('state', JSON.stringify(componentState));
formData.append('params', JSON.stringify({ fileId }));
// Create XHR request
const xhr = new XMLHttpRequest();
progress.xhr = xhr;
// Setup progress tracking
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
progress.updateProgress(e.loaded, e.total);
this.onUploadProgress({
fileId,
...progress.toObject()
});
}
});
// Setup completion handler
const uploadPromise = new Promise((resolve, reject) => {
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const response = JSON.parse(xhr.responseText);
if (response.success) {
progress.setStatus('complete');
// Update component state if provided
if (response.state) {
this.updateComponentState(response.state);
}
// Update component HTML if provided
if (response.html) {
this.updateComponentHtml(response.html);
}
this.onUploadComplete({
fileId,
file: progress.file,
response
});
resolve(response);
} else {
throw new Error(response.error || 'Upload failed');
}
} catch (e) {
reject(e);
}
} else {
reject(new Error(`Upload failed with status ${xhr.status}`));
}
});
xhr.addEventListener('error', () => {
reject(new Error('Network error during upload'));
});
xhr.addEventListener('abort', () => {
reject(new Error('Upload cancelled'));
});
});
// Set status to uploading
progress.setStatus('uploading');
this.onUploadStart({ fileId, file: progress.file });
// Open and send request
xhr.open('POST', this.endpoint);
// Set CSRF headers
xhr.setRequestHeader('X-CSRF-Form-ID', csrfTokens.form_id);
xhr.setRequestHeader('X-CSRF-Token', csrfTokens.token);
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('User-Agent', navigator.userAgent);
xhr.send(formData);
await uploadPromise;
} catch (error) {
progress.setStatus('error', error.message);
this.onUploadError({
fileId,
file: progress.file,
error: error.message
});
}
}
/**
* Cancel all uploads
*/
cancelAll() {
this.uploadQueue = [];
this.files.forEach(progress => {
if (progress.isUploading) {
progress.abort();
}
});
}
/**
* Clear all files (completed and pending)
*/
clearAll() {
this.cancelAll();
this.files.clear();
}
/**
* Get overall upload progress
*/
getOverallProgress() {
if (this.files.size === 0) return 100;
const totalBytes = Array.from(this.files.values()).reduce((sum, p) => sum + p.totalBytes, 0);
const uploadedBytes = Array.from(this.files.values()).reduce((sum, p) => sum + p.uploadedBytes, 0);
return totalBytes > 0 ? Math.round((uploadedBytes / totalBytes) * 100) : 0;
}
/**
* Get upload statistics
*/
getStats() {
const files = Array.from(this.files.values());
return {
total: files.length,
pending: files.filter(p => p.status === 'pending').length,
uploading: files.filter(p => p.status === 'uploading').length,
complete: files.filter(p => p.status === 'complete').length,
error: files.filter(p => p.status === 'error').length,
overallProgress: this.getOverallProgress()
};
}
/**
* Helper: Generate unique file ID
*/
generateFileId(file) {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}-${file.name}`;
}
/**
* Helper: Get CSRF tokens
*/
async getCsrfTokens() {
try {
const response = await fetch(`/api/csrf/token?action=${encodeURIComponent(this.endpoint)}&method=post`, {
headers: {
'Accept': 'application/json',
'User-Agent': navigator.userAgent
}
});
if (!response.ok) {
throw new Error(`CSRF token request failed: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Failed to get CSRF tokens:', error);
throw error;
}
}
/**
* Helper: Get component state from DOM
*/
getComponentState() {
const stateElement = this.componentElement.querySelector('[data-live-state]');
if (stateElement) {
try {
return JSON.parse(stateElement.textContent || '{}');
} catch (e) {
console.warn('Failed to parse component state:', e);
}
}
return {};
}
/**
* Helper: Update component state in DOM
*/
updateComponentState(newState) {
const stateElement = this.componentElement.querySelector('[data-live-state]');
if (stateElement) {
stateElement.textContent = JSON.stringify(newState);
}
}
/**
* Helper: Update component HTML
*/
updateComponentHtml(html) {
// Find the component content container (usually the component itself)
const contentElement = this.componentElement.querySelector('[data-live-content]') || this.componentElement;
if (contentElement) {
contentElement.innerHTML = html;
}
}
/**
* Cleanup
*/
destroy() {
this.cancelAll();
this.clearAll();
if (this.dragDropZone) {
this.dragDropZone.destroy();
}
}
}
export default ComponentFileUploader;

View File

@@ -0,0 +1,568 @@
/**
* ComponentPlayground
*
* Interactive development tool for LiveComponents.
*
* Features:
* - Component browser with search and filtering
* - Live component preview with auto-refresh
* - JSON state editor with validation
* - Action tester with parameter support
* - Performance metrics visualization
* - Template code generator
*
* Usage:
* import { ComponentPlayground } from './modules/livecomponent/ComponentPlayground';
*
* const playground = new ComponentPlayground('#playground-container');
* playground.init();
*/
export class ComponentPlayground {
/**
* @param {string} containerSelector - Container element selector
*/
constructor(containerSelector) {
this.container = document.querySelector(containerSelector);
if (!this.container) {
throw new Error(`Container not found: ${containerSelector}`);
}
// State
this.components = [];
this.selectedComponent = null;
this.componentMetadata = null;
this.currentState = {};
this.previewInstanceId = `playground-${Date.now()}`;
// UI Elements (will be created in init())
this.componentList = null;
this.componentSearch = null;
this.stateEditor = null;
this.previewContainer = null;
this.actionTester = null;
this.metricsDisplay = null;
// Performance tracking
this.metrics = {
renderTime: 0,
stateSize: 0,
actionExecutions: 0
};
}
/**
* Initialize playground
*/
async init() {
this.buildUI();
await this.loadComponents();
this.attachEventListeners();
}
/**
* Build playground UI structure
*/
buildUI() {
this.container.innerHTML = `
<div class="playground">
<!-- Header -->
<header class="playground__header">
<h1 class="playground__title">LiveComponent Playground</h1>
<p class="playground__subtitle">Interactive development tool for testing LiveComponents</p>
</header>
<!-- Main Layout -->
<div class="playground__layout">
<!-- Sidebar: Component Selector -->
<aside class="playground__sidebar">
<div class="playground__search">
<input
type="text"
id="component-search"
class="playground__search-input"
placeholder="Search components..."
autocomplete="off"
/>
</div>
<div id="component-list" class="playground__component-list">
<div class="playground__loading">Loading components...</div>
</div>
</aside>
<!-- Main Content -->
<main class="playground__main">
<!-- State Editor -->
<section class="playground__section">
<h2 class="playground__section-title">Component State</h2>
<div class="playground__state-editor">
<textarea
id="state-editor"
class="playground__textarea"
placeholder='{\n "property": "value"\n}'
rows="10"
></textarea>
<div class="playground__editor-actions">
<button id="apply-state" class="playground__button playground__button--primary">
Apply State
</button>
<button id="reset-state" class="playground__button">
Reset
</button>
<button id="format-json" class="playground__button">
Format JSON
</button>
</div>
<div id="state-validation" class="playground__validation"></div>
</div>
</section>
<!-- Live Preview -->
<section class="playground__section">
<h2 class="playground__section-title">Live Preview</h2>
<div id="preview-container" class="playground__preview">
<div class="playground__empty">
Select a component to preview
</div>
</div>
<div id="metrics-display" class="playground__metrics"></div>
</section>
<!-- Action Tester -->
<section class="playground__section">
<h2 class="playground__section-title">Actions</h2>
<div id="action-tester" class="playground__actions">
<div class="playground__empty">
Select a component to test actions
</div>
</div>
</section>
<!-- Code Generator -->
<section class="playground__section">
<h2 class="playground__section-title">Template Code</h2>
<div class="playground__code-generator">
<pre id="generated-code" class="playground__code"><code>Select a component to generate code</code></pre>
<button id="copy-code" class="playground__button">
Copy to Clipboard
</button>
</div>
</section>
</main>
</div>
</div>
`;
// Cache UI elements
this.componentList = this.container.querySelector('#component-list');
this.componentSearch = this.container.querySelector('#component-search');
this.stateEditor = this.container.querySelector('#state-editor');
this.previewContainer = this.container.querySelector('#preview-container');
this.actionTester = this.container.querySelector('#action-tester');
this.metricsDisplay = this.container.querySelector('#metrics-display');
}
/**
* Load all available components
*/
async loadComponents() {
try {
const response = await fetch('/playground/api/components');
const data = await response.json();
this.components = data.components || [];
this.renderComponentList(this.components);
} catch (error) {
this.componentList.innerHTML = `
<div class="playground__error">
Failed to load components: ${error.message}
</div>
`;
}
}
/**
* Render component list
*/
renderComponentList(components) {
if (components.length === 0) {
this.componentList.innerHTML = '<div class="playground__empty">No components found</div>';
return;
}
this.componentList.innerHTML = components.map(component => `
<div class="playground__component-item" data-component="${component.name}">
<div class="playground__component-name">${component.name}</div>
<div class="playground__component-meta">
<span class="playground__badge">${component.properties} props</span>
<span class="playground__badge">${component.actions} actions</span>
${component.has_cache ? '<span class="playground__badge playground__badge--cache">cached</span>' : ''}
</div>
</div>
`).join('');
}
/**
* Attach event listeners
*/
attachEventListeners() {
// Component selection
this.componentList.addEventListener('click', (e) => {
const item = e.target.closest('.playground__component-item');
if (item) {
this.selectComponent(item.dataset.component);
}
});
// Component search
this.componentSearch.addEventListener('input', (e) => {
this.filterComponents(e.target.value);
});
// State editor actions
this.container.querySelector('#apply-state').addEventListener('click', () => {
this.applyState();
});
this.container.querySelector('#reset-state').addEventListener('click', () => {
this.resetState();
});
this.container.querySelector('#format-json').addEventListener('click', () => {
this.formatJSON();
});
// Copy code
this.container.querySelector('#copy-code').addEventListener('click', () => {
this.copyCode();
});
}
/**
* Filter components by search term
*/
filterComponents(searchTerm) {
const term = searchTerm.toLowerCase().trim();
if (!term) {
this.renderComponentList(this.components);
return;
}
const filtered = this.components.filter(component =>
component.name.toLowerCase().includes(term) ||
component.class.toLowerCase().includes(term)
);
this.renderComponentList(filtered);
}
/**
* Select component
*/
async selectComponent(componentName) {
// Update active state
this.componentList.querySelectorAll('.playground__component-item').forEach(item => {
item.classList.remove('playground__component-item--active');
});
const selectedItem = this.componentList.querySelector(`[data-component="${componentName}"]`);
if (selectedItem) {
selectedItem.classList.add('playground__component-item--active');
}
this.selectedComponent = componentName;
// Load metadata
await this.loadComponentMetadata(componentName);
// Reset state
this.resetState();
// Render actions
this.renderActions();
// Generate code
this.updateGeneratedCode();
}
/**
* Load component metadata
*/
async loadComponentMetadata(componentName) {
try {
const response = await fetch(`/playground/api/component/${componentName}`);
const data = await response.json();
if (data.success) {
this.componentMetadata = data.data;
} else {
console.error('Failed to load metadata:', data.error);
}
} catch (error) {
console.error('Error loading metadata:', error);
}
}
/**
* Apply state from editor
*/
async applyState() {
const jsonText = this.stateEditor.value.trim();
const validationEl = this.container.querySelector('#state-validation');
// Validate JSON
try {
const state = jsonText ? JSON.parse(jsonText) : {};
this.currentState = state;
validationEl.innerHTML = '<span class="playground__success">✓ Valid JSON</span>';
// Preview component with new state
await this.previewComponent();
} catch (error) {
validationEl.innerHTML = `<span class="playground__error">✗ Invalid JSON: ${error.message}</span>`;
}
}
/**
* Reset state to default
*/
resetState() {
this.currentState = {};
this.stateEditor.value = '{}';
this.container.querySelector('#state-validation').innerHTML = '';
if (this.selectedComponent) {
this.previewComponent();
}
}
/**
* Format JSON in editor
*/
formatJSON() {
try {
const json = JSON.parse(this.stateEditor.value || '{}');
this.stateEditor.value = JSON.stringify(json, null, 2);
this.container.querySelector('#state-validation').innerHTML = '<span class="playground__success">✓ Formatted</span>';
} catch (error) {
this.container.querySelector('#state-validation').innerHTML = `<span class="playground__error">✗ Invalid JSON</span>`;
}
}
/**
* Preview component with current state
*/
async previewComponent() {
if (!this.selectedComponent) return;
this.previewContainer.innerHTML = '<div class="playground__loading">Loading preview...</div>';
try {
const startTime = performance.now();
const response = await fetch('/playground/api/preview', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
component_name: this.selectedComponent,
state: this.currentState,
instance_id: this.previewInstanceId
})
});
const data = await response.json();
const endTime = performance.now();
if (data.success) {
this.previewContainer.innerHTML = data.html;
// Update metrics
this.metrics.renderTime = data.render_time_ms;
this.metrics.stateSize = JSON.stringify(data.state).length;
this.updateMetrics();
// Initialize LiveComponent in preview
if (window.LiveComponent) {
window.LiveComponent.initComponent(this.previewContainer.firstElementChild);
}
} else {
this.previewContainer.innerHTML = `
<div class="playground__error">
Preview failed: ${data.error}
</div>
`;
}
} catch (error) {
this.previewContainer.innerHTML = `
<div class="playground__error">
Error: ${error.message}
</div>
`;
}
}
/**
* Render actions for selected component
*/
renderActions() {
if (!this.componentMetadata || !this.componentMetadata.actions) {
this.actionTester.innerHTML = '<div class="playground__empty">No actions available</div>';
return;
}
const actions = this.componentMetadata.actions.filter(action =>
!['onMount', 'onUpdated', 'onDestroy'].includes(action.name)
);
if (actions.length === 0) {
this.actionTester.innerHTML = '<div class="playground__empty">No actions available</div>';
return;
}
this.actionTester.innerHTML = actions.map(action => `
<div class="playground__action">
<button
class="playground__button playground__button--action"
data-action="${action.name}"
>
${action.name}()
</button>
${action.parameters.length > 0 ? `
<div class="playground__action-params">
${action.parameters.map(param => `
<label>
${param.name} (${param.type}):
<input type="text" data-param="${param.name}" placeholder="${param.type}" />
</label>
`).join('')}
</div>
` : ''}
</div>
`).join('');
// Attach action button listeners
this.actionTester.querySelectorAll('[data-action]').forEach(button => {
button.addEventListener('click', () => {
this.executeAction(button.dataset.action, button.closest('.playground__action'));
});
});
}
/**
* Execute component action
*/
async executeAction(actionName, actionElement) {
// Get parameters
const parameters = {};
if (actionElement) {
actionElement.querySelectorAll('[data-param]').forEach(input => {
const paramName = input.dataset.param;
let value = input.value;
// Try to parse as number or boolean
if (value === 'true') value = true;
else if (value === 'false') value = false;
else if (!isNaN(value) && value !== '') value = Number(value);
parameters[paramName] = value;
});
}
try {
const response = await fetch('/playground/api/action', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
component_id: `${this.selectedComponent}:${this.previewInstanceId}`,
action_name: actionName,
parameters: parameters,
current_state: this.currentState
})
});
const data = await response.json();
if (data.success) {
// Update current state
this.currentState = data.new_state;
this.stateEditor.value = JSON.stringify(this.currentState, null, 2);
// Update preview
this.previewContainer.innerHTML = data.html;
// Update metrics
this.metrics.actionExecutions++;
this.updateMetrics();
// Re-initialize LiveComponent
if (window.LiveComponent) {
window.LiveComponent.initComponent(this.previewContainer.firstElementChild);
}
} else {
alert(`Action failed: ${data.error}`);
}
} catch (error) {
alert(`Error executing action: ${error.message}`);
}
}
/**
* Update metrics display
*/
updateMetrics() {
this.metricsDisplay.innerHTML = `
<div class="playground__metrics-grid">
<div class="playground__metric">
<span class="playground__metric-label">Render Time</span>
<span class="playground__metric-value">${this.metrics.renderTime.toFixed(2)}ms</span>
</div>
<div class="playground__metric">
<span class="playground__metric-label">State Size</span>
<span class="playground__metric-value">${this.metrics.stateSize} bytes</span>
</div>
<div class="playground__metric">
<span class="playground__metric-label">Actions Executed</span>
<span class="playground__metric-value">${this.metrics.actionExecutions}</span>
</div>
</div>
`;
}
/**
* Update generated template code
*/
updateGeneratedCode() {
if (!this.selectedComponent) return;
const code = `<!-- Use in your template -->\n{{{ ${this.selectedComponent} }}}`;
this.container.querySelector('#generated-code code').textContent = code;
}
/**
* Copy generated code to clipboard
*/
async copyCode() {
const code = this.container.querySelector('#generated-code code').textContent;
try {
await navigator.clipboard.writeText(code);
const button = this.container.querySelector('#copy-code');
const originalText = button.textContent;
button.textContent = '✓ Copied!';
setTimeout(() => {
button.textContent = originalText;
}, 2000);
} catch (error) {
alert('Failed to copy code to clipboard');
}
}
}
export default ComponentPlayground;

View File

@@ -0,0 +1,288 @@
/**
* Lightweight DOM Patcher for LiveComponent Fragments
*
* Efficiently patches specific DOM fragments without full re-render.
* Optimized for LiveComponent use case with minimal overhead.
*
* Features:
* - Smart element matching by tag and data-lc-fragment attribute
* - Attribute diffing and patching
* - Text content updates
* - Child node reconciliation
* - Preserves focus and scroll position where possible
*
* Philosophy:
* - Keep it simple and focused
* - No external dependencies
* - Framework-compliant modern JavaScript
*/
export class DomPatcher {
/**
* Patch a specific fragment within a container
*
* @param {HTMLElement} container - Container element to search in
* @param {string} fragmentName - Fragment name (data-lc-fragment value)
* @param {string} newHtml - New HTML for the fragment
* @returns {boolean} - True if fragment was patched, false if not found
*/
patchFragment(container, fragmentName, newHtml) {
// Find existing fragment element
const existingElement = container.querySelector(`[data-lc-fragment="${fragmentName}"]`);
if (!existingElement) {
console.warn(`[DomPatcher] Fragment not found: ${fragmentName}`);
return false;
}
// Parse new HTML into element
const temp = document.createElement('div');
temp.innerHTML = newHtml;
const newElement = temp.firstElementChild;
if (!newElement) {
console.warn(`[DomPatcher] Invalid HTML for fragment: ${fragmentName}`);
return false;
}
// Verify fragment name matches
if (newElement.getAttribute('data-lc-fragment') !== fragmentName) {
console.warn(`[DomPatcher] Fragment name mismatch: expected ${fragmentName}, got ${newElement.getAttribute('data-lc-fragment')}`);
return false;
}
// Patch the element in place
this.patchElement(existingElement, newElement);
return true;
}
/**
* Patch multiple fragments at once
*
* @param {HTMLElement} container - Container element
* @param {Object.<string, string>} fragments - Map of fragment names to HTML
* @returns {Object.<string, boolean>} - Map of fragment names to success status
*/
patchFragments(container, fragments) {
const results = {};
for (const [fragmentName, html] of Object.entries(fragments)) {
results[fragmentName] = this.patchFragment(container, fragmentName, html);
}
return results;
}
/**
* Patch an element with new content
*
* Core patching logic that efficiently updates only what changed.
*
* @param {HTMLElement} oldElement - Existing element to patch
* @param {HTMLElement} newElement - New element with updated content
*/
patchElement(oldElement, newElement) {
// 1. Patch attributes
this.patchAttributes(oldElement, newElement);
// 2. Patch child nodes
this.patchChildren(oldElement, newElement);
}
/**
* Patch element attributes
*
* Only updates attributes that actually changed.
*
* @param {HTMLElement} oldElement - Existing element
* @param {HTMLElement} newElement - New element
*/
patchAttributes(oldElement, newElement) {
// Get all attributes from both elements
const oldAttrs = new Map();
const newAttrs = new Map();
for (const attr of oldElement.attributes) {
oldAttrs.set(attr.name, attr.value);
}
for (const attr of newElement.attributes) {
newAttrs.set(attr.name, attr.value);
}
// Remove attributes that no longer exist
for (const [name, value] of oldAttrs) {
if (!newAttrs.has(name)) {
oldElement.removeAttribute(name);
}
}
// Add or update attributes
for (const [name, value] of newAttrs) {
if (oldAttrs.get(name) !== value) {
oldElement.setAttribute(name, value);
}
}
}
/**
* Patch child nodes
*
* Reconciles child nodes between old and new elements.
* Uses simple key-based matching for efficiency.
*
* @param {HTMLElement} oldElement - Existing element
* @param {HTMLElement} newElement - New element
*/
patchChildren(oldElement, newElement) {
const oldChildren = Array.from(oldElement.childNodes);
const newChildren = Array.from(newElement.childNodes);
const maxLength = Math.max(oldChildren.length, newChildren.length);
for (let i = 0; i < maxLength; i++) {
const oldChild = oldChildren[i];
const newChild = newChildren[i];
if (!oldChild && newChild) {
// New child added - append it
oldElement.appendChild(newChild.cloneNode(true));
} else if (oldChild && !newChild) {
// Child removed - remove it
oldElement.removeChild(oldChild);
} else if (oldChild && newChild) {
// Both exist - patch or replace
if (this.shouldPatch(oldChild, newChild)) {
if (oldChild.nodeType === Node.ELEMENT_NODE) {
this.patchElement(oldChild, newChild);
} else if (oldChild.nodeType === Node.TEXT_NODE) {
if (oldChild.nodeValue !== newChild.nodeValue) {
oldChild.nodeValue = newChild.nodeValue;
}
}
} else {
// Different node types or tags - replace
oldElement.replaceChild(newChild.cloneNode(true), oldChild);
}
}
}
}
/**
* Determine if two nodes should be patched or replaced
*
* Nodes should be patched if they are compatible (same type and tag).
*
* @param {Node} oldNode - Existing node
* @param {Node} newNode - New node
* @returns {boolean} - True if nodes should be patched
*/
shouldPatch(oldNode, newNode) {
// Different node types - replace
if (oldNode.nodeType !== newNode.nodeType) {
return false;
}
// Text nodes can always be patched
if (oldNode.nodeType === Node.TEXT_NODE) {
return true;
}
// Element nodes - check if same tag
if (oldNode.nodeType === Node.ELEMENT_NODE) {
if (oldNode.tagName !== newNode.tagName) {
return false;
}
// Check for special keys that indicate identity
const oldKey = oldNode.getAttribute('data-lc-key') || oldNode.getAttribute('id');
const newKey = newNode.getAttribute('data-lc-key') || newNode.getAttribute('id');
// If both have keys, they must match
if (oldKey && newKey) {
return oldKey === newKey;
}
// Otherwise, assume they match (same tag is enough)
return true;
}
// Other node types - replace
return false;
}
/**
* Preserve focus state before patching
*
* Returns a function to restore focus after patching.
*
* @param {HTMLElement} container - Container being patched
* @returns {Function} - Restore function
*/
preserveFocus(container) {
const activeElement = document.activeElement;
// Check if focused element is within container
if (!activeElement || !container.contains(activeElement)) {
return () => {}; // No-op restore
}
// Get selector for focused element
const selector = this.getElementSelector(activeElement);
const selectionStart = activeElement.selectionStart;
const selectionEnd = activeElement.selectionEnd;
// Return restore function
return () => {
try {
if (selector) {
const element = container.querySelector(selector);
if (element && element.focus) {
element.focus();
// Restore selection for input/textarea
if (element.setSelectionRange &&
typeof selectionStart === 'number' &&
typeof selectionEnd === 'number') {
element.setSelectionRange(selectionStart, selectionEnd);
}
}
}
} catch (e) {
// Focus restoration failed - not critical
console.debug('[DomPatcher] Could not restore focus:', e);
}
};
}
/**
* Get a selector for an element
*
* Tries to create a unique selector using ID, name, or data attributes.
*
* @param {HTMLElement} element - Element to get selector for
* @returns {string|null} - CSS selector or null
*/
getElementSelector(element) {
if (element.id) {
return `#${element.id}`;
}
if (element.name) {
return `[name="${element.name}"]`;
}
const lcKey = element.getAttribute('data-lc-key');
if (lcKey) {
return `[data-lc-key="${lcKey}"]`;
}
// Fallback - not guaranteed to be unique
return element.tagName.toLowerCase();
}
}
// Create singleton instance
export const domPatcher = new DomPatcher();
export default domPatcher;

View File

@@ -0,0 +1,419 @@
/**
* FileUploadWidget - Pre-built UI Component for File Uploads in LiveComponents
*
* Provides a complete upload interface with:
* - Drag & Drop zone
* - File list with thumbnails
* - Progress bars
* - File validation feedback
* - Remove/cancel capabilities
*
* @package Framework\LiveComponents
*/
import { ComponentFileUploader } from './ComponentFileUploader.js';
/**
* FileUploadWidget - Ready-to-use Upload UI
*/
export class FileUploadWidget {
constructor(containerElement, options = {}) {
this.container = containerElement;
this.componentElement = containerElement.closest('[data-live-id]');
if (!this.componentElement) {
throw new Error('FileUploadWidget must be used inside a LiveComponent');
}
// Widget options
this.options = {
maxFileSize: options.maxFileSize || 10 * 1024 * 1024,
allowedMimeTypes: options.allowedMimeTypes || [],
allowedExtensions: options.allowedExtensions || [],
maxFiles: options.maxFiles || 10,
showPreviews: options.showPreviews !== false,
showProgress: options.showProgress !== false,
showFileList: options.showFileList !== false,
autoUpload: options.autoUpload !== false,
multiple: options.multiple !== false,
dropZoneText: options.dropZoneText || 'Drag & drop files here or click to browse',
browseButtonText: options.browseButtonText || 'Browse Files',
uploadButtonText: options.uploadButtonText || 'Upload All',
...options
};
this.files = new Map(); // Map<fileId, FileUIElement>
this.buildUI();
this.initializeUploader();
}
/**
* Build the widget UI
*/
buildUI() {
// Create widget structure
this.container.innerHTML = `
<div class="file-upload-widget">
<!-- Drop Zone -->
<div class="file-upload-dropzone" data-dropzone>
<div class="dropzone-content">
<svg class="dropzone-icon" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
<p class="dropzone-text">${this.options.dropZoneText}</p>
<button type="button" class="btn btn-primary dropzone-button" data-browse-button>
${this.options.browseButtonText}
</button>
<input
type="file"
class="dropzone-input"
data-file-input
${this.options.multiple ? 'multiple' : ''}
${this.options.allowedMimeTypes.length > 0 ? `accept="${this.options.allowedMimeTypes.join(',')}"` : ''}
style="display: none;"
/>
</div>
</div>
<!-- File List -->
${this.options.showFileList ? `
<div class="file-upload-list" data-file-list style="display: none;">
<div class="file-list-header">
<h4>Files (0)</h4>
<div class="file-list-actions">
${!this.options.autoUpload ? `<button type="button" class="btn btn-primary btn-sm" data-upload-all>${this.options.uploadButtonText}</button>` : ''}
<button type="button" class="btn btn-secondary btn-sm" data-clear-all>Clear All</button>
</div>
</div>
<div class="file-list-items" data-file-items></div>
</div>
` : ''}
<!-- Overall Progress (shown when uploading) -->
${this.options.showProgress ? `
<div class="file-upload-progress" data-overall-progress style="display: none;">
<div class="progress-info">
<span class="progress-label">Uploading files...</span>
<span class="progress-percentage" data-progress-text>0%</span>
</div>
<div class="progress-bar">
<div class="progress-fill" data-progress-fill style="width: 0%"></div>
</div>
</div>
` : ''}
</div>
`;
// Cache DOM elements
this.dropZone = this.container.querySelector('[data-dropzone]');
this.fileInput = this.container.querySelector('[data-file-input]');
this.browseButton = this.container.querySelector('[data-browse-button]');
this.fileList = this.container.querySelector('[data-file-list]');
this.fileItems = this.container.querySelector('[data-file-items]');
this.overallProgress = this.container.querySelector('[data-overall-progress]');
this.progressFill = this.container.querySelector('[data-progress-fill]');
this.progressText = this.container.querySelector('[data-progress-text]');
this.uploadAllButton = this.container.querySelector('[data-upload-all]');
this.clearAllButton = this.container.querySelector('[data-clear-all]');
// Bind UI events
this.browseButton?.addEventListener('click', () => this.fileInput.click());
this.uploadAllButton?.addEventListener('click', () => this.uploader.uploadAll());
this.clearAllButton?.addEventListener('click', () => this.clearAll());
}
/**
* Initialize the uploader
*/
initializeUploader() {
this.uploader = new ComponentFileUploader(this.componentElement, {
...this.options,
dropZone: this.dropZone,
fileInput: this.fileInput,
onFileAdded: (data) => this.handleFileAdded(data),
onFileRemoved: (data) => this.handleFileRemoved(data),
onUploadStart: (data) => this.handleUploadStart(data),
onUploadProgress: (data) => this.handleUploadProgress(data),
onUploadComplete: (data) => this.handleUploadComplete(data),
onUploadError: (data) => this.handleUploadError(data),
onAllUploadsComplete: (data) => this.handleAllUploadsComplete(data)
});
}
/**
* Handle file added
*/
handleFileAdded({ fileId, file, progress }) {
if (!this.options.showFileList) return;
// Show file list
if (this.fileList) {
this.fileList.style.display = 'block';
}
// Create file UI element
const fileElement = this.createFileElement(fileId, file, progress);
this.fileItems.appendChild(fileElement);
this.files.set(fileId, fileElement);
// Update file count
this.updateFileCount();
}
/**
* Handle file removed
*/
handleFileRemoved({ fileId }) {
const fileElement = this.files.get(fileId);
if (fileElement) {
fileElement.remove();
this.files.delete(fileId);
}
// Update file count
this.updateFileCount();
// Hide file list if empty
if (this.files.size === 0 && this.fileList) {
this.fileList.style.display = 'none';
}
}
/**
* Handle upload start
*/
handleUploadStart({ fileId }) {
this.updateFileStatus(fileId, 'uploading');
// Show overall progress
if (this.overallProgress) {
this.overallProgress.style.display = 'block';
}
}
/**
* Handle upload progress
*/
handleUploadProgress({ fileId, percentage, uploadedBytes, totalBytes, uploadSpeed, remainingTime }) {
// Update file progress bar
const fileElement = this.files.get(fileId);
if (fileElement) {
const progressBar = fileElement.querySelector('.file-progress-fill');
const progressText = fileElement.querySelector('.file-progress-text');
if (progressBar) {
progressBar.style.width = `${percentage}%`;
}
if (progressText) {
progressText.textContent = `${percentage}%`;
}
}
// Update overall progress
const stats = this.uploader.getStats();
if (this.progressFill) {
this.progressFill.style.width = `${stats.overallProgress}%`;
}
if (this.progressText) {
this.progressText.textContent = `${stats.overallProgress}%`;
}
}
/**
* Handle upload complete
*/
handleUploadComplete({ fileId, response }) {
this.updateFileStatus(fileId, 'complete');
}
/**
* Handle upload error
*/
handleUploadError({ fileId, error }) {
this.updateFileStatus(fileId, 'error', error);
}
/**
* Handle all uploads complete
*/
handleAllUploadsComplete({ totalFiles, successCount, errorCount }) {
// Hide overall progress after a delay
setTimeout(() => {
if (this.overallProgress) {
this.overallProgress.style.display = 'none';
}
}, 2000);
}
/**
* Create file UI element
*/
createFileElement(fileId, file, progress) {
const div = document.createElement('div');
div.className = 'file-item';
div.dataset.fileId = fileId;
const statusClass = progress.error ? 'error' : 'pending';
div.innerHTML = `
<div class="file-preview">
${this.options.showPreviews && file.type.startsWith('image/') ?
`<img class="file-thumbnail" src="${URL.createObjectURL(file)}" alt="${file.name}" />` :
this.getFileIconSvg(file.type)
}
</div>
<div class="file-info">
<div class="file-name" title="${file.name}">${this.truncateFileName(file.name, 40)}</div>
<div class="file-meta">
<span class="file-size">${this.formatBytes(file.size)}</span>
<span class="file-status" data-status="${statusClass}">${progress.error || 'Pending'}</span>
</div>
${this.options.showProgress ? `
<div class="file-progress" style="display: none;">
<div class="file-progress-bar">
<div class="file-progress-fill" style="width: 0%"></div>
</div>
<span class="file-progress-text">0%</span>
</div>
` : ''}
${progress.error ? `
<div class="file-error">${progress.error}</div>
` : ''}
</div>
<div class="file-actions">
<button type="button" class="btn-icon" data-remove-file title="Remove">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
`;
// Bind remove button
const removeButton = div.querySelector('[data-remove-file]');
removeButton.addEventListener('click', () => this.uploader.removeFile(fileId));
return div;
}
/**
* Update file status in UI
*/
updateFileStatus(fileId, status, errorMessage = null) {
const fileElement = this.files.get(fileId);
if (!fileElement) return;
const statusElement = fileElement.querySelector('.file-status');
const progressElement = fileElement.querySelector('.file-progress');
const errorElement = fileElement.querySelector('.file-error');
// Update status text and class
if (statusElement) {
statusElement.dataset.status = status;
const statusText = {
pending: 'Pending',
uploading: 'Uploading...',
complete: 'Complete',
error: 'Error'
}[status] || status;
statusElement.textContent = statusText;
}
// Show/hide progress
if (progressElement) {
progressElement.style.display = status === 'uploading' ? 'flex' : 'none';
}
// Handle errors
if (status === 'error' && errorMessage) {
if (errorElement) {
errorElement.textContent = errorMessage;
errorElement.style.display = 'block';
}
}
// Add completion/error visual feedback
fileElement.classList.remove('file-uploading', 'file-complete', 'file-error');
if (status === 'uploading') fileElement.classList.add('file-uploading');
if (status === 'complete') fileElement.classList.add('file-complete');
if (status === 'error') fileElement.classList.add('file-error');
}
/**
* Update file count in header
*/
updateFileCount() {
const header = this.container.querySelector('.file-list-header h4');
if (header) {
header.textContent = `Files (${this.files.size})`;
}
}
/**
* Clear all files
*/
clearAll() {
this.uploader.clearAll();
this.fileItems.innerHTML = '';
this.files.clear();
if (this.fileList) {
this.fileList.style.display = 'none';
}
this.updateFileCount();
}
/**
* Helper: Format bytes
*/
formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
/**
* Helper: Truncate file name
*/
truncateFileName(name, maxLength) {
if (name.length <= maxLength) return name;
const extension = name.split('.').pop();
const baseName = name.substring(0, name.length - extension.length - 1);
const truncated = baseName.substring(0, maxLength - extension.length - 4);
return `${truncated}...${extension}`;
}
/**
* Helper: Get file icon SVG
*/
getFileIconSvg(mimeType) {
// Document icon
return `
<svg class="file-icon" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
`;
}
/**
* Cleanup
*/
destroy() {
this.uploader.destroy();
this.container.innerHTML = '';
this.files.clear();
}
}
export default FileUploadWidget;

View File

@@ -0,0 +1,415 @@
/**
* Lazy Component Loader
*
* Loads LiveComponents only when they enter the viewport using Intersection Observer API.
* Provides performance optimization for pages with many components.
*
* Features:
* - Viewport-based loading with configurable thresholds
* - Placeholder management during loading
* - Progressive loading with priorities
* - Memory-efficient intersection tracking
* - Automatic cleanup and disconnection
*
* Usage:
* <div data-live-component-lazy="notification-center:user-123"
* data-lazy-threshold="0.1"
* data-lazy-priority="high"
* data-lazy-placeholder="Loading notifications...">
* </div>
*/
export class LazyComponentLoader {
constructor(liveComponentManager) {
this.liveComponentManager = liveComponentManager;
this.observer = null;
this.lazyComponents = new Map(); // element → config
this.loadingQueue = []; // Priority-based loading queue
this.isProcessingQueue = false;
this.defaultOptions = {
rootMargin: '50px', // Load 50px before entering viewport
threshold: 0.1 // Trigger when 10% visible
};
}
/**
* Initialize lazy loading system
*/
init() {
// Create Intersection Observer
this.observer = new IntersectionObserver(
(entries) => this.handleIntersection(entries),
this.defaultOptions
);
// Scan for lazy components
this.scanLazyComponents();
console.log(`[LazyLoader] Initialized with ${this.lazyComponents.size} lazy components`);
}
/**
* Scan DOM for lazy components
*/
scanLazyComponents() {
const lazyElements = document.querySelectorAll('[data-live-component-lazy]');
lazyElements.forEach(element => {
this.registerLazyComponent(element);
});
}
/**
* Register a lazy component for loading
*
* @param {HTMLElement} element - Component container
*/
registerLazyComponent(element) {
const componentId = element.dataset.liveComponentLazy;
if (!componentId) {
console.warn('[LazyLoader] Lazy component missing componentId:', element);
return;
}
// Extract configuration
const config = {
element,
componentId,
threshold: parseFloat(element.dataset.lazyThreshold) || this.defaultOptions.threshold,
priority: element.dataset.lazyPriority || 'normal', // high, normal, low
placeholder: element.dataset.lazyPlaceholder || null,
loaded: false,
loading: false
};
// Show placeholder
if (config.placeholder) {
this.showPlaceholder(element, config.placeholder);
}
// Store config
this.lazyComponents.set(element, config);
// Start observing
this.observer.observe(element);
console.log(`[LazyLoader] Registered lazy component: ${componentId}`);
}
/**
* Handle intersection observer callback
*
* @param {Array<IntersectionObserverEntry>} entries - Intersection entries
*/
handleIntersection(entries) {
entries.forEach(entry => {
if (entry.isIntersecting) {
const config = this.lazyComponents.get(entry.target);
if (config && !config.loaded && !config.loading) {
this.queueComponentLoad(config);
}
}
});
}
/**
* Queue component for loading with priority
*
* @param {Object} config - Component config
*/
queueComponentLoad(config) {
const priorityWeight = this.getPriorityWeight(config.priority);
this.loadingQueue.push({
config,
priority: priorityWeight,
timestamp: Date.now()
});
// Sort by priority (high to low) and then by timestamp (early to late)
this.loadingQueue.sort((a, b) => {
if (b.priority !== a.priority) {
return b.priority - a.priority;
}
return a.timestamp - b.timestamp;
});
console.log(`[LazyLoader] Queued: ${config.componentId} (priority: ${config.priority})`);
// Process queue
this.processLoadingQueue();
}
/**
* Get numeric weight for priority
*
* @param {string} priority - Priority level (high, normal, low)
* @returns {number} Priority weight
*/
getPriorityWeight(priority) {
const weights = {
'high': 3,
'normal': 2,
'low': 1
};
return weights[priority] || 2;
}
/**
* Process loading queue
*
* Loads components sequentially to avoid overloading server.
*/
async processLoadingQueue() {
if (this.isProcessingQueue || this.loadingQueue.length === 0) {
return;
}
this.isProcessingQueue = true;
while (this.loadingQueue.length > 0) {
const { config } = this.loadingQueue.shift();
if (!config.loaded && !config.loading) {
await this.loadComponent(config);
}
}
this.isProcessingQueue = false;
}
/**
* Load component from server
*
* @param {Object} config - Component config
*/
async loadComponent(config) {
config.loading = true;
try {
console.log(`[LazyLoader] Loading: ${config.componentId}`);
// Show loading indicator
this.showLoadingIndicator(config.element);
// Request component HTML from server
const response = await fetch(`/live-component/${config.componentId}/lazy-load`, {
method: 'GET',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to load component');
}
// Replace placeholder with component HTML
config.element.innerHTML = data.html;
// Mark element as regular LiveComponent (no longer lazy)
config.element.setAttribute('data-live-component', config.componentId);
config.element.removeAttribute('data-live-component-lazy');
// Copy CSRF token to element
if (data.csrf_token) {
config.element.dataset.csrfToken = data.csrf_token;
}
// Copy state to element
if (data.state) {
config.element.dataset.state = JSON.stringify(data.state);
}
// Initialize as regular LiveComponent
this.liveComponentManager.init(config.element);
// Mark as loaded
config.loaded = true;
config.loading = false;
// Stop observing
this.observer.unobserve(config.element);
console.log(`[LazyLoader] Loaded: ${config.componentId}`);
// Dispatch custom event
config.element.dispatchEvent(new CustomEvent('livecomponent:lazy:loaded', {
detail: { componentId: config.componentId }
}));
} catch (error) {
console.error(`[LazyLoader] Failed to load ${config.componentId}:`, error);
config.loading = false;
// Show error state
this.showError(config.element, error.message);
// Dispatch error event
config.element.dispatchEvent(new CustomEvent('livecomponent:lazy:error', {
detail: {
componentId: config.componentId,
error: error.message
}
}));
}
}
/**
* Show placeholder
*
* @param {HTMLElement} element - Container element
* @param {string} text - Placeholder text
*/
showPlaceholder(element, text) {
element.innerHTML = `
<div class="livecomponent-lazy-placeholder" style="
padding: 2rem;
text-align: center;
color: #666;
background: #f5f5f5;
border-radius: 8px;
border: 1px dashed #ddd;
">
<div style="font-size: 1.5rem; margin-bottom: 0.5rem;">⏳</div>
<div>${text}</div>
</div>
`;
}
/**
* Show loading indicator
*
* @param {HTMLElement} element - Container element
*/
showLoadingIndicator(element) {
element.innerHTML = `
<div class="livecomponent-lazy-loading" style="
padding: 2rem;
text-align: center;
color: #2196F3;
background: #f5f5f5;
border-radius: 8px;
border: 1px solid #e3f2fd;
">
<div class="spinner" style="
width: 40px;
height: 40px;
margin: 0 auto 1rem;
border: 4px solid #e3f2fd;
border-top: 4px solid #2196F3;
border-radius: 50%;
animation: spin 1s linear infinite;
"></div>
<div>Loading component...</div>
</div>
<style>
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
`;
}
/**
* Show error state
*
* @param {HTMLElement} element - Container element
* @param {string} errorMessage - Error message
*/
showError(element, errorMessage) {
element.innerHTML = `
<div class="livecomponent-lazy-error" style="
padding: 2rem;
text-align: center;
color: #d32f2f;
background: #ffebee;
border-radius: 8px;
border: 1px solid #ef9a9a;
">
<div style="font-size: 1.5rem; margin-bottom: 0.5rem;">❌</div>
<div><strong>Failed to load component</strong></div>
<div style="margin-top: 0.5rem; font-size: 0.9rem; color: #c62828;">
${errorMessage}
</div>
</div>
`;
}
/**
* Unregister lazy component and stop observing
*
* @param {HTMLElement} element - Component element
*/
unregister(element) {
const config = this.lazyComponents.get(element);
if (!config) return;
// Stop observing
this.observer.unobserve(element);
// Remove from registry
this.lazyComponents.delete(element);
console.log(`[LazyLoader] Unregistered: ${config.componentId}`);
}
/**
* Destroy lazy loader
*
* Clean up all observers and references.
*/
destroy() {
// Disconnect observer
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
// Clear queue
this.loadingQueue = [];
// Clear registry
this.lazyComponents.clear();
console.log('[LazyLoader] Destroyed');
}
/**
* Get lazy loading statistics
*
* @returns {Object} Statistics
*/
getStats() {
let loaded = 0;
let loading = 0;
let pending = 0;
this.lazyComponents.forEach(config => {
if (config.loaded) loaded++;
else if (config.loading) loading++;
else pending++;
});
return {
total: this.lazyComponents.size,
loaded,
loading,
pending,
queued: this.loadingQueue.length
};
}
}
// Export for use in LiveComponent module
export default LazyComponentLoader;

View File

@@ -0,0 +1,429 @@
/**
* Nested Component Handler
*
* Manages parent-child relationships for nested LiveComponents on the client-side.
* Coordinates event bubbling, state synchronization, and lifecycle management.
*
* Features:
* - Parent-child relationship tracking
* - Event bubbling from child to parent
* - Child lifecycle coordination
* - State synchronization between parent and children
* - Automatic cleanup on component destruction
*
* Architecture:
* - Scans DOM for nested components (data-parent-component attribute)
* - Registers hierarchies with LiveComponentManager
* - Intercepts events to enable bubbling
* - Coordinates updates between parents and children
*/
export class NestedComponentHandler {
constructor(liveComponentManager) {
this.liveComponentManager = liveComponentManager;
// Registry: componentId → { parentId, childIds, depth }
this.hierarchyRegistry = new Map();
// Parent → [Children] mapping
this.childrenRegistry = new Map();
// Event bubbling callbacks: componentId → [callbacks]
this.bubbleCallbacks = new Map();
}
/**
* Initialize nested component system
* Scans DOM for nested components and registers hierarchies
*/
init() {
this.scanNestedComponents();
console.log(`[NestedComponents] Initialized with ${this.hierarchyRegistry.size} components`);
}
/**
* Scan DOM for nested components
* Looks for data-parent-component attribute to establish parent-child relationships
*/
scanNestedComponents() {
// Find all components with parent
const nestedComponents = document.querySelectorAll('[data-parent-component]');
nestedComponents.forEach(element => {
const componentId = element.dataset.liveComponent;
const parentId = element.dataset.parentComponent;
const depth = parseInt(element.dataset.nestingDepth) || 1;
if (componentId && parentId) {
this.registerHierarchy(componentId, parentId, depth);
}
});
// Register root components (no parent)
const rootComponents = document.querySelectorAll('[data-live-component]:not([data-parent-component])');
rootComponents.forEach(element => {
const componentId = element.dataset.liveComponent;
if (componentId && !this.hierarchyRegistry.has(componentId)) {
this.registerRoot(componentId);
}
});
}
/**
* Register root component (no parent)
*
* @param {string} componentId - Component ID
*/
registerRoot(componentId) {
this.hierarchyRegistry.set(componentId, {
parentId: null,
childIds: [],
depth: 0,
path: [componentId]
});
console.log(`[NestedComponents] Registered root: ${componentId}`);
}
/**
* Register component hierarchy
*
* @param {string} componentId - Child component ID
* @param {string} parentId - Parent component ID
* @param {number} depth - Nesting depth
*/
registerHierarchy(componentId, parentId, depth = 1) {
// Get parent's path
const parentHierarchy = this.hierarchyRegistry.get(parentId);
const parentPath = parentHierarchy ? parentHierarchy.path : [parentId];
// Create hierarchy entry
this.hierarchyRegistry.set(componentId, {
parentId,
childIds: [],
depth,
path: [...parentPath, componentId]
});
// Add to parent's children
if (!this.childrenRegistry.has(parentId)) {
this.childrenRegistry.set(parentId, []);
}
const children = this.childrenRegistry.get(parentId);
if (!children.includes(componentId)) {
children.push(componentId);
}
console.log(`[NestedComponents] Registered child: ${componentId} (parent: ${parentId}, depth: ${depth})`);
}
/**
* Register dynamic nested component at runtime
*
* @param {string} componentId - Component ID
* @param {string} parentId - Parent component ID
*/
registerDynamicChild(componentId, parentId) {
const parentHierarchy = this.hierarchyRegistry.get(parentId);
if (!parentHierarchy) {
console.warn(`[NestedComponents] Cannot register child - parent not found: ${parentId}`);
return;
}
const depth = parentHierarchy.depth + 1;
this.registerHierarchy(componentId, parentId, depth);
}
/**
* Get component hierarchy
*
* @param {string} componentId - Component ID
* @returns {Object|null} Hierarchy object or null
*/
getHierarchy(componentId) {
return this.hierarchyRegistry.get(componentId) || null;
}
/**
* Get parent component ID
*
* @param {string} componentId - Component ID
* @returns {string|null} Parent ID or null if root
*/
getParentId(componentId) {
const hierarchy = this.getHierarchy(componentId);
return hierarchy ? hierarchy.parentId : null;
}
/**
* Get child component IDs
*
* @param {string} componentId - Parent component ID
* @returns {Array<string>} Array of child IDs
*/
getChildIds(componentId) {
return this.childrenRegistry.get(componentId) || [];
}
/**
* Check if component has children
*
* @param {string} componentId - Component ID
* @returns {boolean} True if has children
*/
hasChildren(componentId) {
const children = this.getChildIds(componentId);
return children.length > 0;
}
/**
* Check if component is root
*
* @param {string} componentId - Component ID
* @returns {boolean} True if root component
*/
isRoot(componentId) {
const hierarchy = this.getHierarchy(componentId);
return hierarchy ? hierarchy.parentId === null : true;
}
/**
* Get nesting depth
*
* @param {string} componentId - Component ID
* @returns {number} Nesting depth (0 for root)
*/
getDepth(componentId) {
const hierarchy = this.getHierarchy(componentId);
return hierarchy ? hierarchy.depth : 0;
}
/**
* Get all ancestors (parent, grandparent, etc.)
*
* @param {string} componentId - Component ID
* @returns {Array<string>} Array of ancestor IDs (parent first, root last)
*/
getAncestors(componentId) {
const hierarchy = this.getHierarchy(componentId);
if (!hierarchy || !hierarchy.path) {
return [];
}
// Path includes current component, remove it
const ancestors = [...hierarchy.path];
ancestors.pop();
// Return in reverse order (parent first, root last)
return ancestors.reverse();
}
/**
* Bubble event up through component hierarchy
*
* Dispatches custom event to each ancestor until stopped or root reached.
*
* @param {string} sourceId - Component that dispatched the event
* @param {string} eventName - Event name
* @param {Object} payload - Event payload
* @returns {boolean} True if bubbled to root, false if stopped
*/
bubbleEvent(sourceId, eventName, payload) {
console.log(`[NestedComponents] Bubbling event: ${eventName} from ${sourceId}`, payload);
let currentId = sourceId;
let bubbled = 0;
while (true) {
const parentId = this.getParentId(currentId);
// Reached root
if (parentId === null) {
console.log(`[NestedComponents] Event bubbled to root (${bubbled} levels)`);
return true;
}
// Get parent element
const parentElement = document.querySelector(`[data-live-component="${parentId}"]`);
if (!parentElement) {
console.warn(`[NestedComponents] Parent element not found: ${parentId}`);
return false;
}
// Dispatch custom event to parent
const bubbleEvent = new CustomEvent(`livecomponent:child:${eventName}`, {
detail: {
sourceId,
eventName,
payload,
currentLevel: bubbled
},
bubbles: false, // We handle bubbling manually
cancelable: true
});
const dispatched = parentElement.dispatchEvent(bubbleEvent);
// Event was cancelled - stop bubbling
if (!dispatched) {
console.log(`[NestedComponents] Event bubbling stopped at ${parentId}`);
return false;
}
// Check for registered callbacks
const callbacks = this.bubbleCallbacks.get(parentId);
if (callbacks) {
for (const callback of callbacks) {
const shouldContinue = callback(sourceId, eventName, payload);
if (shouldContinue === false) {
console.log(`[NestedComponents] Event bubbling stopped by callback at ${parentId}`);
return false;
}
}
}
// Move to next level
currentId = parentId;
bubbled++;
}
}
/**
* Register callback for child events
*
* @param {string} parentId - Parent component ID
* @param {Function} callback - Callback function (sourceId, eventName, payload) => boolean
*/
onChildEvent(parentId, callback) {
if (!this.bubbleCallbacks.has(parentId)) {
this.bubbleCallbacks.set(parentId, []);
}
this.bubbleCallbacks.get(parentId).push(callback);
console.log(`[NestedComponents] Registered child event callback for ${parentId}`);
}
/**
* Sync state from parent to children
*
* Useful for broadcasting shared state to all children.
*
* @param {string} parentId - Parent component ID
* @param {Object} sharedState - State to share with children
*/
syncStateToChildren(parentId, sharedState) {
const childIds = this.getChildIds(parentId);
console.log(`[NestedComponents] Syncing state to ${childIds.length} children of ${parentId}`);
childIds.forEach(childId => {
const childElement = document.querySelector(`[data-live-component="${childId}"]`);
if (!childElement) return;
// Dispatch state sync event
childElement.dispatchEvent(new CustomEvent('livecomponent:parent:state-sync', {
detail: { parentId, sharedState }
}));
// Update child state if applicable
// Child components can listen to this event and update accordingly
});
}
/**
* Update all children when parent changes
*
* @param {string} parentId - Parent component ID
* @param {Object} updates - Updates to apply to children
*/
async updateChildren(parentId, updates) {
const childIds = this.getChildIds(parentId);
console.log(`[NestedComponents] Updating ${childIds.length} children of ${parentId}`);
// Update children in parallel for performance
const updatePromises = childIds.map(async childId => {
const childElement = document.querySelector(`[data-live-component="${childId}"]`);
if (!childElement) return;
// Trigger child component update via LiveComponent action
// This depends on how your components handle updates
// For now, just dispatch an event
childElement.dispatchEvent(new CustomEvent('livecomponent:parent:update', {
detail: { parentId, updates }
}));
});
await Promise.all(updatePromises);
}
/**
* Unregister component and cleanup
*
* @param {string} componentId - Component to unregister
*/
unregister(componentId) {
// Remove from hierarchy registry
const hierarchy = this.hierarchyRegistry.get(componentId);
this.hierarchyRegistry.delete(componentId);
// Remove from parent's children
if (hierarchy && hierarchy.parentId) {
const siblings = this.childrenRegistry.get(hierarchy.parentId);
if (siblings) {
const index = siblings.indexOf(componentId);
if (index !== -1) {
siblings.splice(index, 1);
}
}
}
// Remove children registry
this.childrenRegistry.delete(componentId);
// Remove bubble callbacks
this.bubbleCallbacks.delete(componentId);
console.log(`[NestedComponents] Unregistered: ${componentId}`);
}
/**
* Get hierarchy statistics
*
* @returns {Object} Statistics
*/
getStats() {
let rootCount = 0;
let maxDepth = 0;
this.hierarchyRegistry.forEach(hierarchy => {
if (hierarchy.parentId === null) {
rootCount++;
}
maxDepth = Math.max(maxDepth, hierarchy.depth);
});
return {
total_components: this.hierarchyRegistry.size,
root_components: rootCount,
child_components: this.hierarchyRegistry.size - rootCount,
max_nesting_depth: maxDepth,
parents_with_children: this.childrenRegistry.size
};
}
/**
* Destroy nested component handler
*/
destroy() {
this.hierarchyRegistry.clear();
this.childrenRegistry.clear();
this.bubbleCallbacks.clear();
console.log('[NestedComponents] Destroyed');
}
}
export default NestedComponentHandler;

View File

@@ -0,0 +1,445 @@
/**
* Optimistic State Manager
*
* Manages optimistic UI updates with version-based conflict resolution.
* Implements the "Optimistic UI" pattern where client immediately updates
* the UI before server confirms, then rolls back on conflicts.
*
* Key Features:
* - Immediate UI feedback (no loading spinners)
* - Version-based optimistic concurrency control
* - Automatic conflict detection and rollback
* - Pending operations queue with retry
* - User-friendly conflict resolution
*
* Protocol:
* 1. Client updates UI immediately (optimistic)
* 2. Client sends update to server with version
* 3. Server checks version and either:
* a) Accepts: version matches, returns new state with version++
* b) Rejects: version conflict, returns current server state
* 4. Client resolves:
* a) On accept: commit optimistic state
* b) On conflict: rollback to server state, show conflict UI
*/
export class OptimisticStateManager {
constructor() {
/**
* Pending operations queue
* Map<componentId, PendingOperation[]>
*/
this.pendingOperations = new Map();
/**
* Rollback snapshots
* Map<componentId, StateSnapshot>
*/
this.snapshots = new Map();
/**
* Conflict callbacks
* Map<componentId, ConflictCallback>
*/
this.conflictHandlers = new Map();
/**
* Optimistic operation counter
*/
this.operationIdCounter = 0;
}
/**
* Apply optimistic update
*
* Updates component state immediately without waiting for server confirmation.
* Creates snapshot for potential rollback.
*
* @param {string} componentId - Component identifier
* @param {Object} currentState - Current component state (with version)
* @param {Function} optimisticUpdate - Function that returns optimistically updated state
* @param {Object} metadata - Operation metadata (action, params)
* @returns {Object} Optimistic state with incremented version
*/
applyOptimisticUpdate(componentId, currentState, optimisticUpdate, metadata = {}) {
// Create snapshot before applying optimistic update
if (!this.snapshots.has(componentId)) {
this.createSnapshot(componentId, currentState);
}
// Extract version from state
const currentVersion = currentState.version || 1;
// Apply optimistic update
const optimisticState = optimisticUpdate(currentState);
// Increment version optimistically
const newState = {
...optimisticState,
version: currentVersion + 1
};
// Create pending operation
const operation = {
id: this.generateOperationId(),
componentId,
metadata,
expectedVersion: currentVersion,
optimisticState: newState,
timestamp: Date.now(),
status: 'pending' // pending, confirmed, failed
};
// Add to pending operations queue
this.addPendingOperation(componentId, operation);
console.log(`[OptimisticUI] Applied optimistic update for ${componentId}`, {
version: `${currentVersion}${currentVersion + 1}`,
operationId: operation.id,
pendingCount: this.getPendingOperationsCount(componentId)
});
return newState;
}
/**
* Confirm operation success
*
* Called when server successfully processes the operation.
* Commits the optimistic state and clears snapshot if no more pending operations.
*
* @param {string} componentId - Component identifier
* @param {string} operationId - Operation ID to confirm
* @param {Object} serverState - Server-confirmed state (may differ from optimistic)
* @returns {Object} Final state (server state or optimistic if versions match)
*/
confirmOperation(componentId, operationId, serverState) {
const operation = this.getPendingOperation(componentId, operationId);
if (!operation) {
console.warn(`[OptimisticUI] Cannot confirm unknown operation: ${operationId}`);
return serverState;
}
// Mark operation as confirmed
operation.status = 'confirmed';
// Remove from pending queue
this.removePendingOperation(componentId, operationId);
console.log(`[OptimisticUI] Confirmed operation ${operationId} for ${componentId}`, {
pendingCount: this.getPendingOperationsCount(componentId)
});
// If no more pending operations, clear snapshot
if (!this.hasPendingOperations(componentId)) {
this.clearSnapshot(componentId);
}
return serverState;
}
/**
* Handle version conflict
*
* Called when server rejects operation due to version mismatch.
* Rolls back to snapshot and applies server state.
*
* @param {string} componentId - Component identifier
* @param {string} operationId - Failed operation ID
* @param {Object} serverState - Current server state
* @param {Object} conflict - Conflict information
* @returns {Object} Resolution result with rollback state and user notification
*/
handleConflict(componentId, operationId, serverState, conflict = {}) {
const operation = this.getPendingOperation(componentId, operationId);
if (!operation) {
console.warn(`[OptimisticUI] Cannot handle conflict for unknown operation: ${operationId}`);
return { state: serverState, notification: null };
}
// Mark operation as failed
operation.status = 'failed';
// Get snapshot for rollback
const snapshot = this.snapshots.get(componentId);
console.warn(`[OptimisticUI] Version conflict detected for ${componentId}`, {
operationId,
expectedVersion: operation.expectedVersion,
serverVersion: serverState.version,
metadata: operation.metadata,
pendingOperationsCount: this.getPendingOperationsCount(componentId)
});
// Clear all pending operations (cascade rollback)
this.clearPendingOperations(componentId);
// Clear snapshot
this.clearSnapshot(componentId);
// Call conflict handler if registered
const conflictHandler = this.conflictHandlers.get(componentId);
if (conflictHandler) {
conflictHandler({
operation,
serverState,
snapshotState: snapshot?.state,
conflict
});
}
// Create user notification
const notification = {
type: 'conflict',
title: 'Update Conflict',
message: 'Your changes conflicted with another update. The latest version has been loaded.',
action: operation.metadata.action || 'unknown',
canRetry: true,
operation
};
return {
state: serverState,
notification
};
}
/**
* Create state snapshot for rollback
*
* @param {string} componentId - Component identifier
* @param {Object} state - Current state to snapshot
*/
createSnapshot(componentId, state) {
this.snapshots.set(componentId, {
state: JSON.parse(JSON.stringify(state)), // Deep clone
timestamp: Date.now()
});
console.log(`[OptimisticUI] Created snapshot for ${componentId}`, {
version: state.version
});
}
/**
* Clear snapshot after all operations confirmed
*
* @param {string} componentId - Component identifier
*/
clearSnapshot(componentId) {
const snapshot = this.snapshots.get(componentId);
if (snapshot) {
this.snapshots.delete(componentId);
console.log(`[OptimisticUI] Cleared snapshot for ${componentId}`);
}
}
/**
* Get rollback snapshot
*
* @param {string} componentId - Component identifier
* @returns {Object|null} Snapshot or null if none exists
*/
getSnapshot(componentId) {
const snapshot = this.snapshots.get(componentId);
return snapshot ? snapshot.state : null;
}
/**
* Add pending operation
*
* @param {string} componentId - Component identifier
* @param {Object} operation - Operation object
*/
addPendingOperation(componentId, operation) {
if (!this.pendingOperations.has(componentId)) {
this.pendingOperations.set(componentId, []);
}
this.pendingOperations.get(componentId).push(operation);
}
/**
* Remove pending operation
*
* @param {string} componentId - Component identifier
* @param {string} operationId - Operation ID to remove
*/
removePendingOperation(componentId, operationId) {
const operations = this.pendingOperations.get(componentId);
if (!operations) {
return;
}
const index = operations.findIndex(op => op.id === operationId);
if (index !== -1) {
operations.splice(index, 1);
}
// Clean up empty arrays
if (operations.length === 0) {
this.pendingOperations.delete(componentId);
}
}
/**
* Get specific pending operation
*
* @param {string} componentId - Component identifier
* @param {string} operationId - Operation ID
* @returns {Object|null} Operation or null if not found
*/
getPendingOperation(componentId, operationId) {
const operations = this.pendingOperations.get(componentId);
if (!operations) {
return null;
}
return operations.find(op => op.id === operationId) || null;
}
/**
* Get all pending operations for component
*
* @param {string} componentId - Component identifier
* @returns {Array<Object>} Array of pending operations
*/
getPendingOperations(componentId) {
return this.pendingOperations.get(componentId) || [];
}
/**
* Get pending operations count
*
* @param {string} componentId - Component identifier
* @returns {number} Number of pending operations
*/
getPendingOperationsCount(componentId) {
const operations = this.pendingOperations.get(componentId);
return operations ? operations.length : 0;
}
/**
* Check if component has pending operations
*
* @param {string} componentId - Component identifier
* @returns {boolean} True if has pending operations
*/
hasPendingOperations(componentId) {
return this.getPendingOperationsCount(componentId) > 0;
}
/**
* Clear all pending operations for component
*
* @param {string} componentId - Component identifier
*/
clearPendingOperations(componentId) {
this.pendingOperations.delete(componentId);
}
/**
* Register conflict handler
*
* Allows components to provide custom conflict resolution UI.
*
* @param {string} componentId - Component identifier
* @param {Function} handler - Conflict handler callback
*/
registerConflictHandler(componentId, handler) {
this.conflictHandlers.set(componentId, handler);
}
/**
* Unregister conflict handler
*
* @param {string} componentId - Component identifier
*/
unregisterConflictHandler(componentId) {
this.conflictHandlers.delete(componentId);
}
/**
* Generate unique operation ID
*
* @returns {string} Unique operation ID
*/
generateOperationId() {
return `op-${++this.operationIdCounter}-${Date.now()}`;
}
/**
* Retry failed operation
*
* Allows user to retry operation after conflict resolution.
*
* @param {string} componentId - Component identifier
* @param {Object} operation - Failed operation to retry
* @param {Function} retryCallback - Callback to execute retry
* @returns {Promise} Retry promise
*/
async retryOperation(componentId, operation, retryCallback) {
console.log(`[OptimisticUI] Retrying operation ${operation.id} for ${componentId}`);
try {
// Execute retry callback
const result = await retryCallback(operation.metadata);
console.log(`[OptimisticUI] Retry succeeded for ${operation.id}`);
return result;
} catch (error) {
console.error(`[OptimisticUI] Retry failed for ${operation.id}:`, error);
throw error;
}
}
/**
* Get debugging stats
*
* @returns {Object} Statistics about optimistic operations
*/
getStats() {
const stats = {
total_components: this.pendingOperations.size,
total_pending_operations: 0,
total_snapshots: this.snapshots.size,
components: {}
};
this.pendingOperations.forEach((operations, componentId) => {
stats.total_pending_operations += operations.length;
stats.components[componentId] = {
pending: operations.length,
has_snapshot: this.snapshots.has(componentId)
};
});
return stats;
}
/**
* Clear all state (for testing/debugging)
*/
clear() {
this.pendingOperations.clear();
this.snapshots.clear();
this.conflictHandlers.clear();
this.operationIdCounter = 0;
console.log('[OptimisticUI] Cleared all state');
}
}
// Create singleton instance
export const optimisticStateManager = new OptimisticStateManager();
// Export for testing and direct use
export default optimisticStateManager;

File diff suppressed because it is too large Load Diff