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

542 lines
18 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 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');
}
}