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,75 @@
export class EventEmitter {
constructor() {
this.events = new Map();
}
on(event, listener) {
if (!this.events.has(event)) {
this.events.set(event, []);
}
this.events.get(event).push(listener);
return this; // Allow chaining
}
off(event, listener) {
if (!this.events.has(event)) {
return this;
}
const listeners = this.events.get(event);
const index = listeners.indexOf(listener);
if (index > -1) {
listeners.splice(index, 1);
}
// Remove event key if no listeners remain
if (listeners.length === 0) {
this.events.delete(event);
}
return this;
}
emit(event, ...args) {
if (!this.events.has(event)) {
return false;
}
const listeners = this.events.get(event);
for (const listener of listeners) {
try {
listener.apply(this, args);
} catch (error) {
console.error('Error in event listener:', error);
}
}
return true;
}
once(event, listener) {
const onceWrapper = (...args) => {
this.off(event, onceWrapper);
listener.apply(this, args);
};
return this.on(event, onceWrapper);
}
removeAllListeners(event) {
if (event) {
this.events.delete(event);
} else {
this.events.clear();
}
return this;
}
listenerCount(event) {
return this.events.has(event) ? this.events.get(event).length : 0;
}
eventNames() {
return Array.from(this.events.keys());
}
}

View File

@@ -0,0 +1,542 @@
import { Logger } from '../../core/logger.js';
import { EventEmitter } from './EventEmitter.js';
export class ImageGallery extends EventEmitter {
constructor(config, state) {
super();
this.config = config;
this.state = state;
this.galleries = new Map();
this.currentImages = new Map();
this.currentPage = 1;
this.hasMore = true;
this.isLoading = false;
this.searchTerm = '';
this.sortField = 'ulid';
this.sortDirection = 'desc';
}
async init() {
const elements = document.querySelectorAll('[data-image-gallery]');
for (const element of elements) {
await this.initializeGallery(element);
}
Logger.info(`[ImageGallery] Initialized ${elements.length} galleries`);
}
async initializeGallery(element) {
const config = this.parseElementConfig(element);
const gallery = new Gallery(element, config, this);
this.galleries.set(element, gallery);
await gallery.init();
}
parseElementConfig(element) {
return {
pageSize: parseInt(element.dataset.pageSize) || 20,
columns: parseInt(element.dataset.columns) || 4,
showPagination: element.hasAttribute('data-pagination'),
showSearch: element.hasAttribute('data-search'),
showSort: element.hasAttribute('data-sort'),
allowDelete: element.hasAttribute('data-allow-delete'),
allowEdit: element.hasAttribute('data-allow-edit'),
selectable: element.hasAttribute('data-selectable'),
...this.config
};
}
async loadImages(page = 1, search = '', sort = 'ulid', direction = 'desc') {
if (this.isLoading) return;
this.isLoading = true;
this.emit('gallery:loading', true);
try {
const params = new URLSearchParams({
page: page.toString(),
limit: this.config.pageSize?.toString() || '20',
sort: sort,
direction: direction
});
if (search) {
params.append('search', search);
}
const response = await fetch(`${this.config.listEndpoint}?${params}`, {
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
this.currentPage = page;
this.hasMore = data.meta.has_more;
this.searchTerm = search;
this.sortField = sort;
this.sortDirection = direction;
// Store images for this gallery instance
const galleryKey = `${search}_${sort}_${direction}`;
if (page === 1) {
this.currentImages.set(galleryKey, data.data);
} else {
const existing = this.currentImages.get(galleryKey) || [];
this.currentImages.set(galleryKey, [...existing, ...data.data]);
}
this.emit('gallery:loaded', {
images: this.currentImages.get(galleryKey),
meta: data.meta,
page,
hasMore: this.hasMore
});
return data;
} catch (error) {
Logger.error('[ImageGallery] Failed to load images:', error);
this.emit('gallery:error', error);
throw error;
} finally {
this.isLoading = false;
this.emit('gallery:loading', false);
}
}
async deleteImage(imageId) {
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ||
document.querySelector('input[name="_token"]')?.value;
const response = await fetch(`${this.config.uploadEndpoint}/${imageId}`, {
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}`);
}
// Remove from current images
for (const [key, images] of this.currentImages) {
const filtered = images.filter(img => img.ulid !== imageId);
this.currentImages.set(key, filtered);
}
this.emit('image:delete', { imageId });
return true;
} catch (error) {
Logger.error('[ImageGallery] Failed to delete image:', error);
this.emit('gallery:error', error);
throw error;
}
}
async refresh() {
await this.loadImages(1, this.searchTerm, this.sortField, this.sortDirection);
}
destroy() {
for (const [element, gallery] of this.galleries) {
gallery.destroy();
}
this.galleries.clear();
this.currentImages.clear();
this.removeAllListeners();
}
}
class Gallery {
constructor(element, config, manager) {
this.element = element;
this.config = config;
this.manager = manager;
this.gridContainer = null;
this.searchInput = null;
this.sortSelect = null;
this.loadMoreButton = null;
this.currentImages = [];
this.selectedImages = new Set();
}
async init() {
this.createHTML();
this.setupEventListeners();
await this.loadInitialImages();
}
createHTML() {
this.element.classList.add('image-gallery');
this.element.innerHTML = `
${this.config.showSearch ? this.createSearchHTML() : ''}
${this.config.showSort ? this.createSortHTML() : ''}
<div class="gallery__grid" style="--columns: ${this.config.columns}"></div>
${this.config.showPagination ? this.createPaginationHTML() : ''}
<div class="gallery__loading" style="display: none;">
<div class="loading-spinner"></div>
<p>Loading images...</p>
</div>
<div class="gallery__empty" style="display: none;">
<div class="empty-state__icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<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>
<h3>No images found</h3>
<p>Upload some images to get started!</p>
</div>
`;
this.gridContainer = this.element.querySelector('.gallery__grid');
this.searchInput = this.element.querySelector('.gallery__search');
this.sortSelect = this.element.querySelector('.gallery__sort');
this.loadMoreButton = this.element.querySelector('.gallery__load-more');
}
createSearchHTML() {
return `
<div class="gallery__controls">
<div class="gallery__search-wrapper">
<input type="text" class="gallery__search" placeholder="Search images...">
<button type="button" class="gallery__search-clear" style="display: none;">&times;</button>
</div>
</div>
`;
}
createSortHTML() {
return `
<div class="gallery__sort-wrapper">
<label for="gallery-sort">Sort by:</label>
<select class="gallery__sort" id="gallery-sort">
<option value="ulid:desc">Newest first</option>
<option value="ulid:asc">Oldest first</option>
<option value="filename:asc">Name A-Z</option>
<option value="filename:desc">Name Z-A</option>
<option value="fileSize:desc">Largest first</option>
<option value="fileSize:asc">Smallest first</option>
</select>
</div>
`;
}
createPaginationHTML() {
return `
<div class="gallery__pagination">
<button type="button" class="gallery__load-more" style="display: none;">
Load More Images
</button>
</div>
`;
}
setupEventListeners() {
// Search functionality
if (this.searchInput) {
let searchTimeout;
this.searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
this.handleSearch(e.target.value);
}, 300);
});
const clearButton = this.element.querySelector('.gallery__search-clear');
if (clearButton) {
clearButton.addEventListener('click', () => {
this.searchInput.value = '';
this.handleSearch('');
});
this.searchInput.addEventListener('input', (e) => {
clearButton.style.display = e.target.value ? 'block' : 'none';
});
}
}
// Sort functionality
if (this.sortSelect) {
this.sortSelect.addEventListener('change', (e) => {
const [field, direction] = e.target.value.split(':');
this.handleSort(field, direction);
});
}
// Load more functionality
if (this.loadMoreButton) {
this.loadMoreButton.addEventListener('click', () => {
this.loadMore();
});
}
// Manager event listeners
this.manager.on('gallery:loaded', (data) => {
this.renderImages(data.images);
this.updatePagination(data.hasMore);
});
this.manager.on('gallery:loading', (isLoading) => {
this.toggleLoading(isLoading);
});
this.manager.on('gallery:error', (error) => {
this.showError(error.message);
});
// Intersection Observer for infinite scroll
if (this.config.showPagination) {
this.setupInfiniteScroll();
}
}
setupInfiniteScroll() {
const observer = new IntersectionObserver((entries) => {
const [entry] = entries;
if (entry.isIntersecting && this.manager.hasMore && !this.manager.isLoading) {
this.loadMore();
}
}, {
rootMargin: '100px'
});
// Observe the load more button
if (this.loadMoreButton) {
observer.observe(this.loadMoreButton);
}
}
async loadInitialImages() {
await this.manager.loadImages(1);
}
async handleSearch(term) {
await this.manager.loadImages(1, term, this.manager.sortField, this.manager.sortDirection);
}
async handleSort(field, direction) {
await this.manager.loadImages(1, this.manager.searchTerm, field, direction);
}
async loadMore() {
if (this.manager.hasMore && !this.manager.isLoading) {
await this.manager.loadImages(
this.manager.currentPage + 1,
this.manager.searchTerm,
this.manager.sortField,
this.manager.sortDirection
);
}
}
renderImages(images) {
this.currentImages = images;
if (images.length === 0) {
this.showEmptyState();
return;
}
this.hideEmptyState();
this.gridContainer.innerHTML = '';
images.forEach(image => {
const imageElement = this.createImageElement(image);
this.gridContainer.appendChild(imageElement);
});
}
createImageElement(image) {
const imageItem = document.createElement('div');
imageItem.className = 'gallery__item';
imageItem.dataset.imageId = image.ulid;
const aspectRatio = image.dimensions.width / image.dimensions.height;
const gridSpan = aspectRatio > 1.5 ? 2 : 1; // Wide images span 2 columns
imageItem.style.gridColumn = `span ${Math.min(gridSpan, this.config.columns)}`;
imageItem.innerHTML = `
<div class="gallery__item-inner">
<div class="gallery__item-image">
<img src="${image.thumbnail_url}" alt="${image.alt_text || image.filename}"
loading="lazy" draggable="false">
<div class="gallery__item-overlay">
<div class="gallery__item-actions">
${this.config.selectable ? '<button class="action-btn select-btn" title="Select">✓</button>' : ''}
<button class="action-btn view-btn" title="View">👁</button>
${this.config.allowEdit ? '<button class="action-btn edit-btn" title="Edit">✏️</button>' : ''}
${this.config.allowDelete ? '<button class="action-btn delete-btn" title="Delete">🗑</button>' : ''}
</div>
</div>
</div>
<div class="gallery__item-info">
<div class="gallery__item-name" title="${image.filename}">${image.filename}</div>
<div class="gallery__item-meta">
<span class="meta-item">${image.dimensions.width}×${image.dimensions.height}</span>
<span class="meta-item">${image.file_size.human_readable}</span>
<span class="meta-item">${image.mime_type.split('/')[1].toUpperCase()}</span>
</div>
</div>
</div>
`;
this.setupImageEventListeners(imageItem, image);
return imageItem;
}
setupImageEventListeners(imageItem, image) {
// View button
const viewBtn = imageItem.querySelector('.view-btn');
if (viewBtn) {
viewBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.manager.emit('image:view', image);
});
}
// Edit button
const editBtn = imageItem.querySelector('.edit-btn');
if (editBtn) {
editBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.manager.emit('image:edit', image);
});
}
// Delete button
const deleteBtn = imageItem.querySelector('.delete-btn');
if (deleteBtn) {
deleteBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (confirm(`Delete "${image.filename}"?`)) {
try {
await this.manager.deleteImage(image.ulid);
imageItem.remove();
} catch (error) {
alert('Failed to delete image: ' + error.message);
}
}
});
}
// Select button
const selectBtn = imageItem.querySelector('.select-btn');
if (selectBtn) {
selectBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleSelection(imageItem, image);
});
}
// Double-click to view
imageItem.addEventListener('dblclick', () => {
this.manager.emit('image:view', image);
});
}
toggleSelection(imageItem, image) {
if (this.selectedImages.has(image.ulid)) {
this.selectedImages.delete(image.ulid);
imageItem.classList.remove('gallery__item--selected');
} else {
this.selectedImages.add(image.ulid);
imageItem.classList.add('gallery__item--selected');
}
this.manager.emit('image:select', {
image,
selected: this.selectedImages.has(image.ulid),
selectedCount: this.selectedImages.size
});
}
updatePagination(hasMore) {
if (this.loadMoreButton) {
this.loadMoreButton.style.display = hasMore ? 'block' : 'none';
}
}
toggleLoading(isLoading) {
const loadingElement = this.element.querySelector('.gallery__loading');
if (loadingElement) {
loadingElement.style.display = isLoading ? 'block' : 'none';
}
if (this.loadMoreButton) {
this.loadMoreButton.disabled = isLoading;
this.loadMoreButton.textContent = isLoading ? 'Loading...' : 'Load More Images';
}
}
showEmptyState() {
const emptyElement = this.element.querySelector('.gallery__empty');
if (emptyElement) {
emptyElement.style.display = 'block';
}
this.gridContainer.style.display = 'none';
}
hideEmptyState() {
const emptyElement = this.element.querySelector('.gallery__empty');
if (emptyElement) {
emptyElement.style.display = 'none';
}
this.gridContainer.style.display = 'grid';
}
showError(message) {
// Create or update error message
let errorElement = this.element.querySelector('.gallery__error');
if (!errorElement) {
errorElement = document.createElement('div');
errorElement.className = 'gallery__error';
this.element.appendChild(errorElement);
}
errorElement.innerHTML = `
<div class="error-message">
<span class="error-icon">⚠️</span>
<span class="error-text">${message}</span>
<button class="error-close">&times;</button>
</div>
`;
// Auto-remove after 5 seconds
setTimeout(() => {
if (errorElement.parentNode) {
errorElement.parentNode.removeChild(errorElement);
}
}, 5000);
// Close button
errorElement.querySelector('.error-close').addEventListener('click', () => {
if (errorElement.parentNode) {
errorElement.parentNode.removeChild(errorElement);
}
});
}
destroy() {
this.element.innerHTML = '';
this.element.classList.remove('image-gallery');
}
}

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

View File

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

View File

@@ -0,0 +1,446 @@
import { Logger } from '../../core/logger.js';
export const definition = {
name: 'image-manager',
version: '1.0.0',
dependencies: [],
provides: ['image-upload', 'image-gallery', 'image-modal'],
priority: 5
};
const ImageManagerModule = {
name: 'image-manager',
activeGalleries: new Map(),
activeUploaders: new Map(),
async init(config = {}, state) {
Logger.info('[ImageManager] Module initialized');
// Initialize all image gallery elements
this.initializeGalleries();
// Initialize all image uploader elements
this.initializeUploaders();
// Set up modal functionality
this.initializeModal();
return this;
},
initializeGalleries() {
const galleryElements = document.querySelectorAll('[data-image-gallery]');
Logger.info(`[ImageManager] Found ${galleryElements.length} gallery elements`);
galleryElements.forEach((element, index) => {
const galleryId = `gallery-${index}`;
const gallery = new ImageGallery(element, {
listEndpoint: element.dataset.listEndpoint || '/api/images',
pageSize: parseInt(element.dataset.pageSize) || 20,
columns: parseInt(element.dataset.columns) || 4
});
this.activeGalleries.set(galleryId, gallery);
Logger.info(`[ImageManager] Initialized gallery: ${galleryId}`);
});
},
initializeUploaders() {
const uploaderElements = document.querySelectorAll('[data-image-uploader]');
Logger.info(`[ImageManager] Found ${uploaderElements.length} uploader elements`);
uploaderElements.forEach((element, index) => {
const uploaderId = `uploader-${index}`;
const uploader = new ImageUploader(element, {
uploadUrl: element.dataset.uploadUrl || '/api/images',
maxFileSize: parseInt(element.dataset.maxFileSize) || 10485760,
allowedTypes: element.dataset.allowedTypes?.split(',') || ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
maxFiles: parseInt(element.dataset.maxFiles) || 10
});
this.activeUploaders.set(uploaderId, uploader);
Logger.info(`[ImageManager] Initialized uploader: ${uploaderId}`);
});
},
initializeModal() {
const modal = new ImageModal();
window.ImageModal = modal;
Logger.info('[ImageManager] Modal initialized');
},
destroy() {
// Destroy all galleries
this.activeGalleries.forEach((gallery, id) => {
gallery.destroy();
Logger.info(`[ImageManager] Destroyed gallery: ${id}`);
});
this.activeGalleries.clear();
// Destroy all uploaders
this.activeUploaders.forEach((uploader, id) => {
uploader.destroy();
Logger.info(`[ImageManager] Destroyed uploader: ${id}`);
});
this.activeUploaders.clear();
// Clean up modal
if (window.ImageModal) {
window.ImageModal.destroy();
delete window.ImageModal;
}
Logger.info('[ImageManager] Module destroyed');
}
};
// Image Gallery Implementation
class ImageGallery {
constructor(element, config) {
this.element = element;
this.config = config;
this.currentPage = 1;
this.images = [];
this.loading = false;
this.init();
}
async init() {
Logger.info('[ImageGallery] Initializing gallery');
// Clear loading state and build UI
this.element.innerHTML = '';
this.buildGalleryUI();
// Load images
await this.loadImages();
}
buildGalleryUI() {
this.element.innerHTML = `
<div class="image-gallery">
<div class="gallery__controls">
<div class="gallery__search-wrapper">
<input type="text" class="gallery__search" placeholder="Search images...">
<button type="button" class="gallery__search-clear">&times;</button>
</div>
<div class="gallery__sort-wrapper">
<label>Sort:</label>
<select class="gallery__sort">
<option value="created_desc">Newest First</option>
<option value="created_asc">Oldest First</option>
<option value="name_asc">Name A-Z</option>
<option value="name_desc">Name Z-A</option>
</select>
</div>
</div>
<div class="gallery__grid" style="--columns: ${this.config.columns}"></div>
<div class="gallery__pagination">
<button type="button" class="gallery__load-more" style="display: none;">Load More</button>
</div>
<div class="gallery__loading">
<div class="loading-spinner"></div>
<p>Loading images...</p>
</div>
</div>
`;
// Add event listeners
this.setupEventListeners();
}
setupEventListeners() {
const searchInput = this.element.querySelector('.gallery__search');
const searchClear = this.element.querySelector('.gallery__search-clear');
const sortSelect = this.element.querySelector('.gallery__sort');
const loadMoreBtn = this.element.querySelector('.gallery__load-more');
// Search functionality
let searchTimeout;
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
this.searchImages(e.target.value);
}, 300);
});
searchClear.addEventListener('click', () => {
searchInput.value = '';
this.searchImages('');
});
// Sort functionality
sortSelect.addEventListener('change', (e) => {
this.sortImages(e.target.value);
});
// Load more functionality
loadMoreBtn.addEventListener('click', () => {
this.loadMoreImages();
});
}
async loadImages() {
if (this.loading) return;
this.loading = true;
this.showLoading();
try {
Logger.info('[ImageGallery] Loading images from:', this.config.listEndpoint);
const response = await fetch(`${this.config.listEndpoint}?page=${this.currentPage}&limit=${this.config.pageSize}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const response_data = await response.json();
Logger.info('[ImageGallery] API Response:', response_data);
// Handle different API response formats
let images = [];
let metaInfo = {};
if (response_data.data && Array.isArray(response_data.data)) {
// Format: {data: [...], meta: {...}}
images = response_data.data;
metaInfo = response_data.meta || {};
} else if (response_data.images && Array.isArray(response_data.images)) {
// Format: {images: [...]}
images = response_data.images;
metaInfo = response_data;
} else if (Array.isArray(response_data)) {
// Format: [...]
images = response_data;
metaInfo = {};
} else {
Logger.warn('[ImageGallery] Unexpected API response format:', response_data);
images = [];
metaInfo = {};
}
Logger.info('[ImageGallery] Processed images:', images.length);
if (this.currentPage === 1) {
this.images = images;
} else {
this.images = [...this.images, ...images];
}
this.renderImages();
this.updateLoadMoreButton(metaInfo);
} catch (error) {
Logger.error('[ImageGallery] Failed to load images:', error);
this.showError(`Failed to load images: ${error.message}`);
} finally {
this.loading = false;
this.hideLoading();
}
}
renderImages() {
const grid = this.element.querySelector('.gallery__grid');
if (this.images.length === 0) {
grid.innerHTML = `
<div class="gallery__empty">
<div class="empty-state__icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21,15 16,10 5,21"></polyline>
</svg>
</div>
<h3>No images found</h3>
<p>Upload some images to get started</p>
</div>
`;
return;
}
grid.innerHTML = this.images.map(image => this.renderImageItem(image)).join('');
// Add click handlers to gallery items
grid.querySelectorAll('.gallery__item').forEach((item, index) => {
item.addEventListener('click', () => {
this.showImageModal(this.images[index]);
});
});
}
renderImageItem(image) {
// Handle different API response formats for file size and dimensions
let fileSize = '0 B';
let dimensions = '0 × 0';
if (image.file_size) {
if (typeof image.file_size === 'object' && image.file_size.bytes) {
fileSize = this.formatFileSize(image.file_size.bytes);
} else if (typeof image.file_size === 'number') {
fileSize = this.formatFileSize(image.file_size);
}
}
if (image.dimensions && image.dimensions.width && image.dimensions.height) {
dimensions = `${image.dimensions.width} × ${image.dimensions.height}`;
} else if (image.width && image.height) {
dimensions = `${image.width} × ${image.height}`;
}
return `
<div class="gallery__item" data-image-id="${image.ulid}">
<div class="gallery__item-inner">
<div class="gallery__item-image">
<img src="${image.url}" alt="${image.alt_text || image.filename}" loading="lazy">
<div class="gallery__item-overlay">
<div class="gallery__item-actions">
<button type="button" class="action-btn" title="View Details">👁</button>
<button type="button" class="action-btn" title="Edit">✏️</button>
<button type="button" class="action-btn" title="Delete">🗑</button>
</div>
</div>
</div>
<div class="gallery__item-info">
<div class="gallery__item-name" title="${image.original_filename || image.filename}">
${image.original_filename || image.filename}
</div>
<div class="gallery__item-meta">
<span class="meta-item">${dimensions}</span>
<span class="meta-item">${fileSize}</span>
<span class="meta-item">${(image.mime_type || '').replace('image/', '')}</span>
</div>
</div>
</div>
</div>
`;
}
showImageModal(image) {
if (window.ImageModal) {
window.ImageModal.show(image);
}
}
formatFileSize(bytes) {
const sizes = ['B', 'KB', 'MB', 'GB'];
if (bytes === 0) return '0 B';
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${Math.round(bytes / Math.pow(1024, i) * 100) / 100} ${sizes[i]}`;
}
showLoading() {
const loading = this.element.querySelector('.gallery__loading');
if (loading) loading.style.display = 'flex';
}
hideLoading() {
const loading = this.element.querySelector('.gallery__loading');
if (loading) loading.style.display = 'none';
}
showError(message) {
const grid = this.element.querySelector('.gallery__grid');
grid.innerHTML = `
<div class="gallery__error">
<div class="error-message">
<span>⚠️</span>
<span>${message}</span>
<button type="button" class="error-close" onclick="this.parentElement.parentElement.remove()">&times;</button>
</div>
</div>
`;
}
updateLoadMoreButton(metaInfo) {
const loadMoreBtn = this.element.querySelector('.gallery__load-more');
const hasMore = metaInfo.has_more || metaInfo.hasMore || metaInfo.pagination?.hasMore || false;
if (hasMore) {
loadMoreBtn.style.display = 'block';
loadMoreBtn.disabled = false;
} else {
loadMoreBtn.style.display = 'none';
}
}
async loadMoreImages() {
this.currentPage++;
await this.loadImages();
}
searchImages(query) {
// Simple client-side search for now
// TODO: Implement server-side search
const filteredImages = this.images.filter(image =>
(image.filename || '').toLowerCase().includes(query.toLowerCase()) ||
(image.original_filename || '').toLowerCase().includes(query.toLowerCase()) ||
(image.alt_text || '').toLowerCase().includes(query.toLowerCase())
);
const grid = this.element.querySelector('.gallery__grid');
grid.innerHTML = filteredImages.map(image => this.renderImageItem(image)).join('');
}
sortImages(sortType) {
const sortedImages = [...this.images];
switch (sortType) {
case 'created_desc':
sortedImages.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
break;
case 'created_asc':
sortedImages.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
break;
case 'name_asc':
sortedImages.sort((a, b) => (a.filename || '').localeCompare(b.filename || ''));
break;
case 'name_desc':
sortedImages.sort((a, b) => (b.filename || '').localeCompare(a.filename || ''));
break;
}
this.images = sortedImages;
this.renderImages();
}
destroy() {
// Clean up event listeners and DOM
this.element.innerHTML = '';
Logger.info('[ImageGallery] Gallery destroyed');
}
}
// Placeholder classes for uploader and modal
class ImageUploader {
constructor(element, config) {
this.element = element;
this.config = config;
Logger.info('[ImageUploader] Uploader placeholder initialized');
}
destroy() {
Logger.info('[ImageUploader] Uploader destroyed');
}
}
class ImageModal {
constructor() {
Logger.info('[ImageModal] Modal placeholder initialized');
}
show(image) {
Logger.info('[ImageModal] Would show modal for image:', image);
}
destroy() {
Logger.info('[ImageModal] Modal destroyed');
}
}
// Export for module system - following exact pattern from working modules
export const init = ImageManagerModule.init.bind(ImageManagerModule);
export const destroy = ImageManagerModule.destroy.bind(ImageManagerModule);
export default ImageManagerModule;