diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 7eaaafe..9c6d3f6 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -1004,45 +1004,37 @@ func (h *ContentHandler) CreateCollectionItem(w http.ResponseWriter, r *http.Req if req.CollectionID == "" { req.CollectionID = collectionID } - - // Generate item ID - 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 + if req.TemplateID == 0 { + req.TemplateID = 1 // Default to first template } + // 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 { http.Error(w, fmt.Sprintf("Failed to create collection item: %v", err), http.StatusInternalServerError) 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.WriteHeader(http.StatusCreated) diff --git a/internal/content/client.go b/internal/content/client.go index cd088f6..75b6c7a 100644 --- a/internal/content/client.go +++ b/internal/content/client.go @@ -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) { 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") +} diff --git a/internal/content/mock.go b/internal/content/mock.go index c4b41be..9fe9554 100644 --- a/internal/content/mock.go +++ b/internal/content/mock.go @@ -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) { 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") +} diff --git a/internal/engine/database_client.go b/internal/engine/database_client.go index 6113c36..fa4fe0b 100644 --- a/internal/engine/database_client.go +++ b/internal/engine/database_client.go @@ -516,3 +516,36 @@ func (c *DatabaseClient) CreateCollectionItem(siteID, collectionID, itemID strin 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, + ) +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 0b359f9..28fed97 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -3,6 +3,7 @@ package engine import ( "fmt" "strings" + "time" "golang.org/x/net/html" ) @@ -385,6 +386,30 @@ func (e *ContentEngine) extractHTMLContent(node *html.Node) 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) func (e *ContentEngine) extractOriginalTemplate(node *html.Node) string { var buf strings.Builder @@ -394,6 +419,86 @@ func (e *ContentEngine) extractOriginalTemplate(node *html.Node) 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 func (e *ContentEngine) processCollection(collectionNode *html.Node, collectionID, siteID string) error { // 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) } else { - // 4. Database-first approach: Check if collection items already exist - existingItems, err := e.client.GetCollectionItems(siteID, collectionID) + // 4. Existing collection: Always reconstruct from database (database is source of truth) + err = e.reconstructCollectionItems(collectionNode, collectionID, siteID) 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 { - // 5. Collection exists but no items - store original children as initial items - err = e.storeInitialCollectionItems(collectionNode, collectionID, siteID) - 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)) - } + // Get final item count for logging + existingItems, _ := e.client.GetCollectionItems(siteID, collectionID) + fmt.Printf("✅ Reconstructed collection: %s from database (%d items)\n", collectionID, len(existingItems)) } return nil @@ -468,7 +561,7 @@ func (e *ContentEngine) extractAndStoreTemplatesAndItems(collectionNode *html.No // Extract templates from existing children and store them var templateIDs []int for i, templateElement := range templateElements { - templateHTML := e.extractOriginalTemplate(templateElement) + templateHTML := e.extractCleanTemplate(templateElement) templateName := fmt.Sprintf("template-%d", i+1) isDefault := (i == 0) // First template is default @@ -517,69 +610,49 @@ func (e *ContentEngine) reconstructCollectionItems(collectionNode *html.Node, co child = next } - // Add items from database in position order + // Add items from database in position order using unified .insertr approach for _, item := range items { - // Get the template for this item - templateHTML, exists := templateMap[item.TemplateID] - 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)) + // Parse the stored structural HTML with content IDs (no template needed for reconstruction) + structuralDoc, err := html.Parse(strings.NewReader(item.HTMLContent)) 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 } - // Find the body element and extract the template structure - var templateBody *html.Node - e.walkNodes(templateDoc, func(n *html.Node) { + var structuralBody *html.Node + e.walkNodes(structuralDoc, func(n *html.Node) { if n.Type == html.ElementNode && n.Data == "body" { - templateBody = n + structuralBody = n } }) - if templateBody != nil && templateBody.FirstChild != nil { - // Clone the template structure (first child of body) - templateNode := templateBody.FirstChild - clonedTemplate := e.cloneNode(templateNode) + if structuralBody != nil { + // Process each .insertr element using Injector pattern (unified approach) + injector := NewInjector(e.client, siteID) - // Replace the template's inner content with the stored item content - // Clear the cloned template's children - for child := clonedTemplate.FirstChild; child != nil; { - next := child.NextSibling - clonedTemplate.RemoveChild(child) - child = next - } - - // Parse and add the item's content - itemDoc, err := html.Parse(strings.NewReader(item.HTMLContent)) - if err != nil { - 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 + // Walk through structural elements and hydrate with content from content table + e.walkNodes(structuralBody, func(n *html.Node) { + if n.Type == html.ElementNode && e.hasClass(n, "insertr") { + // Get content ID from data attribute + contentID := e.getAttributeValue(n, "data-content-id") + if contentID != "" { + // Use Injector to hydrate content (unified .insertr approach) + element := &Element{Node: n, Type: "html"} + err := injector.InjectContent(element, contentID) + if err != nil { + fmt.Printf("⚠️ Failed to inject content for %s: %v\n", contentID, err) + } + } } }) - if itemBody != nil { - // Move all children from item body to cloned template - for itemChild := itemBody.FirstChild; itemChild != nil; { - next := itemChild.NextSibling - itemBody.RemoveChild(itemChild) - clonedTemplate.AppendChild(itemChild) - itemChild = next - } + // Add hydrated structural elements directly to collection (stored HTML has complete structure) + for structuralChild := structuralBody.FirstChild; structuralChild != nil; { + next := structuralChild.NextSibling + structuralBody.RemoveChild(structuralChild) + collectionNode.AppendChild(structuralChild) + structuralChild = next } - - // Add the reconstructed item to collection - collectionNode.AppendChild(clonedTemplate) } } @@ -587,6 +660,139 @@ func (e *ContentEngine) reconstructCollectionItems(collectionNode *html.Node, co 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 func (e *ContentEngine) cloneNode(node *html.Node) *html.Node { cloned := &html.Node{ @@ -614,28 +820,6 @@ func (e *ContentEngine) cloneNode(node *html.Node) *html.Node { 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 func (e *ContentEngine) storeChildrenAsCollectionItems(collectionNode *html.Node, collectionID, siteID string, templateIDs []int) error { // Find existing children elements to store as items @@ -653,24 +837,33 @@ func (e *ContentEngine) storeChildrenAsCollectionItems(collectionNode *html.Node 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 { // Generate item ID (like content ID generation) itemID := fmt.Sprintf("%s-initial-%d", collectionID, i+1) - // Extract HTML content from the child (reuse .insertr pattern) - htmlContent := e.extractHTMLContent(childElement) + // Process .insertr elements within this child (unified approach) + 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) templateID := templateIDs[i%len(templateIDs)] - // Store as collection item - _, err := e.client.CreateCollectionItem(siteID, collectionID, itemID, templateID, htmlContent, i+1, "system") + // Store structural template in collection_items (content lives in content table) + _, err = e.client.CreateCollectionItem(siteID, collectionID, itemID, templateID, structuralTemplate, i+1, "system") if err != nil { 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 diff --git a/internal/engine/types.go b/internal/engine/types.go index 9e2eae1..d304555 100644 --- a/internal/engine/types.go +++ b/internal/engine/types.go @@ -41,6 +41,14 @@ type ProcessedElement struct { 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 // This will be implemented by database clients, HTTP clients, and mock clients type ContentClient interface { @@ -56,6 +64,7 @@ type ContentClient interface { GetCollectionTemplates(siteID, collectionID string) ([]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) + CreateCollectionItemAtomic(siteID, collectionID string, templateID int, lastEditedBy string) (*CollectionItemWithTemplate, error) } // ContentItem represents a piece of content from the database diff --git a/lib/src/core/api-client.js b/lib/src/core/api-client.js index 3a7d85a..891b6b7 100644 --- a/lib/src/core/api-client.js +++ b/lib/src/core/api-client.js @@ -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} 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} 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 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} 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} 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 * @returns {string} JWT token or mock token for development diff --git a/lib/src/ui/collection-manager.js b/lib/src/ui/collection-manager.js index 54929de..8470d2c 100644 --- a/lib/src/ui/collection-manager.js +++ b/lib/src/ui/collection-manager.js @@ -17,6 +17,13 @@ export class CollectionManager { this.apiClient = apiClient; 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 this.template = null; this.items = []; @@ -26,19 +33,22 @@ export class CollectionManager { this.addButton = null; 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() { + async initialize() { if (this.isActive) return; console.log('🚀 Starting collection management for:', this.container.className); // Analyze existing content to detect template 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 this.setupEditModeDetection(); @@ -135,10 +145,12 @@ export class CollectionManager { console.log('📋 Template detected:', this.template); // Store reference to current items + // For existing items, try to extract collection item IDs if they exist this.items = children.map((child, index) => ({ element: child, index: index, - id: this.generateItemId(index) + id: this.generateItemId(index), + collectionItemId: this.extractCollectionItemId(child) })); } @@ -227,6 +239,61 @@ export class CollectionManager { generateItemId(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 @@ -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'); - if (!this.template) { - console.error('❌ No template available for creating new items'); + if (!this.template || !this.collectionId) { + console.error('❌ No template or collection ID available for creating new items'); return; } - - // Create new item from template - const newItem = this.createItemFromTemplate(); - - // Add to DOM - this.container.insertBefore(newItem, this.addButton); - - // Update items array - const newItemData = { - element: newItem, - index: this.items.length, - id: this.generateItemId(this.items.length) - }; - this.items.push(newItemData); - - // Add controls to new item - this.addItemControls(newItem, this.items.length - 1); - - // Re-initialize any .insertr elements in the new item - // This allows the existing editor system to handle individual field editing - this.initializeInsertrElements(newItem); - - // Update all item controls (indices may have changed) - this.updateAllItemControls(); - - console.log('✅ New item added successfully'); + + try { + // 1. Create collection item in database first (backend-first approach) + const templateId = 1; // Use first template by default + const collectionItem = await this.apiClient.createCollectionItem(this.collectionId, templateId); + + // 2. Create DOM element from the returned collection item data + const newItem = this.createItemFromCollectionData(collectionItem); + + // 3. Add to DOM + this.container.insertBefore(newItem, this.addButton); + + // 4. Update items array with backend data + const newItemData = { + element: newItem, + index: this.items.length, + id: collectionItem.item_id, + collectionItem: collectionItem + }; + this.items.push(newItemData); + + // 5. Add controls to new item + this.addItemControls(newItem, this.items.length - 1); + + // 6. Re-initialize any .insertr elements in the new item + this.initializeInsertrElements(newItem); + + // 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() { // 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?')) { return; } console.log('🗑️ Removing item from collection'); - - // Remove controls - const controls = this.itemControls.get(itemElement); - if (controls) { - controls.remove(); - this.itemControls.delete(itemElement); + + try { + // 1. Get the collection item ID from the element + const collectionItemId = itemElement.getAttribute('data-collection-item-id'); + if (!collectionItemId) { + 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}`); const currentIndex = this.items.findIndex(item => item.element === itemElement); @@ -556,26 +685,49 @@ export class CollectionManager { } else { return; // Can't move in that direction } - - // Get the target position in DOM - const targetItem = this.items[newIndex]; - - // Move in DOM - if (direction === 'up') { - this.container.insertBefore(itemElement, targetItem.element); - } else { - this.container.insertBefore(itemElement, targetItem.element.nextSibling); + + try { + // 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; + } + + // 2. Update position in database first (backend-first approach) + // Note: Backend expects 0-based positions, but we may need to adjust based on backend implementation + const success = await this.apiClient.updateCollectionItemPosition(this.collectionId, collectionItemId, newIndex); + if (!success) { + 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'); } /**