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() : ''}
${this.config.showPagination ? this.createPaginationHTML() : ''}
No images found
Upload some images to get started!
`;
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 `
`;
}
createSortHTML() {
return `
`;
}
createPaginationHTML() {
return `
`;
}
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 = `
${this.config.selectable ? '' : ''}
${this.config.allowEdit ? '' : ''}
${this.config.allowDelete ? '' : ''}
${image.filename}
${image.dimensions.width}×${image.dimensions.height}
${image.file_size.human_readable}
${image.mime_type.split('/')[1].toUpperCase()}
`;
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 = `
⚠️
${message}
`;
// 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');
}
}