Files
michaelschiemer/resources/js/modules/image-manager/ImageUploader.js
Michael Schiemer 5050c7d73a docs: consolidate documentation into organized structure
- 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
2025-10-05 11:05:04 +02:00

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');
}
}