Implement class-based template differentiation and fix collection item creation

- Add class-based template comparison to differentiate styling variants
- Implement template deduplication based on structure + class signatures
- Add GetCollectionTemplate method to repository interface and implementations
- Fix collection item creation by replacing unimplemented CreateCollectionItemAtomic
- Add template selection modal with auto-default selection in frontend
- Generate meaningful template names from distinctive CSS classes
- Fix unique constraint violations with timestamp-based collection item IDs
- Add collection templates API endpoint for frontend template fetching
- Update simple demo with featured/compact/dark testimonial variants for testing
This commit is contained in:
2025-10-27 21:02:59 +01:00
parent 0bad96d866
commit 00255cb105
10 changed files with 486 additions and 33 deletions

20
collection-example.html Normal file
View File

@@ -0,0 +1,20 @@
<!-- insertr collections is defined with .insertr-add -->
<div class="insertr-add">
<!-- the collection containers children are collection items. -->
<!-- this is meant mostly for structural elements such as cards, testimonials etc. -->
<!-- it is not meant for text editing (such as multiple paragraphs) and you should instead use .insertr-content -->
<div class="variant-1">
<!-- The cards content can be edited as normal insertr elements -->
<h2 class="insertr">Card 1</h2>
<p class="insertr lead">This is a lead paragraph</p>
<p class="insertr">This is the main paragraph that could be longer and give additional info</p>
</div>
<!-- Since the developer has defined two templates in the markup two templates must be generated. -->
<!-- they might have different structure, or been styled differently. (imagine an alternating color on testimonial cards). the variant-x classnames are only for ilustration and is not a keyword -->
<div class="variant-2">
<h2 class="insertr">Card 2</h2>
<p class="insertr">This is the main paragraph that could be longer and give additional info</p>
<button type="button insertr">Call to action</button>
</div>
</div>

View File

@@ -142,6 +142,29 @@
.testimonial-item cite:before {
content: "— ";
}
/* Styling variants for template differentiation testing */
.testimonial-item.featured {
border-left: 4px solid #3b82f6;
background: #eff6ff;
}
.testimonial-item.compact {
padding: 1rem;
}
.testimonial-item.dark {
background: #1f2937;
color: white;
}
.testimonial-item.dark blockquote {
color: #e5e7eb;
}
.testimonial-item.dark cite {
color: #9ca3af;
}
</style>
</head>
<body>
@@ -225,11 +248,11 @@
<blockquote class="insertr">Not all that is gold does glitter</blockquote>
<cite class="insertr">Tolkien</cite>
</div>
<div class="testimonial-item">
<div class="testimonial-item featured">
<blockquote class="insertr">The journey of a thousand miles begins with one step</blockquote>
<cite class="insertr">Lao Tzu</cite>
</div>
<div class="testimonial-item">
<div class="testimonial-item compact dark">
<blockquote class="insertr">Innovation distinguishes between a leader and a follower</blockquote>
<cite class="insertr">Steve Jobs</cite>
</div>

View File

