- Move 12 markdown files from root to docs/ subdirectories - Organize documentation by category: • docs/troubleshooting/ (1 file) - Technical troubleshooting guides • docs/deployment/ (4 files) - Deployment and security documentation • docs/guides/ (3 files) - Feature-specific guides • docs/planning/ (4 files) - Planning and improvement proposals Root directory cleanup: - Reduced from 16 to 4 markdown files in root - Only essential project files remain: • CLAUDE.md (AI instructions) • README.md (Main project readme) • CLEANUP_PLAN.md (Current cleanup plan) • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements) This improves: ✅ Documentation discoverability ✅ Logical organization by purpose ✅ Clean root directory ✅ Better maintainability
385 lines
13 KiB
JavaScript
385 lines
13 KiB
JavaScript
import { Logger } from '../../core/logger.js';
|
|
import { EventEmitter } from './EventEmitter.js';
|
|
|
|
export class ImageUploader extends EventEmitter {
|
|
constructor(config, state) {
|
|
super();
|
|
this.config = config;
|
|
this.state = state;
|
|
this.uploadAreas = new Map();
|
|
this.activeUploads = new Map();
|
|
}
|
|
|
|
async init() {
|
|
const elements = document.querySelectorAll('[data-image-upload]');
|
|
|
|
for (const element of elements) {
|
|
await this.initializeUploadArea(element);
|
|
}
|
|
|
|
Logger.info(`[ImageUploader] Initialized ${elements.length} upload areas`);
|
|
}
|
|
|
|
async initializeUploadArea(element) {
|
|
const config = this.parseElementConfig(element);
|
|
const uploadArea = new UploadArea(element, config, this);
|
|
|
|
this.uploadAreas.set(element, uploadArea);
|
|
await uploadArea.init();
|
|
}
|
|
|
|
parseElementConfig(element) {
|
|
return {
|
|
multiple: element.hasAttribute('data-multiple'),
|
|
maxFiles: parseInt(element.dataset.maxFiles) || 10,
|
|
accept: element.dataset.accept || this.config.allowedTypes.join(','),
|
|
maxSize: parseInt(element.dataset.maxSize) || this.config.maxFileSize,
|
|
preview: element.hasAttribute('data-preview'),
|
|
...this.config
|
|
};
|
|
}
|
|
|
|
async uploadFile(file, config, progressCallback) {
|
|
const uploadId = this.generateUploadId();
|
|
|
|
try {
|
|
// Validate file
|
|
this.validateFile(file, config);
|
|
|
|
// Create form data
|
|
const formData = new FormData();
|
|
formData.append('image', file);
|
|
|
|
// Add CSRF token if available
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ||
|
|
document.querySelector('input[name="_token"]')?.value;
|
|
if (csrfToken) {
|
|
formData.append('_token', csrfToken);
|
|
}
|
|
|
|
// Create XMLHttpRequest for progress tracking
|
|
const xhr = new XMLHttpRequest();
|
|
|
|
// Track active upload
|
|
this.activeUploads.set(uploadId, { xhr, file });
|
|
|
|
// Setup progress tracking
|
|
xhr.upload.addEventListener('progress', (event) => {
|
|
if (event.lengthComputable) {
|
|
const progress = Math.round((event.loaded / event.total) * 100);
|
|
progressCallback?.(progress);
|
|
this.emit('upload:progress', { uploadId, file, progress });
|
|
}
|
|
});
|
|
|
|
// Create upload promise
|
|
const uploadPromise = new Promise((resolve, reject) => {
|
|
xhr.onload = () => {
|
|
this.activeUploads.delete(uploadId);
|
|
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
try {
|
|
const response = JSON.parse(xhr.responseText);
|
|
resolve(response);
|
|
} catch (e) {
|
|
reject(new Error('Invalid JSON response'));
|
|
}
|
|
} else {
|
|
try {
|
|
const errorResponse = JSON.parse(xhr.responseText);
|
|
reject(new Error(errorResponse.message || `Upload failed: ${xhr.status}`));
|
|
} catch (e) {
|
|
reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`));
|
|
}
|
|
}
|
|
};
|
|
|
|
xhr.onerror = () => {
|
|
this.activeUploads.delete(uploadId);
|
|
reject(new Error('Network error during upload'));
|
|
};
|
|
|
|
xhr.onabort = () => {
|
|
this.activeUploads.delete(uploadId);
|
|
reject(new Error('Upload cancelled'));
|
|
};
|
|
});
|
|
|
|
// Start upload
|
|
xhr.open('POST', config.uploadEndpoint);
|
|
xhr.send(formData);
|
|
|
|
const result = await uploadPromise;
|
|
this.emit('upload:success', result);
|
|
return result;
|
|
|
|
} catch (error) {
|
|
this.activeUploads.delete(uploadId);
|
|
this.emit('upload:error', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
validateFile(file, config) {
|
|
// Check file type
|
|
if (!config.allowedTypes.includes(file.type)) {
|
|
throw new Error(`File type ${file.type} is not allowed`);
|
|
}
|
|
|
|
// Check file size
|
|
if (file.size > config.maxSize) {
|
|
const maxSizeMB = Math.round(config.maxSize / (1024 * 1024));
|
|
throw new Error(`File size exceeds ${maxSizeMB}MB limit`);
|
|
}
|
|
|
|
// Check if it's actually an image
|
|
if (!file.type.startsWith('image/')) {
|
|
throw new Error('File is not an image');
|
|
}
|
|
}
|
|
|
|
cancelUpload(uploadId) {
|
|
const upload = this.activeUploads.get(uploadId);
|
|
if (upload) {
|
|
upload.xhr.abort();
|
|
this.activeUploads.delete(uploadId);
|
|
this.emit('upload:cancelled', { uploadId, file: upload.file });
|
|
}
|
|
}
|
|
|
|
generateUploadId() {
|
|
return 'upload_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
|
}
|
|
|
|
destroy() {
|
|
// Cancel all active uploads
|
|
for (const [uploadId] of this.activeUploads) {
|
|
this.cancelUpload(uploadId);
|
|
}
|
|
|
|
// Destroy all upload areas
|
|
for (const [element, uploadArea] of this.uploadAreas) {
|
|
uploadArea.destroy();
|
|
}
|
|
|
|
this.uploadAreas.clear();
|
|
this.removeAllListeners();
|
|
}
|
|
}
|
|
|
|
class UploadArea {
|
|
constructor(element, config, uploader) {
|
|
this.element = element;
|
|
this.config = config;
|
|
this.uploader = uploader;
|
|
this.fileInput = null;
|
|
this.previewContainer = null;
|
|
this.uploadQueue = [];
|
|
this.isDragOver = false;
|
|
}
|
|
|
|
async init() {
|
|
this.createHTML();
|
|
this.setupEventListeners();
|
|
this.setupDragAndDrop();
|
|
}
|
|
|
|
createHTML() {
|
|
this.element.classList.add('image-upload-area');
|
|
|
|
this.element.innerHTML = `
|
|
<div class="upload-area__content">
|
|
<div class="upload-area__icon">
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"></path>
|
|
<circle cx="12" cy="13" r="3"></circle>
|
|
</svg>
|
|
</div>
|
|
<div class="upload-area__text">
|
|
<h3>Drop images here or click to browse</h3>
|
|
<p>Supports ${this.config.allowedTypes.map(type => type.split('/')[1].toUpperCase()).join(', ')}
|
|
up to ${Math.round(this.config.maxSize / (1024 * 1024))}MB</p>
|
|
</div>
|
|
<button type="button" class="upload-area__button">Choose Files</button>
|
|
<input type="file" class="upload-area__input" ${this.config.multiple ? 'multiple' : ''}
|
|
accept="${this.config.accept}" style="display: none;">
|
|
</div>
|
|
${this.config.preview ? '<div class="upload-area__preview"></div>' : ''}
|
|
<div class="upload-area__progress" style="display: none;">
|
|
<div class="progress-bar">
|
|
<div class="progress-bar__fill"></div>
|
|
</div>
|
|
<div class="progress-text">Uploading...</div>
|
|
</div>
|
|
`;
|
|
|
|
this.fileInput = this.element.querySelector('.upload-area__input');
|
|
if (this.config.preview) {
|
|
this.previewContainer = this.element.querySelector('.upload-area__preview');
|
|
}
|
|
}
|
|
|
|
setupEventListeners() {
|
|
const button = this.element.querySelector('.upload-area__button');
|
|
const content = this.element.querySelector('.upload-area__content');
|
|
|
|
// Button and area click handlers
|
|
button.addEventListener('click', () => this.fileInput.click());
|
|
content.addEventListener('click', (e) => {
|
|
if (e.target === content || e.target.closest('.upload-area__text')) {
|
|
this.fileInput.click();
|
|
}
|
|
});
|
|
|
|
// File input change
|
|
this.fileInput.addEventListener('change', (e) => {
|
|
this.handleFiles(Array.from(e.target.files));
|
|
});
|
|
|
|
// Keyboard accessibility
|
|
this.element.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
this.fileInput.click();
|
|
}
|
|
});
|
|
|
|
// Make area focusable
|
|
this.element.setAttribute('tabindex', '0');
|
|
this.element.setAttribute('role', 'button');
|
|
this.element.setAttribute('aria-label', 'Upload images');
|
|
}
|
|
|
|
setupDragAndDrop() {
|
|
const events = ['dragenter', 'dragover', 'dragleave', 'drop'];
|
|
|
|
events.forEach(eventName => {
|
|
this.element.addEventListener(eventName, (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
});
|
|
});
|
|
|
|
this.element.addEventListener('dragenter', () => {
|
|
this.isDragOver = true;
|
|
this.element.classList.add('upload-area--drag-over');
|
|
});
|
|
|
|
this.element.addEventListener('dragleave', (e) => {
|
|
// Only remove drag styles if leaving the entire upload area
|
|
if (!this.element.contains(e.relatedTarget)) {
|
|
this.isDragOver = false;
|
|
this.element.classList.remove('upload-area--drag-over');
|
|
}
|
|
});
|
|
|
|
this.element.addEventListener('drop', (e) => {
|
|
this.isDragOver = false;
|
|
this.element.classList.remove('upload-area--drag-over');
|
|
|
|
const files = Array.from(e.dataTransfer.files).filter(file =>
|
|
file.type.startsWith('image/')
|
|
);
|
|
|
|
if (files.length > 0) {
|
|
this.handleFiles(files);
|
|
}
|
|
});
|
|
}
|
|
|
|
async handleFiles(files) {
|
|
// Validate file count
|
|
if (!this.config.multiple && files.length > 1) {
|
|
files = [files[0]];
|
|
}
|
|
|
|
if (files.length > this.config.maxFiles) {
|
|
this.uploader.emit('upload:error',
|
|
new Error(`Maximum ${this.config.maxFiles} files allowed`)
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Show previews if enabled
|
|
if (this.config.preview) {
|
|
this.showPreviews(files);
|
|
}
|
|
|
|
// Upload files
|
|
for (const file of files) {
|
|
await this.uploadFile(file);
|
|
}
|
|
}
|
|
|
|
showPreviews(files) {
|
|
if (!this.previewContainer) return;
|
|
|
|
this.previewContainer.innerHTML = '';
|
|
|
|
files.forEach(file => {
|
|
const previewItem = document.createElement('div');
|
|
previewItem.className = 'preview-item';
|
|
|
|
const img = document.createElement('img');
|
|
img.className = 'preview-item__image';
|
|
img.alt = file.name;
|
|
|
|
const info = document.createElement('div');
|
|
info.className = 'preview-item__info';
|
|
info.innerHTML = `
|
|
<div class="preview-item__name">${file.name}</div>
|
|
<div class="preview-item__size">${this.formatFileSize(file.size)}</div>
|
|
`;
|
|
|
|
previewItem.appendChild(img);
|
|
previewItem.appendChild(info);
|
|
this.previewContainer.appendChild(previewItem);
|
|
|
|
// Create image preview
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
img.src = e.target.result;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
async uploadFile(file) {
|
|
const progressBar = this.element.querySelector('.upload-area__progress');
|
|
const progressFill = this.element.querySelector('.progress-bar__fill');
|
|
const progressText = this.element.querySelector('.progress-text');
|
|
|
|
try {
|
|
// Show progress
|
|
progressBar.style.display = 'block';
|
|
progressText.textContent = `Uploading ${file.name}...`;
|
|
|
|
await this.uploader.uploadFile(file, this.config, (progress) => {
|
|
progressFill.style.width = `${progress}%`;
|
|
progressText.textContent = `Uploading ${file.name}... ${progress}%`;
|
|
});
|
|
|
|
// Hide progress on success
|
|
progressBar.style.display = 'none';
|
|
|
|
} catch (error) {
|
|
// Hide progress on error
|
|
progressBar.style.display = 'none';
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
formatFileSize(bytes) {
|
|
if (bytes === 0) return '0 Bytes';
|
|
const k = 1024;
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
}
|
|
|
|
destroy() {
|
|
// Remove event listeners and clean up
|
|
this.element.innerHTML = '';
|
|
this.element.classList.remove('image-upload-area', 'upload-area--drag-over');
|
|
}
|
|
} |