+
Innovation distinguishes between a leader and a follower
Steve Jobs
diff --git a/internal/api/handlers.go b/internal/api/handlers.go
index 1148685..ee5a09b 100644
--- a/internal/api/handlers.go
+++ b/internal/api/handlers.go
@@ -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) {
diff --git a/internal/db/http_client.go b/internal/db/http_client.go
index a0ad514..12d347f 100644
--- a/internal/db/http_client.go
+++ b/internal/db/http_client.go
@@ -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")
}
diff --git a/internal/db/postgresql_repository.go b/internal/db/postgresql_repository.go
index 80aa849..d96e1ac 100644
--- a/internal/db/postgresql_repository.go
+++ b/internal/db/postgresql_repository.go
@@ -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{
diff --git a/internal/db/repository.go b/internal/db/repository.go
index 562b6fe..058bc98 100644
--- a/internal/db/repository.go
+++ b/internal/db/repository.go
@@ -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)
diff --git a/internal/db/sqlite_repository.go b/internal/db/sqlite_repository.go
index 991617e..b702a37 100644
--- a/internal/db/sqlite_repository.go
+++ b/internal/db/sqlite_repository.go
@@ -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{
diff --git a/internal/engine/collection.go b/internal/engine/collection.go
index a79fe74..1db91cf 100644
--- a/internal/engine/collection.go
+++ b/internal/engine/collection.go
@@ -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
+}
diff --git a/lib/src/core/api-client.js b/lib/src/core/api-client.js
index 298dffd..be0cace 100644
--- a/lib/src/core/api-client.js
+++ b/lib/src/core/api-client.js
@@ -242,6 +242,29 @@ export class ApiClient {
}
}
+ /**
+ * Get available templates for a collection
+ * @param {string} collectionId - Collection ID
+ * @returns {Promise
} 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
diff --git a/lib/src/ui/collection-manager.js b/lib/src/ui/collection-manager.js
index adc25b2..205fbe4 100644
--- a/lib/src/ui/collection-manager.js
+++ b/lib/src/ui/collection-manager.js
@@ -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. 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);
- // 2. Create DOM element from the returned collection item data
+ // 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);
@@ -448,6 +463,190 @@ export class CollectionManager {
alert('Failed to add new item. Please try again.');
}
}
+
+ /**
+ * Get available templates for this collection
+ * @returns {Promise} 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