- 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
446 lines
16 KiB
JavaScript
446 lines
16 KiB
JavaScript
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">×</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()">×</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; |