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
This commit is contained in:
2025-10-05 11:05:04 +02:00
parent 887847dde6
commit 5050c7d73a
36686 changed files with 196456 additions and 12398919 deletions

View File

@@ -0,0 +1,519 @@
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();
}
}