import { Logger } from '../../core/logger.js'; import { EventEmitter } from './EventEmitter.js'; export class ImageModal extends EventEmitter { constructor(config, state) { super(); this.config = config; this.state = state; this.modal = null; this.currentImage = null; this.isOpen = false; this.keydownHandler = null; } async init() { this.createModal(); this.setupEventListeners(); this.setupKeyboardNavigation(); Logger.info('[ImageModal] Image modal initialized'); } createModal() { // Create modal container this.modal = document.createElement('div'); this.modal.className = 'image-modal'; this.modal.setAttribute('role', 'dialog'); this.modal.setAttribute('aria-modal', 'true'); this.modal.setAttribute('aria-labelledby', 'modal-title'); this.modal.innerHTML = `

Alt Text

${this.createVariantsSection()}
`; // Append to body document.body.appendChild(this.modal); } createVariantsSection() { return `

Available Variants

`; } setupEventListeners() { // Close modal events const closeBtn = this.modal.querySelector('[data-action="close"]'); const backdrop = this.modal.querySelector('.image-modal__backdrop'); closeBtn.addEventListener('click', () => this.close()); backdrop.addEventListener('click', () => this.close()); // Action buttons this.modal.querySelector('[data-action="download"]').addEventListener('click', () => { this.downloadImage(); }); this.modal.querySelector('[data-action="edit"]').addEventListener('click', () => { this.editImage(); }); this.modal.querySelector('[data-action="delete"]').addEventListener('click', () => { this.deleteImage(); }); this.modal.querySelector('[data-action="save-alt"]').addEventListener('click', () => { this.saveAltText(); }); // Image load events const image = this.modal.querySelector('.image-modal__image'); image.addEventListener('load', () => { this.hideLoading(); }); image.addEventListener('error', () => { this.hideLoading(); this.showImageError(); }); // Listen for image view events from gallery document.addEventListener('image:view', (e) => { this.open(e.detail); }); } setupKeyboardNavigation() { this.keydownHandler = (e) => { if (!this.isOpen) return; switch (e.key) { case 'Escape': e.preventDefault(); this.close(); break; case 'Enter': if (e.target.classList.contains('alt-text-input')) { e.preventDefault(); this.saveAltText(); } break; } }; document.addEventListener('keydown', this.keydownHandler); } open(image) { if (this.isOpen) { this.close(); } this.currentImage = image; this.isOpen = true; // Update modal content this.updateModalContent(image); // Show modal this.modal.classList.add('image-modal--open'); document.body.classList.add('modal-open'); // Focus management this.modal.querySelector('.modal-close').focus(); // Trap focus this.trapFocus(); this.emit('modal:open', image); Logger.info('[ImageModal] Opened modal for image:', image.filename); } close() { if (!this.isOpen) return; this.isOpen = false; this.currentImage = null; // Hide modal this.modal.classList.remove('image-modal--open'); document.body.classList.remove('modal-open'); // Clear image const image = this.modal.querySelector('.image-modal__image'); image.src = ''; this.emit('modal:close'); Logger.info('[ImageModal] Modal closed'); } updateModalContent(image) { // Update title const title = this.modal.querySelector('.image-modal__title'); title.textContent = image.filename; // Update image this.showLoading(); const modalImage = this.modal.querySelector('.image-modal__image'); modalImage.src = image.url; modalImage.alt = image.alt_text || image.filename; // Update metadata this.updateMetadata(image); // Update alt text const altTextInput = this.modal.querySelector('.alt-text-input'); altTextInput.value = image.alt_text || ''; // Update variants this.updateVariants(image); } updateMetadata(image) { const metadata = { filename: image.filename, dimensions: `${image.dimensions.width} × ${image.dimensions.height}`, fileSize: image.file_size.human_readable, format: image.mime_type.split('/')[1].toUpperCase(), orientation: image.dimensions.orientation, aspectRatio: image.dimensions.aspect_ratio.toFixed(2), created: new Date(image.created_at).toLocaleDateString(), hash: image.hash }; for (const [field, value] of Object.entries(metadata)) { const element = this.modal.querySelector(`[data-field="${field}"]`); if (element) { element.textContent = value; // Special handling for hash (make it copyable) if (field === 'hash') { element.title = 'Click to copy'; element.style.cursor = 'pointer'; element.onclick = () => { navigator.clipboard.writeText(value).then(() => { element.textContent = 'Copied!'; setTimeout(() => { element.textContent = value; }, 1000); }); }; } } } } updateVariants(image) { const variantsList = this.modal.querySelector('.variants-list'); if (!image.variants || image.variants.length === 0) { variantsList.innerHTML = '

No variants available

'; return; } variantsList.innerHTML = image.variants.map(variant => `
${variant.type} ${variant.width}×${variant.height}
`).join(''); } showLoading() { const loading = this.modal.querySelector('.image-modal__loading'); const image = this.modal.querySelector('.image-modal__image'); loading.style.display = 'flex'; image.style.display = 'none'; } hideLoading() { const loading = this.modal.querySelector('.image-modal__loading'); const image = this.modal.querySelector('.image-modal__image'); loading.style.display = 'none'; image.style.display = 'block'; } showImageError() { const container = this.modal.querySelector('.image-modal__image-container'); container.innerHTML = `
⚠️

Failed to load image

The image could not be loaded. It may have been deleted or moved.

`; } async downloadImage() { if (!this.currentImage) return; try { const response = await fetch(this.currentImage.url); const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = this.currentImage.filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); window.URL.revokeObjectURL(url); this.emit('image:download', this.currentImage); } catch (error) { Logger.error('[ImageModal] Download failed:', error); alert('Failed to download image: ' + error.message); } } editImage() { if (!this.currentImage) return; this.emit('image:edit', this.currentImage); this.close(); } async deleteImage() { if (!this.currentImage) return; if (!confirm(`Delete "${this.currentImage.filename}"?`)) { return; } try { const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || document.querySelector('input[name="_token"]')?.value; const response = await fetch(`${this.config.uploadEndpoint}/${this.currentImage.ulid}`, { method: 'DELETE', headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-TOKEN': csrfToken || '' } }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.message || `HTTP ${response.status}`); } this.emit('image:delete', this.currentImage); this.close(); } catch (error) { Logger.error('[ImageModal] Delete failed:', error); alert('Failed to delete image: ' + error.message); } } async saveAltText() { if (!this.currentImage) return; const altTextInput = this.modal.querySelector('.alt-text-input'); const newAltText = altTextInput.value.trim(); if (newAltText === this.currentImage.alt_text) { return; // No change } try { const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || document.querySelector('input[name="_token"]')?.value; const response = await fetch(`${this.config.uploadEndpoint}/${this.currentImage.ulid}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-TOKEN': csrfToken || '' }, body: JSON.stringify({ alt_text: newAltText }) }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.message || `HTTP ${response.status}`); } // Update current image data this.currentImage.alt_text = newAltText; // Update the modal image alt text const modalImage = this.modal.querySelector('.image-modal__image'); modalImage.alt = newAltText || this.currentImage.filename; // Show success feedback const saveBtn = this.modal.querySelector('[data-action="save-alt"]'); const originalText = saveBtn.textContent; saveBtn.textContent = 'Saved!'; saveBtn.classList.add('modal-btn--success'); setTimeout(() => { saveBtn.textContent = originalText; saveBtn.classList.remove('modal-btn--success'); }, 1500); this.emit('image:update', this.currentImage); } catch (error) { Logger.error('[ImageModal] Save alt text failed:', error); alert('Failed to save alt text: ' + error.message); } } trapFocus() { const focusableElements = this.modal.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1]; const handleTabKey = (e) => { if (e.key === 'Tab') { if (e.shiftKey) { if (document.activeElement === firstElement) { e.preventDefault(); lastElement.focus(); } } else { if (document.activeElement === lastElement) { e.preventDefault(); firstElement.focus(); } } } }; this.modal.addEventListener('keydown', handleTabKey); } destroy() { if (this.isOpen) { this.close(); } if (this.keydownHandler) { document.removeEventListener('keydown', this.keydownHandler); } if (this.modal && this.modal.parentNode) { this.modal.parentNode.removeChild(this.modal); } this.removeAllListeners(); } }