Implement complete collection persistence with database-backed survival across server restarts
• Add full multi-table schema for collections with normalized design (collections, collection_templates, collection_items, collection_item_versions) • Implement collection detection and processing in enhancement pipeline for .insertr-add elements • Add template extraction and storage from existing HTML children with multi-variant support • Enable collection reconstruction from database on server restart with proper DOM rebuilding • Extend ContentClient interface with collection operations and full database integration • Update enhance command to use engine.DatabaseClient for collection persistence support
This commit is contained in:
610
lib/src/ui/collection-manager.js
Normal file
610
lib/src/ui/collection-manager.js
Normal file
@@ -0,0 +1,610 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// Collection state
|
||||
this.template = null;
|
||||
this.items = [];
|
||||
this.isActive = false;
|
||||
|
||||
// UI elements
|
||||
this.addButton = null;
|
||||
this.itemControls = new Map(); // Map item element to its controls
|
||||
|
||||
console.log('🔄 CollectionManager initialized for:', this.container);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the collection manager
|
||||
*/
|
||||
initialize() {
|
||||
if (this.isActive) return;
|
||||
|
||||
console.log('🚀 Starting collection management for:', this.container.className);
|
||||
|
||||
// Analyze existing content to detect template
|
||||
this.analyzeTemplate();
|
||||
|
||||
// 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
|
||||
this.items = children.map((child, index) => ({
|
||||
element: child,
|
||||
index: index,
|
||||
id: this.generateItemId(index)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.has(itemElement)) return; // Already has controls
|
||||
|
||||
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
|
||||
*/
|
||||
addNewItem() {
|
||||
console.log('➕ Adding new item to collection');
|
||||
|
||||
if (!this.template) {
|
||||
console.error('❌ No template available for creating new items');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new item from template
|
||||
const newItem = this.createItemFromTemplate();
|
||||
|
||||
// Add to DOM
|
||||
this.container.insertBefore(newItem, this.addButton);
|
||||
|
||||
// Update items array
|
||||
const newItemData = {
|
||||
element: newItem,
|
||||
index: this.items.length,
|
||||
id: this.generateItemId(this.items.length)
|
||||
};
|
||||
this.items.push(newItemData);
|
||||
|
||||
// Add controls to new item
|
||||
this.addItemControls(newItem, this.items.length - 1);
|
||||
|
||||
// Re-initialize any .insertr elements in the new item
|
||||
// This allows the existing editor system to handle individual field editing
|
||||
this.initializeInsertrElements(newItem);
|
||||
|
||||
// Update all item controls (indices may have changed)
|
||||
this.updateAllItemControls();
|
||||
|
||||
console.log('✅ New item added successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new item from the template
|
||||
*/
|
||||
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
|
||||
*/
|
||||
removeItem(itemElement) {
|
||||
if (!confirm('Are you sure you want to remove this item?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🗑️ Removing item from collection');
|
||||
|
||||
// Remove controls
|
||||
const controls = this.itemControls.get(itemElement);
|
||||
if (controls) {
|
||||
controls.remove();
|
||||
this.itemControls.delete(itemElement);
|
||||
}
|
||||
|
||||
// Remove from items array
|
||||
this.items = this.items.filter(item => item.element !== itemElement);
|
||||
|
||||
// Remove from DOM
|
||||
itemElement.remove();
|
||||
|
||||
// Update all item controls (indices changed)
|
||||
this.updateAllItemControls();
|
||||
|
||||
console.log('✅ Item removed successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an item up or down in the collection
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
// Get the target position in DOM
|
||||
const targetItem = this.items[newIndex];
|
||||
|
||||
// Move in DOM
|
||||
if (direction === 'up') {
|
||||
this.container.insertBefore(itemElement, targetItem.element);
|
||||
} else {
|
||||
this.container.insertBefore(itemElement, targetItem.element.nextSibling);
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// Update all item controls
|
||||
this.updateAllItemControls();
|
||||
|
||||
console.log('✅ Item moved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user