- 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.
420 lines
15 KiB
JavaScript
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;
|