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:
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user