/** * CollectionManager - Dynamic content collection management for .insertr-add elements * * Handles: * - Template detection from existing children * - Add/remove/reorder UI controls * - Collection data management * - Integration with existing .insertr editing system */ import { InsertrFormRenderer } from './form-renderer.js'; export class CollectionManager { constructor(meta, apiClient, auth) { this.meta = meta; this.container = meta.element; this.apiClient = apiClient; this.auth = auth; // Extract collection ID from container this.collectionId = this.container.getAttribute('data-collection-id'); if (!this.collectionId) { console.error('โŒ Collection container missing data-collection-id attribute'); return; } // Collection state this.template = null; this.items = []; this.isActive = false; this.cachedPreview = null; // Cache for collection preview data // UI elements this.addButton = null; this.itemControls = new Map(); // Map item element to its controls console.log('๐Ÿ”„ CollectionManager initialized for:', this.container, 'Collection ID:', this.collectionId); } /** * Initialize the collection manager */ async initialize() { if (this.isActive) return; console.log('๐Ÿš€ Starting collection management for:', this.container.className); // Analyze existing content to detect template this.analyzeTemplate(); // Sync with backend to map existing items to collection item IDs await this.syncWithBackend(); // Add collection management UI only when in edit mode this.setupEditModeDetection(); this.isActive = true; } /** * Set up detection for when edit mode is activated */ setupEditModeDetection() { // Check current auth state if (this.auth.isAuthenticated() && this.auth.isEditMode()) { this.activateCollectionUI(); } // Listen for auth state changes (assuming the auth object has events) // For now, we'll poll - in a real implementation we'd use events this.authCheckInterval = setInterval(() => { const shouldBeActive = this.auth.isAuthenticated() && this.auth.isEditMode(); if (shouldBeActive && !this.hasCollectionUI()) { this.activateCollectionUI(); } else if (!shouldBeActive && this.hasCollectionUI()) { this.deactivateCollectionUI(); } }, 1000); } /** * Check if collection UI is currently active */ hasCollectionUI() { return this.addButton && this.addButton.parentNode; } /** * Activate collection UI when in edit mode */ activateCollectionUI() { console.log('โœ… Activating collection UI'); // Add visual indicator to container this.container.classList.add('insertr-collection-active'); // Add the "+ Add" button (top right of container per spec) this.createAddButton(); // Add control buttons to each existing item this.addControlsToExistingItems(); } /** * Deactivate collection UI when not in edit mode */ deactivateCollectionUI() { console.log('โŒ Deactivating collection UI'); // Remove visual indicator this.container.classList.remove('insertr-collection-active'); // Remove add button if (this.addButton) { this.addButton.remove(); this.addButton = null; } // Remove all item controls this.itemControls.forEach((controls, item) => { controls.remove(); }); this.itemControls.clear(); } /** * Analyze existing children to detect template pattern */ analyzeTemplate() { const children = Array.from(this.container.children); if (children.length === 0) { console.warn('โš ๏ธ No children found for template analysis'); return; } // Use first child as template baseline const firstChild = children[0]; this.template = { structure: this.extractElementStructure(firstChild), editableFields: this.findEditableElements(firstChild), htmlTemplate: firstChild.outerHTML }; console.log('๐Ÿ“‹ Template detected:', this.template); // Store reference to current items // For existing items, try to extract collection item IDs if they exist this.items = children.map((child, index) => ({ element: child, index: index, id: this.generateItemId(index), collectionItemId: this.extractCollectionItemId(child) })); } /** * Extract the structural pattern of an element */ extractElementStructure(element) { return { tagName: element.tagName, classes: Array.from(element.classList), attributes: this.getRelevantAttributes(element), childStructure: this.analyzeChildStructure(element) }; } /** * Get relevant attributes (excluding data-content-id which will be unique) */ getRelevantAttributes(element) { const relevantAttrs = {}; for (const attr of element.attributes) { if (attr.name !== 'data-content-id') { relevantAttrs[attr.name] = attr.value; } } return relevantAttrs; } /** * Analyze child structure for template replication */ analyzeChildStructure(element) { return Array.from(element.children).map(child => ({ tagName: child.tagName, classes: Array.from(child.classList), hasInsertrClass: child.classList.contains('insertr'), content: child.classList.contains('insertr') ? '' : child.textContent })); } /** * Find editable elements within a container */ findEditableElements(container) { return Array.from(container.querySelectorAll('.insertr')).map(el => ({ selector: this.generateRelativeSelector(el, container), type: this.determineFieldType(el), placeholder: this.generatePlaceholder(el) })); } /** * Generate a relative selector for an element within a container */ generateRelativeSelector(element, container) { // Simple approach: use tag name and classes const tagName = element.tagName.toLowerCase(); const classes = Array.from(element.classList).join('.'); return classes ? `${tagName}.${classes}` : tagName; } /** * Determine the type of field for editing */ determineFieldType(element) { const tagName = element.tagName.toLowerCase(); if (tagName === 'a') return 'link'; if (tagName === 'img') return 'image'; return 'text'; } /** * Generate placeholder text for empty fields */ generatePlaceholder(element) { const tagName = element.tagName.toLowerCase(); if (tagName === 'h1' || tagName === 'h2') return 'Enter heading...'; if (tagName === 'blockquote') return 'Enter quote...'; if (tagName === 'cite') return 'Enter author...'; return 'Enter text...'; } /** * Generate unique ID for new items */ generateItemId(index) { return `item-${Date.now()}-${index}`; } /** * Extract collection item ID from existing DOM element * This is used for existing items that were reconstructed from database */ extractCollectionItemId(element) { // Look for data-item-id attribute first (from server enhancement) let itemId = element.getAttribute('data-item-id'); if (itemId) { return itemId; } // For existing items reconstructed from database, try to infer from data-content-id // The backend should have generated collection item IDs based on collection ID const contentId = element.getAttribute('data-content-id'); if (contentId && this.collectionId) { // This is a heuristic - we'll need to fetch the actual mapping from the backend // For now, return null and let the backend operations handle missing IDs return null; } return null; } /** * Sync frontend state with backend collection items * This maps existing DOM elements to their collection item IDs */ async syncWithBackend() { if (!this.collectionId) { console.warn('โš ๏ธ Cannot sync with backend: no collection ID'); return; } try { // Fetch current collection items from backend const backendItems = await this.apiClient.getCollectionItems(this.collectionId); console.log('๐Ÿ“‹ Backend collection items:', backendItems); // Items already have data-item-id from server enhancement // Just update the collectionItemId in our internal items array backendItems.forEach((backendItem, index) => { if (this.items[index]) { this.items[index].collectionItemId = backendItem.item_id; console.log(`๐Ÿ”— Mapped DOM element ${index} to collection item ${backendItem.item_id}`); } }); console.log('โœ… Frontend-backend sync completed'); } catch (error) { console.error('โŒ Failed to sync with backend:', error); // Continue without backend sync - collection management will still work for new items } } /** * Create the "+ Add" button positioned in top right of container */ createAddButton() { if (this.addButton) return; // Already exists this.addButton = document.createElement('button'); this.addButton.className = 'insertr-add-btn'; this.addButton.innerHTML = '+ Add Item'; this.addButton.title = 'Add new item to collection'; // Position in top right of container as per spec this.container.style.position = 'relative'; this.addButton.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.addNewItem(); }); this.container.appendChild(this.addButton); console.log('โž• Add button created'); } /** * Add control buttons to all existing items */ addControlsToExistingItems() { this.items.forEach((item, index) => { this.addItemControls(item.element, index); }); } /** * Add management controls to an item (remove, reorder) */ addItemControls(itemElement, index) { if (!this.itemControls || this.itemControls.has(itemElement)) return; // Already has controls or not initialized const controls = document.createElement('div'); controls.className = 'insertr-item-controls'; // Remove button (always present) const removeBtn = this.createControlButton('ร—', 'Remove item', () => this.removeItem(itemElement) ); // Move up button (if not first item) if (index > 0) { const upBtn = this.createControlButton('โ†‘', 'Move up', () => this.moveItem(itemElement, 'up') ); controls.appendChild(upBtn); } // Move down button (if not last item) if (index < this.items.length - 1) { const downBtn = this.createControlButton('โ†“', 'Move down', () => this.moveItem(itemElement, 'down') ); controls.appendChild(downBtn); } controls.appendChild(removeBtn); // Position in top right corner of item as per spec itemElement.style.position = 'relative'; itemElement.appendChild(controls); // Store reference this.itemControls.set(itemElement, controls); // Add hover behavior this.setupItemHoverBehavior(itemElement, controls); } /** * Create a control button */ createControlButton(text, title, onClick) { const button = document.createElement('button'); button.className = 'insertr-control-btn'; button.textContent = text; button.title = title; button.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); onClick(); }); return button; } /** * Set up hover behavior for item controls */ setupItemHoverBehavior(itemElement, controls) { itemElement.addEventListener('mouseenter', () => { controls.style.opacity = '1'; }); itemElement.addEventListener('mouseleave', () => { controls.style.opacity = '0'; }); } /** * Add a new item to the collection (backend-first approach) */ async addNewItem() { console.log('โž• Adding new item to collection'); if (!this.template || !this.collectionId) { console.error('โŒ No template or collection ID available for creating new items'); return; } try { // 1. Get collection preview data const previewData = await this.getCollectionPreview(); if (!previewData || !previewData.templates || previewData.templates.length === 0) { console.error('โŒ No templates available for collection:', this.collectionId); alert('No templates available for this collection. Please refresh the page.'); return; } // 2. Select template (auto-select if only one, otherwise show live preview) const selectedTemplate = await this.selectTemplate(previewData); if (!selectedTemplate) { console.log('Template selection cancelled by user'); return; } // 3. Create collection item in database first (backend-first approach) const collectionItem = await this.apiClient.createCollectionItem(this.collectionId, selectedTemplate.template_id); // 4. Create DOM element from the returned collection item data const newItem = this.createItemFromCollectionData(collectionItem); // 5. Add to DOM this.container.insertBefore(newItem, this.addButton); // 6. Update items array with backend data const newItemData = { element: newItem, index: this.items.length, id: collectionItem.item_id, collectionItem: collectionItem }; this.items.push(newItemData); // 7. Add controls to new item this.addItemControls(newItem, this.items.length - 1); // 8. Re-initialize any .insertr elements in the new item this.initializeInsertrElements(newItem); // 9. Update all item controls (indices may have changed) this.updateAllItemControls(); // 10. Trigger site enhancement to update static files await this.apiClient.enhanceSite(); console.log('โœ… New item added successfully:', collectionItem.item_id); } catch (error) { console.error('โŒ Failed to add new collection item:', error); alert('Failed to add new item. Please try again.'); } } /** * Get collection preview data (container + templates) * @returns {Promise} Object with collection_id, container_html, and templates */ async getCollectionPreview() { try { if (!this.cachedPreview) { console.log('๐Ÿ” Fetching preview for collection:', this.collectionId); this.cachedPreview = await this.apiClient.getCollectionPreview(this.collectionId); console.log('๐Ÿ“‹ Preview fetched:', this.cachedPreview); } return this.cachedPreview; } catch (error) { console.error('โŒ Failed to fetch preview for collection:', this.collectionId, error); return null; } } /** * Select a template for creating new items * @param {Object} previewData - Preview data with container_html and templates * @returns {Promise} Selected template or null if cancelled */ async selectTemplate(previewData) { const templates = previewData.templates; // Auto-select if only one template if (templates.length === 1) { console.log('๐ŸŽฏ Auto-selecting single template:', templates[0].name); return templates[0]; } // Present live collection preview for multiple templates console.log('๐ŸŽจ Multiple templates available, showing live preview'); return this.showLiveCollectionPreview(previewData); } /** * Show live collection preview for template selection * @param {Object} previewData - Preview data with container_html and templates * @returns {Promise} Selected template or null if cancelled */ async showLiveCollectionPreview(previewData) { return new Promise((resolve) => { // Create modal overlay const overlay = document.createElement('div'); overlay.className = 'insertr-modal-overlay'; // Create modal content const modal = document.createElement('div'); modal.className = 'insertr-collection-preview-modal'; // Generate live preview by reconstructing collection with all templates const previewHTML = this.generateLivePreview(previewData.container_html, previewData.templates); modal.innerHTML = `

Choose Template

Click on the item you want to add

${previewHTML}
`; // Handle template selection by clicking on preview items modal.addEventListener('click', (e) => { const previewItem = e.target.closest('.insertr-preview-item'); if (previewItem) { const templateId = parseInt(previewItem.dataset.templateId); const selectedTemplate = previewData.templates.find(t => t.template_id === templateId); if (selectedTemplate) { console.log('๐ŸŽฏ Template selected from preview:', selectedTemplate.name); document.body.removeChild(overlay); resolve(selectedTemplate); } } }); // Cancel button handler modal.querySelector('.insertr-template-btn-cancel').addEventListener('click', () => { document.body.removeChild(overlay); resolve(null); }); // Close on overlay click overlay.addEventListener('click', (e) => { if (e.target === overlay) { document.body.removeChild(overlay); resolve(null); } }); overlay.appendChild(modal); document.body.appendChild(overlay); }); } /** * Create safe template preview text (no HTML truncation) * @param {string} html - HTML string * @param {number} maxLength - Maximum character length * @returns {string} Safe preview text */ createTemplatePreview(html, maxLength = 60) { try { // Create a temporary DOM element to safely extract text const tempDiv = document.createElement('div'); tempDiv.innerHTML = html; // Extract just the text content const textContent = tempDiv.textContent || tempDiv.innerText || ''; // Truncate the text (not HTML) if (textContent.length <= maxLength) { return textContent; } return textContent.substring(0, maxLength).trim() + '...'; } catch (error) { // Fallback to safer extraction console.warn('Template preview extraction failed:', error); return html.replace(/<[^>]*>/g, '').substring(0, maxLength) + '...'; } } /** * Create styled template preview that shows actual template styling * @param {string} html - HTML template string * @returns {string} HTML preview with actual styles */ createStyledTemplatePreview(html) { try { // Clean the HTML and replace .insertr elements with placeholder content let previewHtml = html .replace(/class="insertr"/g, 'class="insertr-preview-content"') .replace(/class="([^"]*\s+)?insertr(\s+[^"]*)?"/g, 'class="$1insertr-preview-content$2"') .replace(/>([^<]{0,50}) { // Replace long content with placeholder text if (content.trim().length > 30) { return '>Sample content...<'; } return match; }); // Wrap in a preview container with scaling return `
${previewHtml}
`; } catch (error) { console.warn('Styled template preview failed:', error); // Fallback to text preview return `
${this.createTemplatePreview(html, 50)}
`; } } /** * Create a DOM element from collection item data returned by backend * Backend is the source of truth - use its HTML content directly */ createItemFromCollectionData(collectionItem) { // Use backend HTML content directly (database is source of truth) if (collectionItem.html_content && collectionItem.html_content.trim()) { const tempContainer = document.createElement('div'); tempContainer.innerHTML = collectionItem.html_content; const newItem = tempContainer.firstElementChild; // Set the collection item ID as data attribute for future reference newItem.setAttribute('data-item-id', collectionItem.item_id); return newItem; } else { // Fallback: create from frontend template if backend content is empty const tempContainer = document.createElement('div'); tempContainer.innerHTML = this.template.htmlTemplate; const newItem = tempContainer.firstElementChild; // Set the collection item ID as data attribute for future reference newItem.setAttribute('data-item-id', collectionItem.item_id); return newItem; } } /** * Create a new item from the template (legacy method, kept for compatibility) */ createItemFromTemplate() { // Create element from template HTML const tempContainer = document.createElement('div'); tempContainer.innerHTML = this.template.htmlTemplate; const newItem = tempContainer.firstElementChild; // Clear content from editable fields this.template.editableFields.forEach(field => { const element = newItem.querySelector(field.selector); if (element) { this.clearElementContent(element, field.type); // Add placeholder text if (field.type === 'text') { element.textContent = field.placeholder; element.style.color = '#999'; element.style.fontStyle = 'italic'; // Remove placeholder styling when user starts editing element.addEventListener('focus', () => { if (element.textContent === field.placeholder) { element.textContent = ''; element.style.color = ''; element.style.fontStyle = ''; } }); } } }); // Generate unique data-content-id for the item newItem.setAttribute('data-content-id', this.generateItemId(Date.now())); return newItem; } /** * Clear content from an element based on its type */ clearElementContent(element, type) { if (type === 'link') { element.textContent = ''; element.removeAttribute('href'); } else if (type === 'image') { element.removeAttribute('src'); element.removeAttribute('alt'); } else { element.textContent = ''; } } /** * Initialize .insertr elements within a new item * This integrates with the existing editing system */ initializeInsertrElements(container) { const insertrElements = container.querySelectorAll('.insertr'); insertrElements.forEach(element => { // Add click handler for editing (same as existing system) element.addEventListener('click', (e) => { // Only allow editing if authenticated and in edit mode if (!this.auth.isAuthenticated() || !this.auth.isEditMode()) { return; } e.preventDefault(); e.stopPropagation(); // Use the existing form renderer const formRenderer = new InsertrFormRenderer(this.apiClient); const meta = { contentId: element.getAttribute('data-content-id'), element: element, htmlMarkup: element.outerHTML }; const currentContent = this.extractCurrentContent(element); formRenderer.showEditForm( meta, currentContent, (formData) => this.handleItemSave(meta, formData), () => formRenderer.closeForm() ); }); }); } /** * Extract current content (simplified version of editor.js method) */ extractCurrentContent(element) { if (element.tagName.toLowerCase() === 'a') { return { text: element.textContent.trim(), url: element.getAttribute('href') || '' }; } return element.textContent.trim(); } /** * Handle saving of individual item content */ async handleItemSave(meta, formData) { console.log('๐Ÿ’พ Saving item content:', meta.contentId, formData); try { let contentValue; if (typeof formData === 'string') { contentValue = formData; } else if (formData.content) { contentValue = formData.content; } else if (formData.text) { contentValue = formData.text; } else { contentValue = formData; } let result; if (meta.contentId) { result = await this.apiClient.updateContent(meta.contentId, contentValue); } else { result = await this.apiClient.createContent(contentValue, meta.htmlMarkup); } if (result) { meta.element.setAttribute('data-content-id', result.id); console.log(`โœ… Item content saved: ${result.id}`); } else { console.error('โŒ Failed to save item content to server'); } } catch (error) { console.error('โŒ Error saving item content:', error); } } /** * Remove an item from the collection (backend-first approach) */ async removeItem(itemElement) { if (!confirm('Are you sure you want to remove this item?')) { return; } console.log('๐Ÿ—‘๏ธ Removing item from collection'); try { // 1. Get the collection item ID from the element const collectionItemId = itemElement.getAttribute('data-item-id'); if (!collectionItemId) { console.error('โŒ Cannot remove item: missing data-item-id attribute'); return; } // 2. Delete from database first (backend-first approach) const success = await this.apiClient.deleteCollectionItem(this.collectionId, collectionItemId); if (!success) { alert('Failed to remove item from database. Please try again.'); return; } // 3. Remove controls const controls = this.itemControls.get(itemElement); if (controls) { controls.remove(); this.itemControls.delete(itemElement); } // 4. Remove from items array this.items = this.items.filter(item => item.element !== itemElement); // 5. Remove from DOM itemElement.remove(); // 6. Update all item controls (indices changed) this.updateAllItemControls(); // 7. Trigger site enhancement to update static files await this.apiClient.enhanceSite(); console.log('โœ… Item removed successfully:', collectionItemId); } catch (error) { console.error('โŒ Failed to remove collection item:', error); alert('Failed to remove item. Please try again.'); } } /** * Move an item up or down in the collection (backend-first approach) */ async moveItem(itemElement, direction) { console.log(`๐Ÿ”„ Moving item ${direction}`); const currentIndex = this.items.findIndex(item => item.element === itemElement); if (currentIndex === -1) return; let newIndex; if (direction === 'up' && currentIndex > 0) { newIndex = currentIndex - 1; } else if (direction === 'down' && currentIndex < this.items.length - 1) { newIndex = currentIndex + 1; } else { return; // Can't move in that direction } try { // 1. Get the collection item ID const collectionItemId = itemElement.getAttribute('data-item-id'); if (!collectionItemId) { console.error('โŒ Cannot move item: missing data-item-id attribute'); return; } // 2. Store original state for potential rollback const originalItems = [...this.items]; // 3. Perform DOM move (optimistic UI) const targetItem = this.items[newIndex]; if (direction === 'up') { this.container.insertBefore(itemElement, targetItem.element); } else { this.container.insertBefore(itemElement, targetItem.element.nextSibling); } // 4. Update items array [this.items[currentIndex], this.items[newIndex]] = [this.items[newIndex], this.items[currentIndex]]; this.items[currentIndex].index = currentIndex; this.items[newIndex].index = newIndex; // 5. Update all item controls this.updateAllItemControls(); // 6. Build bulk reorder payload from current DOM state const itemOrder = this.items.map((item, index) => ({ itemId: item.element.getAttribute('data-item-id'), position: index + 1 // 1-based positions })); // 7. Send bulk reorder to backend const success = await this.apiClient.reorderCollection(this.collectionId, itemOrder); if (!success) { // Rollback DOM changes this.items = originalItems; this.items.forEach(item => { this.container.appendChild(item.element); }); this.updateAllItemControls(); alert('Failed to update item position in database. Please try again.'); return; } // 8. Trigger site enhancement to update static files await this.apiClient.enhanceSite(); console.log('โœ… Item moved successfully:', collectionItemId, 'โ†’ position', newIndex + 1); } catch (error) { console.error('โŒ Failed to move collection item:', error); alert('Failed to move item. Please try again.'); } } /** * Update controls for all items (called after reordering) */ updateAllItemControls() { // Remove all existing controls this.itemControls.forEach((controls, item) => { controls.remove(); }); this.itemControls.clear(); // Re-add controls with correct up/down button states this.items.forEach((item, index) => { this.addItemControls(item.element, index); }); } /** * Cleanup when the collection manager is destroyed */ destroy() { if (this.authCheckInterval) { clearInterval(this.authCheckInterval); } this.deactivateCollectionUI(); this.isActive = false; console.log('๐Ÿงน CollectionManager destroyed'); } /** * Generate live collection preview by reconstructing container with all template variants * @param {string} containerHTML - Collection container HTML * @param {Array} templates - Array of template objects * @returns {string} HTML string with reconstructed collection */ generateLivePreview(containerHTML, templates) { try { // Parse the container HTML const tempContainer = document.createElement('div'); tempContainer.innerHTML = containerHTML; const collectionContainer = tempContainer.querySelector('.insertr-add'); if (!collectionContainer) { console.error('โŒ No .insertr-add container found in collection HTML'); return '
Preview generation failed
'; } // Clear existing children collectionContainer.innerHTML = ''; // Add one instance of each template with preview classes templates.forEach(template => { // Parse template HTML const templateContainer = document.createElement('div'); templateContainer.innerHTML = template.html_template; const templateElement = templateContainer.firstElementChild; if (templateElement) { // Clone the template element const previewElement = templateElement.cloneNode(true); // Add preview classes and data attributes for selection previewElement.classList.add('insertr-preview-item'); previewElement.setAttribute('data-template-id', template.template_id); previewElement.setAttribute('data-template-name', template.name); // Add the preview element to the collection container collectionContainer.appendChild(previewElement); console.log(`โœ… Added template ${template.template_id} (${template.name}) to preview`); } }); // Return the complete collection HTML return tempContainer.innerHTML; } catch (error) { console.error('โŒ Failed to generate live preview:', error); return '
Preview generation failed
'; } } }