Implement atomic collection item creation API with unified content engine approach
Updates collection creation to use database-first atomic operations for reliable collection item management. Replaces manual database calls with unified content engine methods that handle content extraction, storage, and structural template generation consistently. Key changes: - Replace manual database operations in CreateCollectionItem handler with DatabaseClient.CreateCollectionItemAtomic() - Implement unified content engine approach for API-based collection item creation - Add atomic collection item creation methods across all content clients - Enhance reconstruction to use stored structural templates with content ID hydration - Add comprehensive collection management API methods in JavaScript client - Implement collection manager UI with create, delete, and reorder functionality 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1004,45 +1004,37 @@ func (h *ContentHandler) CreateCollectionItem(w http.ResponseWriter, r *http.Req
|
|||||||
if req.CollectionID == "" {
|
if req.CollectionID == "" {
|
||||||
req.CollectionID = collectionID
|
req.CollectionID = collectionID
|
||||||
}
|
}
|
||||||
|
if req.TemplateID == 0 {
|
||||||
// Generate item ID
|
req.TemplateID = 1 // Default to first template
|
||||||
itemID := fmt.Sprintf("%s-item-%d", collectionID, time.Now().Unix())
|
|
||||||
|
|
||||||
var createdItem interface{}
|
|
||||||
var err error
|
|
||||||
|
|
||||||
switch h.database.GetDBType() {
|
|
||||||
case "sqlite3":
|
|
||||||
createdItem, err = h.database.GetSQLiteQueries().CreateCollectionItem(context.Background(), sqlite.CreateCollectionItemParams{
|
|
||||||
ItemID: itemID,
|
|
||||||
CollectionID: req.CollectionID,
|
|
||||||
SiteID: req.SiteID,
|
|
||||||
TemplateID: int64(req.TemplateID),
|
|
||||||
HtmlContent: req.HTMLContent,
|
|
||||||
Position: int64(req.Position),
|
|
||||||
LastEditedBy: req.CreatedBy,
|
|
||||||
})
|
|
||||||
case "postgresql":
|
|
||||||
createdItem, err = h.database.GetPostgreSQLQueries().CreateCollectionItem(context.Background(), postgresql.CreateCollectionItemParams{
|
|
||||||
ItemID: itemID,
|
|
||||||
CollectionID: req.CollectionID,
|
|
||||||
SiteID: req.SiteID,
|
|
||||||
TemplateID: int32(req.TemplateID),
|
|
||||||
HtmlContent: req.HTMLContent,
|
|
||||||
Position: int32(req.Position),
|
|
||||||
LastEditedBy: req.CreatedBy,
|
|
||||||
})
|
|
||||||
default:
|
|
||||||
http.Error(w, "Unsupported database type", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create database client for atomic operations
|
||||||
|
dbClient := engine.NewDatabaseClient(h.database)
|
||||||
|
|
||||||
|
// Use atomic collection item creation
|
||||||
|
createdItem, err := dbClient.CreateCollectionItemAtomic(
|
||||||
|
req.SiteID,
|
||||||
|
req.CollectionID,
|
||||||
|
req.TemplateID,
|
||||||
|
req.CreatedBy,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Failed to create collection item: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("Failed to create collection item: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
apiItem := h.convertToAPICollectionItem(createdItem)
|
// Convert to API response format
|
||||||
|
apiItem := CollectionItemData{
|
||||||
|
ItemID: createdItem.ItemID,
|
||||||
|
CollectionID: createdItem.CollectionID,
|
||||||
|
SiteID: createdItem.SiteID,
|
||||||
|
TemplateID: createdItem.TemplateID,
|
||||||
|
HTMLContent: createdItem.HTMLContent,
|
||||||
|
Position: createdItem.Position,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
LastEditedBy: createdItem.LastEditedBy,
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
|||||||
@@ -196,3 +196,7 @@ func (c *HTTPClient) GetCollectionTemplates(siteID, collectionID string) ([]engi
|
|||||||
func (c *HTTPClient) CreateCollectionItem(siteID, collectionID, itemID string, templateID int, htmlContent string, position int, lastEditedBy string) (*engine.CollectionItemWithTemplate, error) {
|
func (c *HTTPClient) CreateCollectionItem(siteID, collectionID, itemID string, templateID int, htmlContent string, position int, lastEditedBy string) (*engine.CollectionItemWithTemplate, error) {
|
||||||
return nil, fmt.Errorf("collection operations not implemented in HTTPClient")
|
return nil, fmt.Errorf("collection operations not implemented in HTTPClient")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *HTTPClient) CreateCollectionItemAtomic(siteID, collectionID string, templateID int, lastEditedBy string) (*engine.CollectionItemWithTemplate, error) {
|
||||||
|
return nil, fmt.Errorf("collection operations not implemented in HTTPClient")
|
||||||
|
}
|
||||||
|
|||||||
@@ -182,3 +182,7 @@ func (m *MockClient) GetCollectionTemplates(siteID, collectionID string) ([]engi
|
|||||||
func (m *MockClient) CreateCollectionItem(siteID, collectionID, itemID string, templateID int, htmlContent string, position int, lastEditedBy string) (*engine.CollectionItemWithTemplate, error) {
|
func (m *MockClient) CreateCollectionItem(siteID, collectionID, itemID string, templateID int, htmlContent string, position int, lastEditedBy string) (*engine.CollectionItemWithTemplate, error) {
|
||||||
return nil, fmt.Errorf("collection operations not implemented in MockClient")
|
return nil, fmt.Errorf("collection operations not implemented in MockClient")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockClient) CreateCollectionItemAtomic(siteID, collectionID string, templateID int, lastEditedBy string) (*engine.CollectionItemWithTemplate, error) {
|
||||||
|
return nil, fmt.Errorf("collection operations not implemented in MockClient")
|
||||||
|
}
|
||||||
|
|||||||
@@ -516,3 +516,36 @@ func (c *DatabaseClient) CreateCollectionItem(siteID, collectionID, itemID strin
|
|||||||
return nil, fmt.Errorf("unsupported database type: %s", c.database.GetDBType())
|
return nil, fmt.Errorf("unsupported database type: %s", c.database.GetDBType())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateCollectionItemAtomic creates a collection item with all its content entries atomically
|
||||||
|
func (c *DatabaseClient) CreateCollectionItemAtomic(
|
||||||
|
siteID, collectionID string,
|
||||||
|
templateID int,
|
||||||
|
lastEditedBy string,
|
||||||
|
) (*CollectionItemWithTemplate, error) {
|
||||||
|
// Get template HTML for processing
|
||||||
|
templates, err := c.GetCollectionTemplates(siteID, collectionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get templates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var templateHTML string
|
||||||
|
for _, template := range templates {
|
||||||
|
if template.TemplateID == templateID {
|
||||||
|
templateHTML = template.HTMLTemplate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if templateHTML == "" {
|
||||||
|
return nil, fmt.Errorf("template %d not found", templateID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use unified engine approach (no more TemplateProcessor)
|
||||||
|
engine := NewContentEngine(c)
|
||||||
|
|
||||||
|
// Create collection item using unified engine method
|
||||||
|
return engine.CreateCollectionItemFromTemplate(
|
||||||
|
siteID, collectionID, templateID, templateHTML, lastEditedBy,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package engine
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"golang.org/x/net/html"
|
"golang.org/x/net/html"
|
||||||
)
|
)
|
||||||
@@ -385,6 +386,30 @@ func (e *ContentEngine) extractHTMLContent(node *html.Node) string {
|
|||||||
return strings.TrimSpace(content.String())
|
return strings.TrimSpace(content.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractTextContent extracts only the text content from a node (for individual content storage)
|
||||||
|
func (e *ContentEngine) extractTextContent(node *html.Node) string {
|
||||||
|
var text strings.Builder
|
||||||
|
|
||||||
|
// Walk through all text nodes to extract content
|
||||||
|
e.walkNodes(node, func(n *html.Node) {
|
||||||
|
if n.Type == html.TextNode {
|
||||||
|
text.WriteString(n.Data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return strings.TrimSpace(text.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAttributeValue gets an attribute value from an HTML node
|
||||||
|
func (e *ContentEngine) getAttributeValue(n *html.Node, attrKey string) string {
|
||||||
|
for _, attr := range n.Attr {
|
||||||
|
if attr.Key == attrKey {
|
||||||
|
return attr.Val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// extractOriginalTemplate extracts the outer HTML of the element (including the element itself)
|
// extractOriginalTemplate extracts the outer HTML of the element (including the element itself)
|
||||||
func (e *ContentEngine) extractOriginalTemplate(node *html.Node) string {
|
func (e *ContentEngine) extractOriginalTemplate(node *html.Node) string {
|
||||||
var buf strings.Builder
|
var buf strings.Builder
|
||||||
@@ -394,6 +419,86 @@ func (e *ContentEngine) extractOriginalTemplate(node *html.Node) string {
|
|||||||
return buf.String()
|
return buf.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractCleanTemplate extracts a clean template without data-content-id attributes and with placeholder content
|
||||||
|
func (e *ContentEngine) extractCleanTemplate(node *html.Node) string {
|
||||||
|
// Clone the node to avoid modifying the original
|
||||||
|
clonedNode := e.cloneNode(node)
|
||||||
|
|
||||||
|
// Remove all data-content-id attributes and replace content with placeholders
|
||||||
|
e.walkNodes(clonedNode, func(n *html.Node) {
|
||||||
|
if n.Type == html.ElementNode {
|
||||||
|
// Remove data-content-id attribute
|
||||||
|
e.removeAttribute(n, "data-content-id")
|
||||||
|
|
||||||
|
// If this is an .insertr element, replace content with placeholder
|
||||||
|
if e.hasClass(n, "insertr") {
|
||||||
|
placeholderText := e.getPlaceholderForElement(n.Data)
|
||||||
|
// Clear existing children and add placeholder text
|
||||||
|
for child := n.FirstChild; child != nil; {
|
||||||
|
next := child.NextSibling
|
||||||
|
n.RemoveChild(child)
|
||||||
|
child = next
|
||||||
|
}
|
||||||
|
n.AppendChild(&html.Node{
|
||||||
|
Type: html.TextNode,
|
||||||
|
Data: placeholderText,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
var buf strings.Builder
|
||||||
|
if err := html.Render(&buf, clonedNode); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeAttribute removes an attribute from an HTML node
|
||||||
|
func (e *ContentEngine) removeAttribute(n *html.Node, key string) {
|
||||||
|
for i, attr := range n.Attr {
|
||||||
|
if attr.Key == key {
|
||||||
|
n.Attr = append(n.Attr[:i], n.Attr[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasClass checks if an HTML node has a specific class
|
||||||
|
func (e *ContentEngine) hasClass(n *html.Node, className string) bool {
|
||||||
|
for _, attr := range n.Attr {
|
||||||
|
if attr.Key == "class" {
|
||||||
|
classes := strings.Fields(attr.Val)
|
||||||
|
for _, class := range classes {
|
||||||
|
if class == className {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPlaceholderForElement returns appropriate placeholder text for an element type
|
||||||
|
func (e *ContentEngine) getPlaceholderForElement(elementType string) string {
|
||||||
|
placeholders := map[string]string{
|
||||||
|
"blockquote": "Enter your quote here...",
|
||||||
|
"cite": "Enter author name...",
|
||||||
|
"h1": "Enter heading...",
|
||||||
|
"h2": "Enter heading...",
|
||||||
|
"h3": "Enter heading...",
|
||||||
|
"p": "Enter text...",
|
||||||
|
"span": "Enter text...",
|
||||||
|
"div": "Enter content...",
|
||||||
|
"a": "Enter link text...",
|
||||||
|
}
|
||||||
|
|
||||||
|
if placeholder, exists := placeholders[elementType]; exists {
|
||||||
|
return placeholder
|
||||||
|
}
|
||||||
|
return "Enter content..."
|
||||||
|
}
|
||||||
|
|
||||||
// processCollection handles collection detection, persistence and reconstruction
|
// processCollection handles collection detection, persistence and reconstruction
|
||||||
func (e *ContentEngine) processCollection(collectionNode *html.Node, collectionID, siteID string) error {
|
func (e *ContentEngine) processCollection(collectionNode *html.Node, collectionID, siteID string) error {
|
||||||
// 1. Check if collection exists in database
|
// 1. Check if collection exists in database
|
||||||
@@ -417,27 +522,15 @@ func (e *ContentEngine) processCollection(collectionNode *html.Node, collectionI
|
|||||||
|
|
||||||
fmt.Printf("✅ Created new collection: %s with templates and initial items\n", collectionID)
|
fmt.Printf("✅ Created new collection: %s with templates and initial items\n", collectionID)
|
||||||
} else {
|
} else {
|
||||||
// 4. Database-first approach: Check if collection items already exist
|
// 4. Existing collection: Always reconstruct from database (database is source of truth)
|
||||||
existingItems, err := e.client.GetCollectionItems(siteID, collectionID)
|
err = e.reconstructCollectionItems(collectionNode, collectionID, siteID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check existing collection items: %w", err)
|
return fmt.Errorf("failed to reconstruct collection %s: %w", collectionID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(existingItems) == 0 {
|
// Get final item count for logging
|
||||||
// 5. Collection exists but no items - store original children as initial items
|
existingItems, _ := e.client.GetCollectionItems(siteID, collectionID)
|
||||||
err = e.storeInitialCollectionItems(collectionNode, collectionID, siteID)
|
fmt.Printf("✅ Reconstructed collection: %s from database (%d items)\n", collectionID, len(existingItems))
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to store initial collection items for %s: %w", collectionID, err)
|
|
||||||
}
|
|
||||||
fmt.Printf("✅ Stored initial items for existing collection: %s\n", collectionID)
|
|
||||||
} else {
|
|
||||||
// 6. Items exist: reconstruct from database (normal case)
|
|
||||||
err = e.reconstructCollectionItems(collectionNode, collectionID, siteID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to reconstruct collection %s: %w", collectionID, err)
|
|
||||||
}
|
|
||||||
fmt.Printf("✅ Reconstructed collection: %s from database (%d items)\n", collectionID, len(existingItems))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -468,7 +561,7 @@ func (e *ContentEngine) extractAndStoreTemplatesAndItems(collectionNode *html.No
|
|||||||
// Extract templates from existing children and store them
|
// Extract templates from existing children and store them
|
||||||
var templateIDs []int
|
var templateIDs []int
|
||||||
for i, templateElement := range templateElements {
|
for i, templateElement := range templateElements {
|
||||||
templateHTML := e.extractOriginalTemplate(templateElement)
|
templateHTML := e.extractCleanTemplate(templateElement)
|
||||||
templateName := fmt.Sprintf("template-%d", i+1)
|
templateName := fmt.Sprintf("template-%d", i+1)
|
||||||
isDefault := (i == 0) // First template is default
|
isDefault := (i == 0) // First template is default
|
||||||
|
|
||||||
@@ -517,69 +610,49 @@ func (e *ContentEngine) reconstructCollectionItems(collectionNode *html.Node, co
|
|||||||
child = next
|
child = next
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add items from database in position order
|
// Add items from database in position order using unified .insertr approach
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
// Get the template for this item
|
// Parse the stored structural HTML with content IDs (no template needed for reconstruction)
|
||||||
templateHTML, exists := templateMap[item.TemplateID]
|
structuralDoc, err := html.Parse(strings.NewReader(item.HTMLContent))
|
||||||
if !exists {
|
|
||||||
fmt.Printf("⚠️ Template %d not found for item %s\n", item.TemplateID, item.ItemID)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the template HTML
|
|
||||||
templateDoc, err := html.Parse(strings.NewReader(templateHTML))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("⚠️ Failed to parse template HTML for %s: %v\n", item.ItemID, err)
|
fmt.Printf("⚠️ Failed to parse stored HTML for %s: %v\n", item.ItemID, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the body element and extract the template structure
|
var structuralBody *html.Node
|
||||||
var templateBody *html.Node
|
e.walkNodes(structuralDoc, func(n *html.Node) {
|
||||||
e.walkNodes(templateDoc, func(n *html.Node) {
|
|
||||||
if n.Type == html.ElementNode && n.Data == "body" {
|
if n.Type == html.ElementNode && n.Data == "body" {
|
||||||
templateBody = n
|
structuralBody = n
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if templateBody != nil && templateBody.FirstChild != nil {
|
if structuralBody != nil {
|
||||||
// Clone the template structure (first child of body)
|
// Process each .insertr element using Injector pattern (unified approach)
|
||||||
templateNode := templateBody.FirstChild
|
injector := NewInjector(e.client, siteID)
|
||||||
clonedTemplate := e.cloneNode(templateNode)
|
|
||||||
|
|
||||||
// Replace the template's inner content with the stored item content
|
// Walk through structural elements and hydrate with content from content table
|
||||||
// Clear the cloned template's children
|
e.walkNodes(structuralBody, func(n *html.Node) {
|
||||||
for child := clonedTemplate.FirstChild; child != nil; {
|
if n.Type == html.ElementNode && e.hasClass(n, "insertr") {
|
||||||
next := child.NextSibling
|
// Get content ID from data attribute
|
||||||
clonedTemplate.RemoveChild(child)
|
contentID := e.getAttributeValue(n, "data-content-id")
|
||||||
child = next
|
if contentID != "" {
|
||||||
}
|
// Use Injector to hydrate content (unified .insertr approach)
|
||||||
|
element := &Element{Node: n, Type: "html"}
|
||||||
// Parse and add the item's content
|
err := injector.InjectContent(element, contentID)
|
||||||
itemDoc, err := html.Parse(strings.NewReader(item.HTMLContent))
|
if err != nil {
|
||||||
if err != nil {
|
fmt.Printf("⚠️ Failed to inject content for %s: %v\n", contentID, err)
|
||||||
fmt.Printf("⚠️ Failed to parse item content for %s: %v\n", item.ItemID, err)
|
}
|
||||||
continue
|
}
|
||||||
}
|
|
||||||
|
|
||||||
var itemBody *html.Node
|
|
||||||
e.walkNodes(itemDoc, func(n *html.Node) {
|
|
||||||
if n.Type == html.ElementNode && n.Data == "body" {
|
|
||||||
itemBody = n
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if itemBody != nil {
|
// Add hydrated structural elements directly to collection (stored HTML has complete structure)
|
||||||
// Move all children from item body to cloned template
|
for structuralChild := structuralBody.FirstChild; structuralChild != nil; {
|
||||||
for itemChild := itemBody.FirstChild; itemChild != nil; {
|
next := structuralChild.NextSibling
|
||||||
next := itemChild.NextSibling
|
structuralBody.RemoveChild(structuralChild)
|
||||||
itemBody.RemoveChild(itemChild)
|
collectionNode.AppendChild(structuralChild)
|
||||||
clonedTemplate.AppendChild(itemChild)
|
structuralChild = next
|
||||||
itemChild = next
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the reconstructed item to collection
|
|
||||||
collectionNode.AppendChild(clonedTemplate)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -587,6 +660,139 @@ func (e *ContentEngine) reconstructCollectionItems(collectionNode *html.Node, co
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// processChildElementsAsContent processes .insertr elements within a collection child and stores them as individual content
|
||||||
|
func (e *ContentEngine) processChildElementsAsContent(childElement *html.Node, siteID, itemID string) ([]ContentEntry, error) {
|
||||||
|
var contentEntries []ContentEntry
|
||||||
|
elementIndex := 0
|
||||||
|
|
||||||
|
// Walk through child element to find .insertr elements
|
||||||
|
e.walkNodes(childElement, func(n *html.Node) {
|
||||||
|
if n.Type == html.ElementNode && e.hasClass(n, "insertr") {
|
||||||
|
// Use core IDGenerator for unified ID generation (like individual .insertr elements)
|
||||||
|
contentID := e.idGenerator.Generate(n, "collection-item")
|
||||||
|
|
||||||
|
// Extract actual content from the element
|
||||||
|
actualContent := e.extractTextContent(n)
|
||||||
|
|
||||||
|
// Store as individual content entry (unified .insertr approach)
|
||||||
|
_, err := e.client.CreateContent(siteID, contentID, actualContent, "", "system")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("⚠️ Failed to create content %s: %v\n", contentID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to content entries for structural template generation
|
||||||
|
contentEntries = append(contentEntries, ContentEntry{
|
||||||
|
ID: contentID,
|
||||||
|
SiteID: siteID,
|
||||||
|
HTMLContent: actualContent,
|
||||||
|
Template: e.extractOriginalTemplate(n),
|
||||||
|
})
|
||||||
|
|
||||||
|
elementIndex++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return contentEntries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateStructuralTemplateFromChild creates structure-only HTML from child element with content IDs
|
||||||
|
func (e *ContentEngine) generateStructuralTemplateFromChild(childElement *html.Node, contentEntries []ContentEntry) (string, error) {
|
||||||
|
// Clone the child element to avoid modifying original
|
||||||
|
clonedChild := e.cloneNode(childElement)
|
||||||
|
entryIndex := 0
|
||||||
|
|
||||||
|
// Walk through cloned element and replace content with content IDs
|
||||||
|
e.walkNodes(clonedChild, func(n *html.Node) {
|
||||||
|
if n.Type == html.ElementNode && e.hasClass(n, "insertr") {
|
||||||
|
if entryIndex < len(contentEntries) {
|
||||||
|
// Set the data-content-id attribute
|
||||||
|
e.setAttribute(n, "data-content-id", contentEntries[entryIndex].ID)
|
||||||
|
|
||||||
|
// Clear content - this will be hydrated during reconstruction
|
||||||
|
for child := n.FirstChild; child != nil; {
|
||||||
|
next := child.NextSibling
|
||||||
|
n.RemoveChild(child)
|
||||||
|
child = next
|
||||||
|
}
|
||||||
|
|
||||||
|
entryIndex++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Render the complete structural template including container
|
||||||
|
var sb strings.Builder
|
||||||
|
html.Render(&sb, clonedChild)
|
||||||
|
|
||||||
|
return sb.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createVirtualElementFromTemplate creates a virtual DOM element from template HTML for API usage
|
||||||
|
// This allows API path to use the same structure extraction as enhancement path
|
||||||
|
func (e *ContentEngine) createVirtualElementFromTemplate(templateHTML string) (*html.Node, error) {
|
||||||
|
// Parse the template HTML
|
||||||
|
templateDoc, err := html.Parse(strings.NewReader(templateHTML))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse template HTML: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the body element and extract the template structure
|
||||||
|
var templateBody *html.Node
|
||||||
|
e.walkNodes(templateDoc, func(n *html.Node) {
|
||||||
|
if n.Type == html.ElementNode && n.Data == "body" {
|
||||||
|
templateBody = n
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if templateBody != nil && templateBody.FirstChild != nil {
|
||||||
|
// Return the first child of body (the actual template element)
|
||||||
|
return templateBody.FirstChild, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("template does not contain valid structure")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCollectionItemFromTemplate creates a collection item using the unified engine approach
|
||||||
|
// This replaces TemplateProcessor with engine-native functionality
|
||||||
|
func (e *ContentEngine) CreateCollectionItemFromTemplate(
|
||||||
|
siteID, collectionID string,
|
||||||
|
templateID int,
|
||||||
|
templateHTML string,
|
||||||
|
lastEditedBy string,
|
||||||
|
) (*CollectionItemWithTemplate, error) {
|
||||||
|
// Generate unique item ID
|
||||||
|
itemID := fmt.Sprintf("%s-item-%d", collectionID, time.Now().Unix())
|
||||||
|
|
||||||
|
// Create virtual element from template (like enhancement path)
|
||||||
|
virtualElement, err := e.createVirtualElementFromTemplate(templateHTML)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create virtual element: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process .insertr elements and create content entries (unified approach)
|
||||||
|
contentEntries, err := e.processChildElementsAsContent(virtualElement, siteID, itemID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to process content entries: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate structural template using unified engine method
|
||||||
|
structuralTemplate, err := e.generateStructuralTemplateFromChild(virtualElement, contentEntries)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate structural template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create collection item with structural template
|
||||||
|
collectionItem, err := e.client.CreateCollectionItem(
|
||||||
|
siteID, collectionID, itemID, templateID, structuralTemplate, 0, lastEditedBy,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create collection item: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return collectionItem, nil
|
||||||
|
}
|
||||||
|
|
||||||
// cloneNode creates a deep copy of an HTML node
|
// cloneNode creates a deep copy of an HTML node
|
||||||
func (e *ContentEngine) cloneNode(node *html.Node) *html.Node {
|
func (e *ContentEngine) cloneNode(node *html.Node) *html.Node {
|
||||||
cloned := &html.Node{
|
cloned := &html.Node{
|
||||||
@@ -614,28 +820,6 @@ func (e *ContentEngine) cloneNode(node *html.Node) *html.Node {
|
|||||||
return cloned
|
return cloned
|
||||||
}
|
}
|
||||||
|
|
||||||
// storeInitialCollectionItems stores original children as collection items (for existing collections)
|
|
||||||
func (e *ContentEngine) storeInitialCollectionItems(collectionNode *html.Node, collectionID, siteID string) error {
|
|
||||||
// Get existing templates for this collection
|
|
||||||
templates, err := e.client.GetCollectionTemplates(siteID, collectionID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get collection templates: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(templates) == 0 {
|
|
||||||
fmt.Printf("⚠️ No templates found for collection %s, skipping initial items storage\n", collectionID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use template IDs from existing templates
|
|
||||||
var templateIDs []int
|
|
||||||
for _, template := range templates {
|
|
||||||
templateIDs = append(templateIDs, template.TemplateID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return e.storeChildrenAsCollectionItems(collectionNode, collectionID, siteID, templateIDs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// storeChildrenAsCollectionItems stores HTML children as collection items in database
|
// storeChildrenAsCollectionItems stores HTML children as collection items in database
|
||||||
func (e *ContentEngine) storeChildrenAsCollectionItems(collectionNode *html.Node, collectionID, siteID string, templateIDs []int) error {
|
func (e *ContentEngine) storeChildrenAsCollectionItems(collectionNode *html.Node, collectionID, siteID string, templateIDs []int) error {
|
||||||
// Find existing children elements to store as items
|
// Find existing children elements to store as items
|
||||||
@@ -653,24 +837,33 @@ func (e *ContentEngine) storeChildrenAsCollectionItems(collectionNode *html.Node
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store each child as a collection item (database-first pattern like .insertr)
|
// Store each child using unified .insertr approach (content table + structural template)
|
||||||
for i, childElement := range childElements {
|
for i, childElement := range childElements {
|
||||||
// Generate item ID (like content ID generation)
|
// Generate item ID (like content ID generation)
|
||||||
itemID := fmt.Sprintf("%s-initial-%d", collectionID, i+1)
|
itemID := fmt.Sprintf("%s-initial-%d", collectionID, i+1)
|
||||||
|
|
||||||
// Extract HTML content from the child (reuse .insertr pattern)
|
// Process .insertr elements within this child (unified approach)
|
||||||
htmlContent := e.extractHTMLContent(childElement)
|
contentEntries, err := e.processChildElementsAsContent(childElement, siteID, itemID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to process content for item %s: %w", itemID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate structural template with content IDs (no actual content)
|
||||||
|
structuralTemplate, err := e.generateStructuralTemplateFromChild(childElement, contentEntries)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to generate structural template for item %s: %w", itemID, err)
|
||||||
|
}
|
||||||
|
|
||||||
// Use appropriate template ID (cycle through available templates)
|
// Use appropriate template ID (cycle through available templates)
|
||||||
templateID := templateIDs[i%len(templateIDs)]
|
templateID := templateIDs[i%len(templateIDs)]
|
||||||
|
|
||||||
// Store as collection item
|
// Store structural template in collection_items (content lives in content table)
|
||||||
_, err := e.client.CreateCollectionItem(siteID, collectionID, itemID, templateID, htmlContent, i+1, "system")
|
_, err = e.client.CreateCollectionItem(siteID, collectionID, itemID, templateID, structuralTemplate, i+1, "system")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create collection item %s: %w", itemID, err)
|
return fmt.Errorf("failed to create collection item %s: %w", itemID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("✅ Stored initial collection item: %s (template %d)\n", itemID, templateID)
|
fmt.Printf("✅ Stored initial collection item: %s (template %d) with %d content entries\n", itemID, templateID, len(contentEntries))
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ type ProcessedElement struct {
|
|||||||
Classes []string // Element CSS classes
|
Classes []string // Element CSS classes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ContentEntry represents a content item to be created for collection templates
|
||||||
|
type ContentEntry struct {
|
||||||
|
ID string
|
||||||
|
SiteID string
|
||||||
|
HTMLContent string
|
||||||
|
Template string
|
||||||
|
}
|
||||||
|
|
||||||
// ContentClient interface for accessing content data
|
// ContentClient interface for accessing content data
|
||||||
// This will be implemented by database clients, HTTP clients, and mock clients
|
// This will be implemented by database clients, HTTP clients, and mock clients
|
||||||
type ContentClient interface {
|
type ContentClient interface {
|
||||||
@@ -56,6 +64,7 @@ type ContentClient interface {
|
|||||||
GetCollectionTemplates(siteID, collectionID string) ([]CollectionTemplateItem, error)
|
GetCollectionTemplates(siteID, collectionID string) ([]CollectionTemplateItem, error)
|
||||||
CreateCollectionTemplate(siteID, collectionID, name, htmlTemplate string, isDefault bool) (*CollectionTemplateItem, error)
|
CreateCollectionTemplate(siteID, collectionID, name, htmlTemplate string, isDefault bool) (*CollectionTemplateItem, error)
|
||||||
CreateCollectionItem(siteID, collectionID, itemID string, templateID int, htmlContent string, position int, lastEditedBy string) (*CollectionItemWithTemplate, error)
|
CreateCollectionItem(siteID, collectionID, itemID string, templateID int, htmlContent string, position int, lastEditedBy string) (*CollectionItemWithTemplate, error)
|
||||||
|
CreateCollectionItemAtomic(siteID, collectionID string, templateID int, lastEditedBy string) (*CollectionItemWithTemplate, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContentItem represents a piece of content from the database
|
// ContentItem represents a piece of content from the database
|
||||||
|
|||||||
@@ -141,6 +141,172 @@ export class ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Collection API Methods
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new collection item
|
||||||
|
* @param {string} collectionId - Collection ID
|
||||||
|
* @param {number} templateId - Template ID to use (defaults to 1)
|
||||||
|
* @param {string} htmlContent - Optional initial HTML content
|
||||||
|
* @returns {Promise<Object>} Created collection item
|
||||||
|
*/
|
||||||
|
async createCollectionItem(collectionId, templateId = 1, htmlContent = '') {
|
||||||
|
try {
|
||||||
|
const collectionsUrl = this.baseUrl.replace('/api/content', '/api/collections');
|
||||||
|
const payload = {
|
||||||
|
site_id: this.siteId,
|
||||||
|
template_id: templateId,
|
||||||
|
html_content: htmlContent
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${collectionsUrl}/${collectionId}/items`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.getAuthToken()}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
console.log(`✅ Collection item created: ${result.item_id}`);
|
||||||
|
return result;
|
||||||
|
} else {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error(`❌ Failed to create collection item (${response.status}): ${errorText}`);
|
||||||
|
throw new Error(`Failed to create collection item: ${response.status} ${errorText}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error creating collection item:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a collection item
|
||||||
|
* @param {string} collectionId - Collection ID
|
||||||
|
* @param {string} itemId - Item ID to delete
|
||||||
|
* @returns {Promise<boolean>} Success status
|
||||||
|
*/
|
||||||
|
async deleteCollectionItem(collectionId, itemId) {
|
||||||
|
try {
|
||||||
|
const collectionsUrl = this.baseUrl.replace('/api/content', '/api/collections');
|
||||||
|
|
||||||
|
const response = await fetch(`${collectionsUrl}/${collectionId}/items/${itemId}?site_id=${this.siteId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${this.getAuthToken()}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
console.log(`✅ Collection item deleted: ${itemId}`);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error(`❌ Failed to delete collection item (${response.status}): ${errorText}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error deleting collection item:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all collection items
|
||||||
|
* @param {string} collectionId - Collection ID
|
||||||
|
* @returns {Promise<Array>} Array of collection items
|
||||||
|
*/
|
||||||
|
async getCollectionItems(collectionId) {
|
||||||
|
try {
|
||||||
|
const collectionsUrl = this.baseUrl.replace('/api/content', '/api/collections');
|
||||||
|
const response = await fetch(`${collectionsUrl}/${collectionId}/items?site_id=${this.siteId}`);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
return result.items || [];
|
||||||
|
} else {
|
||||||
|
console.warn(`⚠️ Failed to fetch collection items (${response.status}): ${collectionId}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch collection items:', collectionId, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update collection item position (for reordering)
|
||||||
|
* @param {string} collectionId - Collection ID
|
||||||
|
* @param {string} itemId - Item ID to update
|
||||||
|
* @param {number} newPosition - New position index
|
||||||
|
* @returns {Promise<boolean>} Success status
|
||||||
|
*/
|
||||||
|
async updateCollectionItemPosition(collectionId, itemId, newPosition) {
|
||||||
|
try {
|
||||||
|
const collectionsUrl = this.baseUrl.replace('/api/content', '/api/collections');
|
||||||
|
const payload = {
|
||||||
|
site_id: this.siteId,
|
||||||
|
position: newPosition
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${collectionsUrl}/${collectionId}/items/${itemId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.getAuthToken()}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
console.log(`✅ Collection item position updated: ${itemId} → position ${newPosition}`);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error(`❌ Failed to update collection item position (${response.status}): ${errorText}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error updating collection item position:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger site enhancement after collection changes
|
||||||
|
* @returns {Promise<boolean>} Success status
|
||||||
|
*/
|
||||||
|
async enhanceSite() {
|
||||||
|
try {
|
||||||
|
const enhanceUrl = this.baseUrl.replace('/api/content', '/api/enhance');
|
||||||
|
const response = await fetch(`${enhanceUrl}?site_id=${this.siteId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${this.getAuthToken()}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('✅ Files enhanced successfully:', result);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.error(`❌ Failed to enhance files (${response.status})`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error enhancing files:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get authentication token for API requests
|
* Get authentication token for API requests
|
||||||
* @returns {string} JWT token or mock token for development
|
* @returns {string} JWT token or mock token for development
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ export class CollectionManager {
|
|||||||
this.apiClient = apiClient;
|
this.apiClient = apiClient;
|
||||||
this.auth = auth;
|
this.auth = auth;
|
||||||
|
|
||||||
|
// Extract collection ID from container
|
||||||
|
this.collectionId = this.container.getAttribute('data-content-id');
|
||||||
|
if (!this.collectionId) {
|
||||||
|
console.error('❌ Collection container missing data-content-id attribute');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Collection state
|
// Collection state
|
||||||
this.template = null;
|
this.template = null;
|
||||||
this.items = [];
|
this.items = [];
|
||||||
@@ -26,13 +33,13 @@ export class CollectionManager {
|
|||||||
this.addButton = null;
|
this.addButton = null;
|
||||||
this.itemControls = new Map(); // Map item element to its controls
|
this.itemControls = new Map(); // Map item element to its controls
|
||||||
|
|
||||||
console.log('🔄 CollectionManager initialized for:', this.container);
|
console.log('🔄 CollectionManager initialized for:', this.container, 'Collection ID:', this.collectionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the collection manager
|
* Initialize the collection manager
|
||||||
*/
|
*/
|
||||||
initialize() {
|
async initialize() {
|
||||||
if (this.isActive) return;
|
if (this.isActive) return;
|
||||||
|
|
||||||
console.log('🚀 Starting collection management for:', this.container.className);
|
console.log('🚀 Starting collection management for:', this.container.className);
|
||||||
@@ -40,6 +47,9 @@ export class CollectionManager {
|
|||||||
// Analyze existing content to detect template
|
// Analyze existing content to detect template
|
||||||
this.analyzeTemplate();
|
this.analyzeTemplate();
|
||||||
|
|
||||||
|
// Sync with backend to map existing items to collection item IDs
|
||||||
|
await this.syncWithBackend();
|
||||||
|
|
||||||
// Add collection management UI only when in edit mode
|
// Add collection management UI only when in edit mode
|
||||||
this.setupEditModeDetection();
|
this.setupEditModeDetection();
|
||||||
|
|
||||||
@@ -135,10 +145,12 @@ export class CollectionManager {
|
|||||||
console.log('📋 Template detected:', this.template);
|
console.log('📋 Template detected:', this.template);
|
||||||
|
|
||||||
// Store reference to current items
|
// Store reference to current items
|
||||||
|
// For existing items, try to extract collection item IDs if they exist
|
||||||
this.items = children.map((child, index) => ({
|
this.items = children.map((child, index) => ({
|
||||||
element: child,
|
element: child,
|
||||||
index: index,
|
index: index,
|
||||||
id: this.generateItemId(index)
|
id: this.generateItemId(index),
|
||||||
|
collectionItemId: this.extractCollectionItemId(child)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,6 +240,61 @@ export class CollectionManager {
|
|||||||
return `item-${Date.now()}-${index}`;
|
return `item-${Date.now()}-${index}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract collection item ID from existing DOM element
|
||||||
|
* This is used for existing items that were reconstructed from database
|
||||||
|
*/
|
||||||
|
extractCollectionItemId(element) {
|
||||||
|
// Look for data-collection-item-id attribute first (newly created items)
|
||||||
|
let itemId = element.getAttribute('data-collection-item-id');
|
||||||
|
if (itemId) {
|
||||||
|
return itemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For existing items reconstructed from database, try to infer from data-content-id
|
||||||
|
// The backend should have generated collection item IDs based on collection ID
|
||||||
|
const contentId = element.getAttribute('data-content-id');
|
||||||
|
if (contentId && this.collectionId) {
|
||||||
|
// This is a heuristic - we'll need to fetch the actual mapping from the backend
|
||||||
|
// For now, return null and let the backend operations handle missing IDs
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync frontend state with backend collection items
|
||||||
|
* This maps existing DOM elements to their collection item IDs
|
||||||
|
*/
|
||||||
|
async syncWithBackend() {
|
||||||
|
if (!this.collectionId) {
|
||||||
|
console.warn('⚠️ Cannot sync with backend: no collection ID');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch current collection items from backend
|
||||||
|
const backendItems = await this.apiClient.getCollectionItems(this.collectionId);
|
||||||
|
console.log('📋 Backend collection items:', backendItems);
|
||||||
|
|
||||||
|
// Map backend items to existing DOM elements by position
|
||||||
|
// This assumes the DOM order matches the database order
|
||||||
|
backendItems.forEach((backendItem, index) => {
|
||||||
|
if (this.items[index]) {
|
||||||
|
this.items[index].collectionItemId = backendItem.item_id;
|
||||||
|
this.items[index].element.setAttribute('data-collection-item-id', backendItem.item_id);
|
||||||
|
console.log(`🔗 Mapped DOM element ${index} to collection item ${backendItem.item_id}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Frontend-backend sync completed');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to sync with backend:', error);
|
||||||
|
// Continue without backend sync - collection management will still work for new items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the "+ Add" button positioned in top right of container
|
* Create the "+ Add" button positioned in top right of container
|
||||||
*/
|
*/
|
||||||
@@ -334,45 +401,85 @@ export class CollectionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a new item to the collection
|
* Add a new item to the collection (backend-first approach)
|
||||||
*/
|
*/
|
||||||
addNewItem() {
|
async addNewItem() {
|
||||||
console.log('➕ Adding new item to collection');
|
console.log('➕ Adding new item to collection');
|
||||||
|
|
||||||
if (!this.template) {
|
if (!this.template || !this.collectionId) {
|
||||||
console.error('❌ No template available for creating new items');
|
console.error('❌ No template or collection ID available for creating new items');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new item from template
|
try {
|
||||||
const newItem = this.createItemFromTemplate();
|
// 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);
|
||||||
|
|
||||||
// Add to DOM
|
// 2. Create DOM element from the returned collection item data
|
||||||
this.container.insertBefore(newItem, this.addButton);
|
const newItem = this.createItemFromCollectionData(collectionItem);
|
||||||
|
|
||||||
// Update items array
|
// 3. Add to DOM
|
||||||
const newItemData = {
|
this.container.insertBefore(newItem, this.addButton);
|
||||||
element: newItem,
|
|
||||||
index: this.items.length,
|
|
||||||
id: this.generateItemId(this.items.length)
|
|
||||||
};
|
|
||||||
this.items.push(newItemData);
|
|
||||||
|
|
||||||
// Add controls to new item
|
// 4. Update items array with backend data
|
||||||
this.addItemControls(newItem, this.items.length - 1);
|
const newItemData = {
|
||||||
|
element: newItem,
|
||||||
|
index: this.items.length,
|
||||||
|
id: collectionItem.item_id,
|
||||||
|
collectionItem: collectionItem
|
||||||
|
};
|
||||||
|
this.items.push(newItemData);
|
||||||
|
|
||||||
// Re-initialize any .insertr elements in the new item
|
// 5. Add controls to new item
|
||||||
// This allows the existing editor system to handle individual field editing
|
this.addItemControls(newItem, this.items.length - 1);
|
||||||
this.initializeInsertrElements(newItem);
|
|
||||||
|
|
||||||
// Update all item controls (indices may have changed)
|
// 6. Re-initialize any .insertr elements in the new item
|
||||||
this.updateAllItemControls();
|
this.initializeInsertrElements(newItem);
|
||||||
|
|
||||||
console.log('✅ New item added successfully');
|
// 7. Update all item controls (indices may have changed)
|
||||||
|
this.updateAllItemControls();
|
||||||
|
|
||||||
|
// 8. Trigger site enhancement to update static files
|
||||||
|
await this.apiClient.enhanceSite();
|
||||||
|
|
||||||
|
console.log('✅ New item added successfully:', collectionItem.item_id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to add new collection item:', error);
|
||||||
|
alert('Failed to add new item. Please try again.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new item from the template
|
* Create a DOM element from collection item data returned by backend
|
||||||
|
* Backend is the source of truth - use its HTML content directly
|
||||||
|
*/
|
||||||
|
createItemFromCollectionData(collectionItem) {
|
||||||
|
// Use backend HTML content directly (database is source of truth)
|
||||||
|
if (collectionItem.html_content && collectionItem.html_content.trim()) {
|
||||||
|
const tempContainer = document.createElement('div');
|
||||||
|
tempContainer.innerHTML = collectionItem.html_content;
|
||||||
|
const newItem = tempContainer.firstElementChild;
|
||||||
|
|
||||||
|
// Set the collection item ID as data attribute for future reference
|
||||||
|
newItem.setAttribute('data-collection-item-id', collectionItem.item_id);
|
||||||
|
|
||||||
|
return newItem;
|
||||||
|
} else {
|
||||||
|
// Fallback: create from frontend template if backend content is empty
|
||||||
|
const tempContainer = document.createElement('div');
|
||||||
|
tempContainer.innerHTML = this.template.htmlTemplate;
|
||||||
|
const newItem = tempContainer.firstElementChild;
|
||||||
|
|
||||||
|
// Set the collection item ID as data attribute for future reference
|
||||||
|
newItem.setAttribute('data-collection-item-id', collectionItem.item_id);
|
||||||
|
|
||||||
|
return newItem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new item from the template (legacy method, kept for compatibility)
|
||||||
*/
|
*/
|
||||||
createItemFromTemplate() {
|
createItemFromTemplate() {
|
||||||
// Create element from template HTML
|
// Create element from template HTML
|
||||||
@@ -511,38 +618,60 @@ export class CollectionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove an item from the collection
|
* Remove an item from the collection (backend-first approach)
|
||||||
*/
|
*/
|
||||||
removeItem(itemElement) {
|
async removeItem(itemElement) {
|
||||||
if (!confirm('Are you sure you want to remove this item?')) {
|
if (!confirm('Are you sure you want to remove this item?')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🗑️ Removing item from collection');
|
console.log('🗑️ Removing item from collection');
|
||||||
|
|
||||||
// Remove controls
|
try {
|
||||||
const controls = this.itemControls.get(itemElement);
|
// 1. Get the collection item ID from the element
|
||||||
if (controls) {
|
const collectionItemId = itemElement.getAttribute('data-collection-item-id');
|
||||||
controls.remove();
|
if (!collectionItemId) {
|
||||||
this.itemControls.delete(itemElement);
|
console.error('❌ Cannot remove item: missing data-collection-item-id attribute');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Delete from database first (backend-first approach)
|
||||||
|
const success = await this.apiClient.deleteCollectionItem(this.collectionId, collectionItemId);
|
||||||
|
if (!success) {
|
||||||
|
alert('Failed to remove item from database. Please try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Remove controls
|
||||||
|
const controls = this.itemControls.get(itemElement);
|
||||||
|
if (controls) {
|
||||||
|
controls.remove();
|
||||||
|
this.itemControls.delete(itemElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Remove from items array
|
||||||
|
this.items = this.items.filter(item => item.element !== itemElement);
|
||||||
|
|
||||||
|
// 5. Remove from DOM
|
||||||
|
itemElement.remove();
|
||||||
|
|
||||||
|
// 6. Update all item controls (indices changed)
|
||||||
|
this.updateAllItemControls();
|
||||||
|
|
||||||
|
// 7. Trigger site enhancement to update static files
|
||||||
|
await this.apiClient.enhanceSite();
|
||||||
|
|
||||||
|
console.log('✅ Item removed successfully:', collectionItemId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to remove collection item:', error);
|
||||||
|
alert('Failed to remove item. Please try again.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from items array
|
|
||||||
this.items = this.items.filter(item => item.element !== itemElement);
|
|
||||||
|
|
||||||
// Remove from DOM
|
|
||||||
itemElement.remove();
|
|
||||||
|
|
||||||
// Update all item controls (indices changed)
|
|
||||||
this.updateAllItemControls();
|
|
||||||
|
|
||||||
console.log('✅ Item removed successfully');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Move an item up or down in the collection
|
* Move an item up or down in the collection (backend-first approach)
|
||||||
*/
|
*/
|
||||||
moveItem(itemElement, direction) {
|
async moveItem(itemElement, direction) {
|
||||||
console.log(`🔄 Moving item ${direction}`);
|
console.log(`🔄 Moving item ${direction}`);
|
||||||
|
|
||||||
const currentIndex = this.items.findIndex(item => item.element === itemElement);
|
const currentIndex = this.items.findIndex(item => item.element === itemElement);
|
||||||
@@ -557,25 +686,48 @@ export class CollectionManager {
|
|||||||
return; // Can't move in that direction
|
return; // Can't move in that direction
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the target position in DOM
|
try {
|
||||||
const targetItem = this.items[newIndex];
|
// 1. Get the collection item ID
|
||||||
|
const collectionItemId = itemElement.getAttribute('data-collection-item-id');
|
||||||
|
if (!collectionItemId) {
|
||||||
|
console.error('❌ Cannot move item: missing data-collection-item-id attribute');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Move in DOM
|
// 2. Update position in database first (backend-first approach)
|
||||||
if (direction === 'up') {
|
// Note: Backend expects 0-based positions, but we may need to adjust based on backend implementation
|
||||||
this.container.insertBefore(itemElement, targetItem.element);
|
const success = await this.apiClient.updateCollectionItemPosition(this.collectionId, collectionItemId, newIndex);
|
||||||
} else {
|
if (!success) {
|
||||||
this.container.insertBefore(itemElement, targetItem.element.nextSibling);
|
alert('Failed to update item position in database. Please try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Get the target position in DOM
|
||||||
|
const targetItem = this.items[newIndex];
|
||||||
|
|
||||||
|
// 4. Move in DOM
|
||||||
|
if (direction === 'up') {
|
||||||
|
this.container.insertBefore(itemElement, targetItem.element);
|
||||||
|
} else {
|
||||||
|
this.container.insertBefore(itemElement, targetItem.element.nextSibling);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Update items array
|
||||||
|
[this.items[currentIndex], this.items[newIndex]] = [this.items[newIndex], this.items[currentIndex]];
|
||||||
|
this.items[currentIndex].index = currentIndex;
|
||||||
|
this.items[newIndex].index = newIndex;
|
||||||
|
|
||||||
|
// 6. Update all item controls
|
||||||
|
this.updateAllItemControls();
|
||||||
|
|
||||||
|
// 7. Trigger site enhancement to update static files
|
||||||
|
await this.apiClient.enhanceSite();
|
||||||
|
|
||||||
|
console.log('✅ Item moved successfully:', collectionItemId, '→ position', newIndex);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to move collection item:', error);
|
||||||
|
alert('Failed to move item. Please try again.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update items array
|
|
||||||
[this.items[currentIndex], this.items[newIndex]] = [this.items[newIndex], this.items[currentIndex]];
|
|
||||||
this.items[currentIndex].index = currentIndex;
|
|
||||||
this.items[newIndex].index = newIndex;
|
|
||||||
|
|
||||||
// Update all item controls
|
|
||||||
this.updateAllItemControls();
|
|
||||||
|
|
||||||
console.log('✅ Item moved successfully');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user