@@ -405,6 +405,30 @@ func (h *ContentHandler) GetCollectionItems(w http.ResponseWriter, r *http.Reque
json.NewEncoder(w).Encode(items)
}
// GetCollectionTemplates handles GET /api/collections/{id}/templates
func (h *ContentHandler) GetCollectionTemplates(w http.ResponseWriter, r *http.Request) {
collectionID := chi.URLParam(r, "id")
siteID := r.URL.Query().Get("site_id")
if siteID == "" {
http.Error(w, "site_id parameter is required", http.StatusBadRequest)
return
}
templates, err := h.repository.GetCollectionTemplates(context.Background(), siteID, collectionID)
if err != nil {
http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
return
}
response := map[string]interface{}{
"templates": templates,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// CreateCollectionItem handles POST /api/collections/{id}/items
func (h *ContentHandler) CreateCollectionItem(w http.ResponseWriter, r *http.Request) {
userInfo, authErr := h.authService.ExtractUserFromRequest(r)
@@ -437,15 +461,29 @@ func (h *ContentHandler) CreateCollectionItem(w http.ResponseWriter, r *http.Req
}
if req.TemplateID == 0 {
req.TemplateID = 1 // Default to first template
http.Error(w, "template_id is required", http.StatusBadRequest)
return
}
// Use atomic collection item creation from repository
createdItem, err := h.repository.CreateCollectionItemAtomic(
context.Background(),
// Get the specific template by ID
selectedTemplate, err := h.repository.GetCollectionTemplate(context.Background(), req.TemplateID)
if err != nil {
http.Error(w, fmt.Sprintf("Template %d not found: %v", req.TemplateID, err), http.StatusBadRequest)
return
}
// Verify template belongs to the requested collection and site
if selectedTemplate.CollectionID != req.CollectionID || selectedTemplate.SiteID != req.SiteID {
http.Error(w, fmt.Sprintf("Template %d not found in collection %s", req.TemplateID, req.CollectionID), http.StatusBadRequest)
return
}
// Use engine's unified collection item creation
createdItem, err := h.engine.CreateCollectionItemFromTemplate(
req.SiteID,
req.CollectionID,
req.TemplateID,
selectedTemplate.HTMLTemplate,
userInfo.ID,
)
if err != nil {
@@ -487,10 +525,10 @@ func (h *ContentHandler) RegisterRoutes(r chi.Router) {
// COLLECTION MANAGEMENT - Groups of related content
// =============================================================================
r.Route("/collections", func(r chi.Router) {
// Public routes
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}/items", h.GetCollectionItems) // GET /api/collections/{id}/items?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}/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
// Protected routes
r.Group(func(r chi.Router) {

View File

@@ -192,6 +192,10 @@ func (c *HTTPClient) GetCollectionTemplates(ctx context.Context, siteID, collect
return nil, fmt.Errorf("collection operations not implemented in HTTPClient")
}
func (c *HTTPClient) GetCollectionTemplate(ctx context.Context, templateID int) (*CollectionTemplateItem, error) {
return nil, fmt.Errorf("collection operations not implemented in HTTPClient")
}
func (c *HTTPClient) CreateCollectionItem(ctx context.Context, siteID, collectionID, itemID string, templateID int, htmlContent string, position int, lastEditedBy string) (*CollectionItemWithTemplate, error) {
return nil, fmt.Errorf("collection operations not implemented in HTTPClient")
}

View File

@@ -214,6 +214,24 @@ func (r *PostgreSQLRepository) GetCollectionTemplates(ctx context.Context, siteI
return result, nil
}
// GetCollectionTemplate retrieves a single template by ID
func (r *PostgreSQLRepository) GetCollectionTemplate(ctx context.Context, templateID int) (*CollectionTemplateItem, error) {
template, err := r.queries.GetCollectionTemplate(ctx, int32(templateID))
if err != nil {
return nil, err
}
result := &CollectionTemplateItem{
TemplateID: int(template.TemplateID),
CollectionID: template.CollectionID,
SiteID: template.SiteID,
Name: template.Name,
HTMLTemplate: template.HtmlTemplate,
IsDefault: template.IsDefault, // PostgreSQL uses BOOLEAN
}
return result, nil
}
// CreateCollectionItem creates a new collection item
func (r *PostgreSQLRepository) CreateCollectionItem(ctx context.Context, siteID, collectionID, itemID string, templateID int, htmlContent string, position int, lastEditedBy string) (*CollectionItemWithTemplate, error) {
item, err := r.queries.CreateCollectionItem(ctx, postgresql.CreateCollectionItemParams{

View File

@@ -18,6 +18,7 @@ type ContentRepository interface {
CreateCollection(ctx context.Context, siteID, collectionID, containerHTML, lastEditedBy string) (*CollectionItem, error)
GetCollectionItems(ctx context.Context, siteID, collectionID string) ([]CollectionItemWithTemplate, error)
GetCollectionTemplates(ctx context.Context, siteID, collectionID string) ([]CollectionTemplateItem, error)
GetCollectionTemplate(ctx context.Context, templateID int) (*CollectionTemplateItem, error)
CreateCollectionTemplate(ctx context.Context, siteID, collectionID, name, htmlTemplate string, isDefault bool) (*CollectionTemplateItem, error)
CreateCollectionItem(ctx context.Context, siteID, collectionID, itemID string, templateID int, htmlContent string, position int, lastEditedBy string) (*CollectionItemWithTemplate, error)
CreateCollectionItemAtomic(ctx context.Context, siteID, collectionID string, templateID int, lastEditedBy string) (*CollectionItemWithTemplate, error)

View File

@@ -219,6 +219,24 @@ func (r *SQLiteRepository) GetCollectionTemplates(ctx context.Context, siteID, c
return result, nil
}
// GetCollectionTemplate retrieves a single template by ID
func (r *SQLiteRepository) GetCollectionTemplate(ctx context.Context, templateID int) (*CollectionTemplateItem, error) {
template, err := r.queries.GetCollectionTemplate(ctx, int64(templateID))
if err != nil {
return nil, err
}
result := &CollectionTemplateItem{
TemplateID: int(template.TemplateID),
CollectionID: template.CollectionID,
SiteID: template.SiteID,
Name: template.Name,
HTMLTemplate: template.HtmlTemplate,
IsDefault: template.IsDefault != 0, // SQLite uses INTEGER for boolean
}
return result, nil
}
// CreateCollectionItem creates a new collection item
func (r *SQLiteRepository) CreateCollectionItem(ctx context.Context, siteID, collectionID, itemID string, templateID int, htmlContent string, position int, lastEditedBy string) (*CollectionItemWithTemplate, error) {
item, err := r.queries.CreateCollectionItem(ctx, sqlite.CreateCollectionItemParams{

View File

@@ -3,7 +3,9 @@ package engine
import (
"context"
"fmt"
"sort"
"strings"
"time"
"github.com/insertr/insertr/internal/db"
"golang.org/x/net/html"
@@ -79,21 +81,35 @@ func (e *ContentEngine) extractAndStoreTemplatesAndItems(collectionNode *html.No
return nil
}
// Create templates for each unique child structure
// Create templates for each unique child structure and styling (deduplicated)
seenTemplates := make(map[string]int) // templateSignature -> templateID
templateIndex := 0
for child := collectionNode.FirstChild; child != nil; child = child.NextSibling {
if child.Type == html.ElementNode {
templateName := fmt.Sprintf("template_%d", templateIndex+1)
templateHTML := e.extractCleanTemplate(child)
isDefault := templateIndex == 0
templateSignature := e.generateTemplateSignature(child)
template, err := e.client.CreateCollectionTemplate(context.Background(), siteID, collectionID, templateName, templateHTML, isDefault)
if err != nil {
return fmt.Errorf("failed to create template %s: %w", templateName, err)
// Check if we've already seen this exact template structure + styling
if existingTemplateID, exists := seenTemplates[templateSignature]; exists {
// Reuse existing template
templateIDs = append(templateIDs, existingTemplateID)
fmt.Printf("✅ Reusing existing template for identical structure+styling in collection %s\n", collectionID)
} else {
// Create new template for unique structure+styling combination
templateName := e.generateTemplateNameFromSignature(child, templateIndex+1)
isDefault := templateIndex == 0
template, err := e.client.CreateCollectionTemplate(context.Background(), siteID, collectionID, templateName, templateHTML, isDefault)
if err != nil {
return fmt.Errorf("failed to create template %s: %w", templateName, err)
}
// Store the mapping and append to results
seenTemplates[templateSignature] = template.TemplateID
templateIDs = append(templateIDs, template.TemplateID)
templateIndex++
fmt.Printf("✅ Created new template '%s' for collection %s\n", templateName, collectionID)
}
templateIDs = append(templateIDs, template.TemplateID)
templateIndex++
fmt.Printf("✅ Created template '%s' for collection %s\n", templateName, collectionID)
}
}
@@ -212,8 +228,8 @@ func (e *ContentEngine) processChildElementsAsContent(childElement *html.Node, s
// Walk through the child element and find .insertr elements
e.walkNodes(childElement, func(n *html.Node) {
if n.Type == html.ElementNode && e.hasClass(n, "insertr") {
// Generate content ID for this .insertr element
contentID := e.idGenerator.Generate(n, "collection-item")
// Generate content ID for this .insertr element, including item ID for uniqueness
contentID := e.idGenerator.Generate(n, fmt.Sprintf("%s-content", itemID))
// Extract the content
htmlContent := e.extractHTMLContent(n)
@@ -312,8 +328,9 @@ func (e *ContentEngine) CreateCollectionItemFromTemplate(
return nil, fmt.Errorf("failed to create virtual element: %w", err)
}
// Generate unique item ID using unified generator with collection context
itemID := e.idGenerator.Generate(virtualElement, "collection-item")
// Generate unique item ID using unified generator with collection context + timestamp for uniqueness
baseID := e.idGenerator.Generate(virtualElement, "collection-item")
itemID := fmt.Sprintf("%s-%d", baseID, time.Now().UnixNano()%1000000) // Add 6-digit unique suffix
// Process any .insertr elements within the template and store as content
contentEntries, err := e.processChildElementsAsContent(virtualElement, siteID, itemID)
@@ -456,3 +473,95 @@ func (e *ContentEngine) cloneNode(node *html.Node) *html.Node {
return cloned
}
// generateTemplateSignature creates a unique signature for template comparison
// This combines structural HTML + class-based styling differences
func (e *ContentEngine) generateTemplateSignature(element *html.Node) string {
// Get the clean template HTML (structure)
structuralHTML := e.extractCleanTemplate(element)
// Extract class-based styling signature
stylingSignature := e.extractClassSignature(element)
// Combine both for a unique signature
return fmt.Sprintf("%s|%s", structuralHTML, stylingSignature)
}
// extractClassSignature recursively extracts and normalizes class attributes
func (e *ContentEngine) extractClassSignature(element *html.Node) string {
var signature strings.Builder
e.walkNodes(element, func(n *html.Node) {
if n.Type == html.ElementNode {
// Get classes for this element
classes := GetClasses(n)
if len(classes) > 0 {
// Sort classes for consistent comparison
sortedClasses := make([]string, len(classes))
copy(sortedClasses, classes)
sort.Strings(sortedClasses)
// Add to signature: element[class1,class2,...]
signature.WriteString(fmt.Sprintf("%s[%s];", n.Data, strings.Join(sortedClasses, ",")))
} else {
// Element with no classes
signature.WriteString(fmt.Sprintf("%s[];", n.Data))
}
}
})
return signature.String()
}
// generateTemplateNameFromSignature creates human-readable template names
func (e *ContentEngine) generateTemplateNameFromSignature(element *html.Node, fallbackIndex int) string {
// Extract root element classes for naming
rootClasses := GetClasses(element)
if len(rootClasses) > 0 {
// Find distinctive classes (exclude common structural and base classes)
var distinctiveClasses []string
commonClasses := map[string]bool{
"insertr": true, "insertr-add": true,
// Common base classes that don't indicate variants
"testimonial-item": true, "card": true, "item": true, "post": true,
"container": true, "wrapper": true, "content": true,
}
for _, class := range rootClasses {
if !commonClasses[class] {
distinctiveClasses = append(distinctiveClasses, class)
}
}
if len(distinctiveClasses) > 0 {
// Use distinctive classes for naming
name := strings.Join(distinctiveClasses, "_")
// Capitalize and clean up
name = strings.ReplaceAll(name, "-", "_")
if len(name) > 20 {
name = name[:20]
}
return strings.Title(strings.ToLower(name))
} else if len(rootClasses) > 1 {
// If only common classes, use the last non-insertr class
for i := len(rootClasses) - 1; i >= 0; i-- {
if rootClasses[i] != "insertr" && rootClasses[i] != "insertr-add" {
name := strings.ReplaceAll(rootClasses[i], "-", "_")
return strings.Title(strings.ToLower(name))
}
}
}
}
// Fallback to numbered template
return fmt.Sprintf("template_%d", fallbackIndex)
}
// min returns the smaller of two integers
func min(a, b int) int {
if a < b {
return a
}
return b
}

View File

@@ -242,6 +242,29 @@ export class ApiClient {
}
}
/**
* Get available templates for a collection
* @param {string} collectionId - Collection ID
* @returns {Promise<Array>} Array of collection templates
*/
async getCollectionTemplates(collectionId) {
try {
const collectionsUrl = this.getCollectionsUrl();
const response = await fetch(`${collectionsUrl}/${collectionId}/templates?site_id=${this.siteId}`);
if (response.ok) {
const result = await response.json();
return result.templates || [];
} else {
console.warn(`⚠️ Failed to fetch collection templates (${response.status}): ${collectionId}`);
return [];
}
} catch (error) {
console.error('Failed to fetch collection templates:', collectionId, error);
return [];
}
}
/**
* Reorder collection items in bulk
* @param {string} collectionId - Collection ID

View File

@@ -28,6 +28,7 @@ export class CollectionManager {
this.template = null;
this.items = [];
this.isActive = false;
this.cachedTemplates = null; // Cache for available templates
// UI elements
this.addButton = null;
@@ -411,17 +412,31 @@ export class CollectionManager {
}
try {
// 1. Create collection item in database first (backend-first approach)
const templateId = 1; // Use first template by default
const collectionItem = await this.apiClient.createCollectionItem(this.collectionId, templateId);
// 1. Get available templates for this collection
const templates = await this.getAvailableTemplates();
if (!templates || 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. Create DOM element from the returned collection item data
// 2. Select template (auto-select if only one, otherwise present options)
const selectedTemplate = await this.selectTemplate(templates);
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);
// 3. Add to DOM
// 5. Add to DOM
this.container.insertBefore(newItem, this.addButton);
// 4. Update items array with backend data
// 6. Update items array with backend data
const newItemData = {
element: newItem,
index: this.items.length,
@@ -430,16 +445,16 @@ export class CollectionManager {
};
this.items.push(newItemData);
// 5. Add controls to new item
// 7. Add controls to new item
this.addItemControls(newItem, this.items.length - 1);
// 6. Re-initialize any .insertr elements in the new item
// 8. Re-initialize any .insertr elements in the new item
this.initializeInsertrElements(newItem);
// 7. Update all item controls (indices may have changed)
// 9. Update all item controls (indices may have changed)
this.updateAllItemControls();
// 8. Trigger site enhancement to update static files
// 10. Trigger site enhancement to update static files
await this.apiClient.enhanceSite();
console.log('✅ New item added successfully:', collectionItem.item_id);
@@ -449,6 +464,190 @@ export class CollectionManager {
}
}
/**
* Get available templates for this collection
* @returns {Promise<Array>} Array of template objects
*/
async getAvailableTemplates() {
try {
if (!this.cachedTemplates) {
console.log('🔍 Fetching templates for collection:', this.collectionId);
this.cachedTemplates = await this.apiClient.getCollectionTemplates(this.collectionId);
console.log('📋 Templates fetched:', this.cachedTemplates);
}
return this.cachedTemplates;
} catch (error) {
console.error('❌ Failed to fetch templates for collection:', this.collectionId, error);
return [];
}
}
/**
* Select a template for creating new items
* @param {Array} templates - Available templates
* @returns {Promise<Object|null>} Selected template or null if cancelled
*/
async selectTemplate(templates) {
// Auto-select if only one template
if (templates.length === 1) {
console.log('🎯 Auto-selecting single template:', templates[0].name);
return templates[0];
}
// Present selection UI for multiple templates
console.log('🎨 Multiple templates available, showing selection UI');
return this.showTemplateSelectionModal(templates);
}
/**
* Show template selection modal
* @param {Array} templates - Available templates
* @returns {Promise<Object|null>} Selected template or null if cancelled
*/
async showTemplateSelectionModal(templates) {
return new Promise((resolve) => {
// Create modal overlay
const overlay = document.createElement('div');
overlay.className = 'insertr-modal-overlay';
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 999999;
`;
// Create modal content
const modal = document.createElement('div');
modal.className = 'insertr-template-selector';
modal.style.cssText = `
background: white;
border-radius: 8px;
padding: 24px;
max-width: 500px;
width: 90%;
max-height: 70vh;
overflow-y: auto;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
`;
// Create modal HTML
modal.innerHTML = `
<h3 style="margin: 0 0 16px 0; font-size: 18px; font-weight: 600;">Choose Template</h3>
<div class="template-options">
${templates.map(template => `
<div class="template-option" data-template-id="${template.template_id}" style="
border: 2px solid #e5e7eb;
border-radius: 6px;
padding: 12px;
margin-bottom: 12px;
cursor: pointer;
transition: all 0.2s;
${template.is_default ? 'border-color: #3b82f6; background: #eff6ff;' : ''}
">
<div style="font-weight: 500; margin-bottom: 4px;">
${template.name}${template.is_default ? ' (default)' : ''}
</div>
<div style="font-size: 14px; color: #6b7280; font-family: monospace; background: #f9fafb; padding: 8px; border-radius: 4px; overflow: hidden;">
${this.truncateHtml(template.html_template, 100)}
</div>
</div>
`).join('')}
</div>
<div style="display: flex; gap: 12px; justify-content: flex-end; margin-top: 20px;">
<button class="cancel-btn" style="
padding: 8px 16px;
border: 1px solid #d1d5db;
background: white;
border-radius: 4px;
cursor: pointer;
">Cancel</button>
<button class="select-btn" style="
padding: 8px 16px;
background: #3b82f6;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
opacity: 0.5;
" disabled>Add Item</button>
</div>
`;
let selectedTemplate = null;
// Add event listeners
modal.querySelectorAll('.template-option').forEach(option => {
option.addEventListener('click', () => {
// Remove previous selection
modal.querySelectorAll('.template-option').forEach(opt => {
opt.style.borderColor = '#e5e7eb';
opt.style.background = 'white';
});
// Apply selection styling
option.style.borderColor = '#3b82f6';
option.style.background = '#eff6ff';
// Find selected template
const templateId = parseInt(option.dataset.templateId);
selectedTemplate = templates.find(t => t.template_id === templateId);
// Enable select button
const selectBtn = modal.querySelector('.select-btn');
selectBtn.disabled = false;
selectBtn.style.opacity = '1';
});
});
// Auto-select default template after all event listeners are set up
const defaultTemplate = templates.find(t => t.is_default);
if (defaultTemplate) {
const defaultOption = modal.querySelector(`[data-template-id="${defaultTemplate.template_id}"]`);
if (defaultOption) {
defaultOption.click();
}
}
modal.querySelector('.cancel-btn').addEventListener('click', () => {
document.body.removeChild(overlay);
resolve(null);
});
modal.querySelector('.select-btn').addEventListener('click', () => {
document.body.removeChild(overlay);
resolve(selectedTemplate);
});
// 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);
});
}
/**
* Truncate HTML for preview
* @param {string} html - HTML string
* @param {number} maxLength - Maximum character length
* @returns {string} Truncated HTML
*/
truncateHtml(html, maxLength) {
if (html.length <= maxLength) return html;
return html.substring(0, maxLength) + '...';
}
/**
* Create a DOM element from collection item data returned by backend
* Backend is the source of truth - use its HTML content directly