Files
michaelschiemer/resources/js/modules/livecomponent/FileUploadWidget.js
Michael Schiemer fc3d7e6357 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.
2025-10-25 19:18:37 +02:00

420 lines
15 KiB
JavaScript

/**
* 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;