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:
20
collection-example.html
Normal file
20
collection-example.html
Normal 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>
|
||||||
|
|
||||||
@@ -142,6 +142,29 @@
|
|||||||
.testimonial-item cite:before {
|
.testimonial-item cite:before {
|
||||||
content: "— ";
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -225,11 +248,11 @@
|
|||||||
<blockquote class="insertr">Not all that is gold does glitter</blockquote>
|
<blockquote class="insertr">Not all that is gold does glitter</blockquote>
|
||||||
<cite class="insertr">Tolkien</cite>
|
<cite class="insertr">Tolkien</cite>
|
||||||
</div>
|
</div>
|
||||||
<div class="testimonial-item">
|
<div class="testimonial-item featured">
|
||||||
<blockquote class="insertr">The journey of a thousand miles begins with one step</blockquote>
|
<blockquote class="insertr">The journey of a thousand miles begins with one step</blockquote>
|
||||||
<cite class="insertr">Lao Tzu</cite>
|
<cite class="insertr">Lao Tzu</cite>
|
||||||
</div>
|
</div>
|
||||||
<div class="testimonial-item">
|
<div class="testimonial-item compact dark">
|
||||||
<blockquote class="insertr">Innovation distinguishes between a leader and a follower</blockquote>
|
<blockquote class="insertr">Innovation distinguishes between a leader and a follower</blockquote>
|
||||||
<cite class="insertr">Steve Jobs</cite>
|
<cite class="insertr">Steve Jobs</cite>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -405,6 +405,30 @@ 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
|
||||||
|
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
|
// CreateCollectionItem handles POST /api/collections/{id}/items
|
||||||
func (h *ContentHandler) CreateCollectionItem(w http.ResponseWriter, r *http.Request) {
|
func (h *ContentHandler) CreateCollectionItem(w http.ResponseWriter, r *http.Request) {
|
||||||
userInfo, authErr := h.authService.ExtractUserFromRequest(r)
|
userInfo, authErr := h.authService.ExtractUserFromRequest(r)
|
||||||
@@ -437,15 +461,29 @@ func (h *ContentHandler) CreateCollectionItem(w http.ResponseWriter, r *http.Req
|
|||||||
}
|
}
|
||||||
|
|
||||||
if req.TemplateID == 0 {
|
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
|
// Get the specific template by ID
|
||||||
createdItem, err := h.repository.CreateCollectionItemAtomic(
|
selectedTemplate, err := h.repository.GetCollectionTemplate(context.Background(), req.TemplateID)
|
||||||
context.Background(),
|
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.SiteID,
|
||||||
req.CollectionID,
|
req.CollectionID,
|
||||||
req.TemplateID,
|
req.TemplateID,
|
||||||
|
selectedTemplate.HTMLTemplate,
|
||||||
userInfo.ID,
|
userInfo.ID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -487,10 +525,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) {
|
||||||
// Public routes
|
|
||||||
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
|
||||||
|
|
||||||
// Protected routes
|
// Protected routes
|
||||||
r.Group(func(r chi.Router) {
|
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")
|
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) {
|
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")
|
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
|
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
|
// 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) {
|
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{
|
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)
|
CreateCollection(ctx context.Context, siteID, collectionID, containerHTML, lastEditedBy string) (*CollectionItem, error)
|
||||||
GetCollectionItems(ctx context.Context, siteID, collectionID string) ([]CollectionItemWithTemplate, error)
|
GetCollectionItems(ctx context.Context, siteID, collectionID string) ([]CollectionItemWithTemplate, error)
|
||||||
GetCollectionTemplates(ctx context.Context, siteID, collectionID string) ([]CollectionTemplateItem, 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)
|
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)
|
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)
|
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
|
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
|
// 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) {
|
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{
|
item, err := r.queries.CreateCollectionItem(ctx, sqlite.CreateCollectionItemParams{
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package engine
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/insertr/insertr/internal/db"
|
"github.com/insertr/insertr/internal/db"
|
||||||
"golang.org/x/net/html"
|
"golang.org/x/net/html"
|
||||||
@@ -79,21 +81,35 @@ func (e *ContentEngine) extractAndStoreTemplatesAndItems(collectionNode *html.No
|
|||||||
return nil
|
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
|
templateIndex := 0
|
||||||
for child := collectionNode.FirstChild; child != nil; child = child.NextSibling {
|
for child := collectionNode.FirstChild; child != nil; child = child.NextSibling {
|
||||||
if child.Type == html.ElementNode {
|
if child.Type == html.ElementNode {
|
||||||
templateName := fmt.Sprintf("template_%d", templateIndex+1)
|
|
||||||
templateHTML := e.extractCleanTemplate(child)
|
templateHTML := e.extractCleanTemplate(child)
|
||||||
|
templateSignature := e.generateTemplateSignature(child)
|
||||||
|
|
||||||
|
// 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
|
isDefault := templateIndex == 0
|
||||||
|
|
||||||
template, err := e.client.CreateCollectionTemplate(context.Background(), siteID, collectionID, templateName, templateHTML, isDefault)
|
template, err := e.client.CreateCollectionTemplate(context.Background(), siteID, collectionID, templateName, templateHTML, isDefault)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create template %s: %w", templateName, err)
|
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)
|
templateIDs = append(templateIDs, template.TemplateID)
|
||||||
templateIndex++
|
templateIndex++
|
||||||
fmt.Printf("✅ Created template '%s' for collection %s\n", templateName, collectionID)
|
fmt.Printf("✅ Created new 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
|
// Walk through the child element and find .insertr elements
|
||||||
e.walkNodes(childElement, func(n *html.Node) {
|
e.walkNodes(childElement, func(n *html.Node) {
|
||||||
if n.Type == html.ElementNode && e.hasClass(n, "insertr") {
|
if n.Type == html.ElementNode && e.hasClass(n, "insertr") {
|
||||||
// Generate content ID for this .insertr element
|
// Generate content ID for this .insertr element, including item ID for uniqueness
|
||||||
contentID := e.idGenerator.Generate(n, "collection-item")
|
contentID := e.idGenerator.Generate(n, fmt.Sprintf("%s-content", itemID))
|
||||||
|
|
||||||
// Extract the content
|
// Extract the content
|
||||||
htmlContent := e.extractHTMLContent(n)
|
htmlContent := e.extractHTMLContent(n)
|
||||||
@@ -312,8 +328,9 @@ func (e *ContentEngine) CreateCollectionItemFromTemplate(
|
|||||||
return nil, fmt.Errorf("failed to create virtual element: %w", err)
|
return nil, fmt.Errorf("failed to create virtual element: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate unique item ID using unified generator with collection context
|
// Generate unique item ID using unified generator with collection context + timestamp for uniqueness
|
||||||
itemID := e.idGenerator.Generate(virtualElement, "collection-item")
|
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
|
// Process any .insertr elements within the template and store as content
|
||||||
contentEntries, err := e.processChildElementsAsContent(virtualElement, siteID, itemID)
|
contentEntries, err := e.processChildElementsAsContent(virtualElement, siteID, itemID)
|
||||||
@@ -456,3 +473,95 @@ func (e *ContentEngine) cloneNode(node *html.Node) *html.Node {
|
|||||||
|
|
||||||
return cloned
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
* Reorder collection items in bulk
|
||||||
* @param {string} collectionId - Collection ID
|
* @param {string} collectionId - Collection ID
|
||||||
|
|||||||
@@ -28,6 +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
|
||||||
|
|
||||||
// UI elements
|
// UI elements
|
||||||
this.addButton = null;
|
this.addButton = null;
|
||||||
@@ -411,17 +412,31 @@ export class CollectionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Create collection item in database first (backend-first approach)
|
// 1. Get available templates for this collection
|
||||||
const templateId = 1; // Use first template by default
|
const templates = await this.getAvailableTemplates();
|
||||||
const collectionItem = await this.apiClient.createCollectionItem(this.collectionId, templateId);
|
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);
|
const newItem = this.createItemFromCollectionData(collectionItem);
|
||||||
|
|
||||||
// 3. Add to DOM
|
// 5. Add to DOM
|
||||||
this.container.insertBefore(newItem, this.addButton);
|
this.container.insertBefore(newItem, this.addButton);
|
||||||
|
|
||||||
// 4. Update items array with backend data
|
// 6. Update items array with backend data
|
||||||
const newItemData = {
|
const newItemData = {
|
||||||
element: newItem,
|
element: newItem,
|
||||||
index: this.items.length,
|
index: this.items.length,
|
||||||
@@ -430,16 +445,16 @@ export class CollectionManager {
|
|||||||
};
|
};
|
||||||
this.items.push(newItemData);
|
this.items.push(newItemData);
|
||||||
|
|
||||||
// 5. Add controls to new item
|
// 7. Add controls to new item
|
||||||
this.addItemControls(newItem, this.items.length - 1);
|
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);
|
this.initializeInsertrElements(newItem);
|
||||||
|
|
||||||
// 7. Update all item controls (indices may have changed)
|
// 9. Update all item controls (indices may have changed)
|
||||||
this.updateAllItemControls();
|
this.updateAllItemControls();
|
||||||
|
|
||||||
// 8. Trigger site enhancement to update static files
|
// 10. Trigger site enhancement to update static files
|
||||||
await this.apiClient.enhanceSite();
|
await this.apiClient.enhanceSite();
|
||||||
|
|
||||||
console.log('✅ New item added successfully:', collectionItem.item_id);
|
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
|
* Create a DOM element from collection item data returned by backend
|
||||||
* Backend is the source of truth - use its HTML content directly
|
* Backend is the source of truth - use its HTML content directly
|
||||||
|
|||||||
Reference in New Issue
Block a user