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 = `
`;
// Append to body
document.body.appendChild(this.modal);
}
createVariantsSection() {
return `
`;
}
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();
}
}