Implement live collection preview system with contextual template selection

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.
This commit is contained in:
2025-10-31 22:41:12 +01:00
parent 81ec8edf36
commit 163cbf7eea
4 changed files with 273 additions and 106 deletions

View File

@@ -405,8 +405,8 @@ func (h *ContentHandler) GetCollectionItems(w http.ResponseWriter, r *http.Reque
json.NewEncoder(w).Encode(items) json.NewEncoder(w).Encode(items)
} }
// GetCollectionTemplates handles GET /api/collections/{id}/templates // GetCollectionPreview handles GET /api/collections/{id}/preview
func (h *ContentHandler) GetCollectionTemplates(w http.ResponseWriter, r *http.Request) { func (h *ContentHandler) GetCollectionPreview(w http.ResponseWriter, r *http.Request) {
collectionID := chi.URLParam(r, "id") collectionID := chi.URLParam(r, "id")
siteID := r.URL.Query().Get("site_id") siteID := r.URL.Query().Get("site_id")
@@ -415,14 +415,24 @@ func (h *ContentHandler) GetCollectionTemplates(w http.ResponseWriter, r *http.R
return return
} }
// Get collection container
collection, err := h.repository.GetCollection(context.Background(), siteID, collectionID)
if err != nil {
http.Error(w, fmt.Sprintf("Collection not found: %v", err), http.StatusNotFound)
return
}
// Get all templates for this collection
templates, err := h.repository.GetCollectionTemplates(context.Background(), siteID, collectionID) templates, err := h.repository.GetCollectionTemplates(context.Background(), siteID, collectionID)
if err != nil { if err != nil {
http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError) http.Error(w, fmt.Sprintf("Templates not found: %v", err), http.StatusInternalServerError)
return return
} }
response := map[string]interface{}{ response := map[string]interface{}{
"templates": templates, "collection_id": collectionID,
"container_html": collection.ContainerHTML,
"templates": templates,
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -525,10 +535,10 @@ func (h *ContentHandler) RegisterRoutes(r chi.Router) {
// COLLECTION MANAGEMENT - Groups of related content // COLLECTION MANAGEMENT - Groups of related content
// ============================================================================= // =============================================================================
r.Route("/collections", func(r chi.Router) { r.Route("/collections", func(r chi.Router) {
r.Get("/", h.GetAllCollections) // GET /api/collections?site_id=X r.Get("/", h.GetAllCollections) // GET /api/collections?site_id=X
r.Get("/{id}", h.GetCollection) // GET /api/collections/{id}?site_id=X r.Get("/{id}", h.GetCollection) // GET /api/collections/{id}?site_id=X
r.Get("/{id}/items", h.GetCollectionItems) // GET /api/collections/{id}/items?site_id=X r.Get("/{id}/items", h.GetCollectionItems) // GET /api/collections/{id}/items?site_id=X
r.Get("/{id}/templates", h.GetCollectionTemplates) // GET /api/collections/{id}/templates?site_id=X r.Get("/{id}/preview", h.GetCollectionPreview) // GET /api/collections/{id}/preview?site_id=X
// Protected routes // Protected routes
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {

View File

@@ -243,25 +243,25 @@ export class ApiClient {
} }
/** /**
* Get available templates for a collection * Get collection preview data (container + templates for frontend reconstruction)
* @param {string} collectionId - Collection ID * @param {string} collectionId - Collection ID
* @returns {Promise<Array>} Array of collection templates * @returns {Promise<Object>} Object with collection_id, container_html, and templates
*/ */
async getCollectionTemplates(collectionId) { async getCollectionPreview(collectionId) {
try { try {
const collectionsUrl = this.getCollectionsUrl(); const collectionsUrl = this.getCollectionsUrl();
const response = await fetch(`${collectionsUrl}/${collectionId}/templates?site_id=${this.siteId}`); const response = await fetch(`${collectionsUrl}/${collectionId}/preview?site_id=${this.siteId}`);
if (response.ok) { if (response.ok) {
const result = await response.json(); const result = await response.json();
return result.templates || []; return result;
} else { } else {
console.warn(`⚠️ Failed to fetch collection templates (${response.status}): ${collectionId}`); console.warn(`⚠️ Failed to fetch collection preview (${response.status}): ${collectionId}`);
return []; return null;
} }
} catch (error) { } catch (error) {
console.error('Failed to fetch collection templates:', collectionId, error); console.error('Failed to fetch collection preview:', collectionId, error);
return []; return null;
} }
} }

View File

@@ -1098,3 +1098,147 @@ body:not(.insertr-edit-mode) .insertr-editing-hover::after {
font-size: 14px; font-size: 14px;
} }
} }
/* =================================================================
LIVE COLLECTION PREVIEW MODAL
================================================================= */
.insertr-collection-preview-modal {
background: var(--insertr-bg-primary);
color: var(--insertr-text-primary);
border-radius: var(--insertr-border-radius);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
max-width: 90vw;
max-height: 90vh;
width: auto;
overflow: hidden;
position: relative;
z-index: var(--insertr-z-modal);
display: flex;
flex-direction: column;
}
.insertr-preview-header {
padding: var(--insertr-spacing-lg);
border-bottom: 1px solid var(--insertr-border-color);
text-align: center;
}
.insertr-preview-header h3 {
margin: 0 0 var(--insertr-spacing-xs) 0;
font-size: 1.2rem;
color: var(--insertr-text-primary);
}
.insertr-preview-header p {
margin: 0;
color: var(--insertr-text-secondary);
font-size: 0.9rem;
}
.insertr-preview-container {
padding: var(--insertr-spacing-lg);
overflow-y: auto;
flex: 1;
}
.insertr-preview-actions {
padding: var(--insertr-spacing-md) var(--insertr-spacing-lg);
border-top: 1px solid var(--insertr-border-color);
display: flex;
justify-content: center;
gap: var(--insertr-spacing-md);
}
/* Preview item selection styling */
.insertr-preview-item {
cursor: pointer;
transition: all 0.2s ease;
position: relative;
border-radius: var(--insertr-border-radius);
overflow: hidden;
}
.insertr-preview-item:hover {
transform: scale(1.02);
box-shadow: 0 4px 20px rgba(59, 130, 246, 0.25);
z-index: 1;
}
.insertr-preview-item::after {
content: 'Click to select';
position: absolute;
top: var(--insertr-spacing-xs);
right: var(--insertr-spacing-xs);
background: rgba(59, 130, 246, 0.95);
color: white;
padding: var(--insertr-spacing-xs) var(--insertr-spacing-sm);
border-radius: var(--insertr-border-radius);
font-size: 0.75rem;
font-weight: 500;
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
white-space: nowrap;
}
.insertr-preview-item:hover::after {
opacity: 1;
}
/* Enhanced hover effect for better visual feedback */
.insertr-preview-item:hover {
outline: 2px solid var(--insertr-primary-color);
outline-offset: 2px;
}
/* Button styling for preview modal */
.insertr-template-btn {
background: var(--insertr-bg-secondary);
color: var(--insertr-text-primary);
border: 1px solid var(--insertr-border-color);
border-radius: var(--insertr-border-radius);
padding: var(--insertr-spacing-sm) var(--insertr-spacing-md);
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
min-width: 80px;
}
.insertr-template-btn:hover {
background: var(--insertr-bg-hover);
border-color: var(--insertr-primary-color);
}
.insertr-template-btn-cancel {
background: var(--insertr-bg-secondary);
color: var(--insertr-text-primary);
}
.insertr-template-btn-cancel:hover {
background: var(--insertr-danger-color);
color: white;
border-color: var(--insertr-danger-color);
}
/* Mobile responsiveness for preview modal */
@media (max-width: 768px) {
.insertr-collection-preview-modal {
max-width: 95vw;
max-height: 95vh;
margin: var(--insertr-spacing-sm);
}
.insertr-preview-container {
padding: var(--insertr-spacing-md);
}
.insertr-preview-item:hover {
transform: scale(1.01);
}
.insertr-preview-item::after {
font-size: 0.7rem;
padding: 2px 6px;
}
}

View File

@@ -28,7 +28,7 @@ export class CollectionManager {
this.template = null; this.template = null;
this.items = []; this.items = [];
this.isActive = false; this.isActive = false;
this.cachedTemplates = null; // Cache for available templates this.cachedPreview = null; // Cache for collection preview data
// UI elements // UI elements
this.addButton = null; this.addButton = null;
@@ -412,16 +412,16 @@ export class CollectionManager {
} }
try { try {
// 1. Get available templates for this collection // 1. Get collection preview data
const templates = await this.getAvailableTemplates(); const previewData = await this.getCollectionPreview();
if (!templates || templates.length === 0) { if (!previewData || !previewData.templates || previewData.templates.length === 0) {
console.error('❌ No templates available for collection:', this.collectionId); console.error('❌ No templates available for collection:', this.collectionId);
alert('No templates available for this collection. Please refresh the page.'); alert('No templates available for this collection. Please refresh the page.');
return; return;
} }
// 2. Select template (auto-select if only one, otherwise present options) // 2. Select template (auto-select if only one, otherwise show live preview)
const selectedTemplate = await this.selectTemplate(templates); const selectedTemplate = await this.selectTemplate(previewData);
if (!selectedTemplate) { if (!selectedTemplate) {
console.log('Template selection cancelled by user'); console.log('Template selection cancelled by user');
return; return;
@@ -465,46 +465,48 @@ export class CollectionManager {
} }
/** /**
* Get available templates for this collection * Get collection preview data (container + templates)
* @returns {Promise<Array>} Array of template objects * @returns {Promise<Object>} Object with collection_id, container_html, and templates
*/ */
async getAvailableTemplates() { async getCollectionPreview() {
try { try {
if (!this.cachedTemplates) { if (!this.cachedPreview) {
console.log('🔍 Fetching templates for collection:', this.collectionId); console.log('🔍 Fetching preview for collection:', this.collectionId);
this.cachedTemplates = await this.apiClient.getCollectionTemplates(this.collectionId); this.cachedPreview = await this.apiClient.getCollectionPreview(this.collectionId);
console.log('📋 Templates fetched:', this.cachedTemplates); console.log('📋 Preview fetched:', this.cachedPreview);
} }
return this.cachedTemplates; return this.cachedPreview;
} catch (error) { } catch (error) {
console.error('❌ Failed to fetch templates for collection:', this.collectionId, error); console.error('❌ Failed to fetch preview for collection:', this.collectionId, error);
return []; return null;
} }
} }
/** /**
* Select a template for creating new items * Select a template for creating new items
* @param {Array} templates - Available templates * @param {Object} previewData - Preview data with container_html and templates
* @returns {Promise<Object|null>} Selected template or null if cancelled * @returns {Promise<Object|null>} Selected template or null if cancelled
*/ */
async selectTemplate(templates) { async selectTemplate(previewData) {
const templates = previewData.templates;
// Auto-select if only one template // Auto-select if only one template
if (templates.length === 1) { if (templates.length === 1) {
console.log('🎯 Auto-selecting single template:', templates[0].name); console.log('🎯 Auto-selecting single template:', templates[0].name);
return templates[0]; return templates[0];
} }
// Present selection UI for multiple templates // Present live collection preview for multiple templates
console.log('🎨 Multiple templates available, showing selection UI'); console.log('🎨 Multiple templates available, showing live preview');
return this.showTemplateSelectionModal(templates); return this.showLiveCollectionPreview(previewData);
} }
/** /**
* Show template selection modal * Show live collection preview for template selection
* @param {Array} templates - Available templates * @param {Object} previewData - Preview data with container_html and templates
* @returns {Promise<Object|null>} Selected template or null if cancelled * @returns {Promise<Object|null>} Selected template or null if cancelled
*/ */
async showTemplateSelectionModal(templates) { async showLiveCollectionPreview(previewData) {
return new Promise((resolve) => { return new Promise((resolve) => {
// Create modal overlay // Create modal overlay
const overlay = document.createElement('div'); const overlay = document.createElement('div');
@@ -512,73 +514,38 @@ export class CollectionManager {
// Create modal content // Create modal content
const modal = document.createElement('div'); const modal = document.createElement('div');
modal.className = 'insertr-template-selector'; modal.className = 'insertr-collection-preview-modal';
// Generate live preview by reconstructing collection with all templates
const previewHTML = this.generateLivePreview(previewData.container_html, previewData.templates);
// Create modal HTML using CSS classes
modal.innerHTML = ` modal.innerHTML = `
<h3>Choose Template</h3> <div class="insertr-preview-header">
<div class="insertr-template-options"> <h3>Choose Template</h3>
${templates.map(template => ` <p>Click on the item you want to add</p>
<div class="insertr-template-option ${template.is_default ? 'insertr-template-default' : ''}"
data-template-id="${template.template_id}">
<div class="insertr-template-name">
${template.name}${template.is_default ? ' (default)' : ''}
</div>
<div class="insertr-template-preview-container">
${this.createStyledTemplatePreview(template.html_template)}
</div>
</div>
`).join('')}
</div> </div>
<div class="insertr-template-actions"> <div class="insertr-preview-container">
${previewHTML}
</div>
<div class="insertr-preview-actions">
<button class="insertr-template-btn insertr-template-btn-cancel">Cancel</button> <button class="insertr-template-btn insertr-template-btn-cancel">Cancel</button>
<button class="insertr-template-btn insertr-template-btn-select" disabled>Add Item</button>
</div> </div>
`; `;
let selectedTemplate = null; // Handle template selection by clicking on preview items
let userHasInteracted = false; 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);
// Add event listeners for template selection if (selectedTemplate) {
modal.querySelectorAll('.insertr-template-option').forEach(option => { console.log('🎯 Template selected from preview:', selectedTemplate.name);
option.addEventListener('click', (e) => { document.body.removeChild(overlay);
// Mark that user has interacted resolve(selectedTemplate);
userHasInteracted = true; }
// Remove previous selection from all options
modal.querySelectorAll('.insertr-template-option').forEach(opt => {
opt.classList.remove('insertr-template-selected');
});
// Add selection class to clicked option
option.classList.add('insertr-template-selected');
// Find selected template
const templateId = parseInt(option.dataset.templateId);
selectedTemplate = templates.find(t => t.template_id === templateId);
// Enable select button
const selectBtn = modal.querySelector('.insertr-template-btn-select');
selectBtn.disabled = false;
console.log('🎯 Template selected by user:', selectedTemplate.name);
});
});
// Auto-select default template only if user hasn't interacted
const defaultTemplate = templates.find(t => t.is_default);
if (defaultTemplate) {
// Set initial selection without triggering click event
selectedTemplate = defaultTemplate;
const defaultOption = modal.querySelector(`[data-template-id="${defaultTemplate.template_id}"]`);
if (defaultOption) {
// Apply visual selection without click event
defaultOption.classList.add('insertr-template-selected');
const selectBtn = modal.querySelector('.insertr-template-btn-select');
selectBtn.disabled = false;
console.log('🎯 Default template pre-selected:', defaultTemplate.name);
} }
} });
// Cancel button handler // Cancel button handler
modal.querySelector('.insertr-template-btn-cancel').addEventListener('click', () => { modal.querySelector('.insertr-template-btn-cancel').addEventListener('click', () => {
@@ -586,13 +553,6 @@ export class CollectionManager {
resolve(null); resolve(null);
}); });
// Select button handler
modal.querySelector('.insertr-template-btn-select').addEventListener('click', () => {
console.log('✅ Adding item with template:', selectedTemplate ? selectedTemplate.name : 'none');
document.body.removeChild(overlay);
resolve(selectedTemplate);
});
// Close on overlay click // Close on overlay click
overlay.addEventListener('click', (e) => { overlay.addEventListener('click', (e) => {
if (e.target === overlay) { if (e.target === overlay) {
@@ -983,4 +943,57 @@ export class CollectionManager {
console.log('🧹 CollectionManager destroyed'); 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>';
}
}
} }