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:
2025-09-22 18:29:58 +02:00
parent b25663f76b
commit 2315ba4750
36 changed files with 4356 additions and 46 deletions

View 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');
}
}