Replace isolated template previews with live collection reconstruction:
- Frontend now reconstructs collection container with all template variants
- Users click directly on rendered templates in proper CSS context
- Perfect preservation of grid/flex layouts and responsive behavior
- Simplified API: preview endpoint returns container_html + templates for frontend reconstruction
- Enhanced UX: WYSIWYG template selection shows exactly what will be added
- Removed redundant templates endpoint in favor of unified preview approach
Backend changes:
- Add GET /api/collections/{id}/preview endpoint
- Remove GET /api/collections/{id}/templates endpoint
- Return container HTML + templates for frontend reconstruction
Frontend changes:
- Replace isolated template modal with live collection preview
- Add generateLivePreview() method for container reconstruction
- Update CollectionManager to use preview API
- Add interactive CSS styling for template selection
This provides true contextual template selection where CSS inheritance,
grid layouts, and responsive design work perfectly in preview mode.
999 lines
36 KiB
JavaScript
999 lines
36 KiB
JavaScript
/**
|
||
* 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>} 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<Object|null>} 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<Object|null>} 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 = `
|
||
<div class="insertr-preview-header">
|
||
<h3>Choose Template</h3>
|
||
<p>Click on the item you want to add</p>
|
||
</div>
|
||
<div class="insertr-preview-container">
|
||
${previewHTML}
|
||
</div>
|
||
<div class="insertr-preview-actions">
|
||
<button class="insertr-template-btn insertr-template-btn-cancel">Cancel</button>
|
||
</div>
|
||
`;
|
||
|
||
// 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})</g, (match, content) => {
|
||
// Replace long content with placeholder text
|
||
if (content.trim().length > 30) {
|
||
return '>Sample content...<';
|
||
}
|
||
return match;
|
||
});
|
||
|
||
// Wrap in a preview container with scaling
|
||
return `<div class="insertr-template-preview-render">${previewHtml}</div>`;
|
||
} catch (error) {
|
||
console.warn('Styled template preview failed:', error);
|
||
// Fallback to text preview
|
||
return `<div class="insertr-template-preview-fallback">${this.createTemplatePreview(html, 50)}</div>`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 '<div>Preview generation failed</div>';
|
||
}
|
||
|
||
// 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 '<div>Preview generation failed</div>';
|
||
}
|
||
}
|
||
} |