Files
michaelschiemer/resources/js/modules/image-manager/ImageModal.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

519 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = `
<div class="image-modal__backdrop"></div>
<div class="image-modal__container">
<div class="image-modal__header">
<h2 id="modal-title" class="image-modal__title"></h2>
<div class="image-modal__actions">
<button type="button" class="modal-btn modal-btn--secondary" data-action="download" title="Download">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7,10 12,15 17,10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
</button>
<button type="button" class="modal-btn modal-btn--secondary" data-action="edit" title="Edit">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
<button type="button" class="modal-btn modal-btn--danger" data-action="delete" title="Delete">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3,6 5,6 21,6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
<button type="button" class="modal-btn modal-btn--ghost modal-close" data-action="close" title="Close (Esc)">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</div>
<div class="image-modal__content">
<div class="image-modal__image-container">
<img class="image-modal__image" alt="" draggable="false">
<div class="image-modal__loading">
<div class="loading-spinner"></div>
</div>
</div>
<div class="image-modal__sidebar">
<div class="image-modal__metadata">
<h3>Image Information</h3>
<div class="metadata-grid">
<div class="metadata-item">
<label>Filename:</label>
<span class="metadata-value" data-field="filename"></span>
</div>
<div class="metadata-item">
<label>Dimensions:</label>
<span class="metadata-value" data-field="dimensions"></span>
</div>
<div class="metadata-item">
<label>File Size:</label>
<span class="metadata-value" data-field="fileSize"></span>
</div>
<div class="metadata-item">
<label>Format:</label>
<span class="metadata-value" data-field="format"></span>
</div>
<div class="metadata-item">
<label>Orientation:</label>
<span class="metadata-value" data-field="orientation"></span>
</div>
<div class="metadata-item">
<label>Aspect Ratio:</label>
<span class="metadata-value" data-field="aspectRatio"></span>
</div>
<div class="metadata-item">
<label>Created:</label>
<span class="metadata-value" data-field="created"></span>
</div>
<div class="metadata-item">
<label>Hash:</label>
<span class="metadata-value metadata-value--hash" data-field="hash"></span>
</div>
</div>
</div>
<div class="image-modal__alt-text">
<h3>Alt Text</h3>
<div class="alt-text-editor">
<textarea class="alt-text-input" placeholder="Add alt text for accessibility..."></textarea>
<div class="alt-text-actions">
<button type="button" class="modal-btn modal-btn--small modal-btn--primary" data-action="save-alt">
Save Alt Text
</button>
</div>
</div>
</div>
${this.createVariantsSection()}
</div>
</div>
</div>
`;
// Append to body
document.body.appendChild(this.modal);
}
createVariantsSection() {
return `
<div class="image-modal__variants">
<h3>Available Variants</h3>
<div class="variants-list">
<!-- Variants will be populated dynamically -->
</div>
</div>
`;
}
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 = '<p class="no-variants">No variants available</p>';
return;
}
variantsList.innerHTML = image.variants.map(variant => `
<div class="variant-item">
<div class="variant-info">
<strong>${variant.type}</strong>
<span class="variant-dimensions">${variant.width}×${variant.height}</span>
</div>
<a href="${variant.url}" class="variant-link" target="_blank" title="View variant">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15,3 21,3 21,9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
</a>
</div>
`).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 = `
<div class="image-error">
<div class="image-error__icon">⚠️</div>
<h3>Failed to load image</h3>
<p>The image could not be loaded. It may have been deleted or moved.</p>
<button type="button" class="modal-btn modal-btn--primary" onclick="location.reload()">
Refresh Page
</button>
</div>
`;
}
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();
}
}