- 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
542 lines
18 KiB
JavaScript
542 lines
18 KiB
JavaScript
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;">×</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">×</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');
|
||
}
|
||
} |