diff --git a/collection-example.html b/collection-example.html new file mode 100644 index 0000000..b9a9f08 --- /dev/null +++ b/collection-example.html @@ -0,0 +1,20 @@ + +
+ + + +
+ +

Card 1

+

This is a lead paragraph

+

This is the main paragraph that could be longer and give additional info

+
+ + +
+

Card 2

+

This is the main paragraph that could be longer and give additional info

+ +
+
+ diff --git a/demos/simple/index.html b/demos/simple/index.html index 008948d..08f6d5f 100644 --- a/demos/simple/index.html +++ b/demos/simple/index.html @@ -142,6 +142,29 @@ .testimonial-item cite:before { content: "— "; } + + /* Styling variants for template differentiation testing */ + .testimonial-item.featured { + border-left: 4px solid #3b82f6; + background: #eff6ff; + } + + .testimonial-item.compact { + padding: 1rem; + } + + .testimonial-item.dark { + background: #1f2937; + color: white; + } + + .testimonial-item.dark blockquote { + color: #e5e7eb; + } + + .testimonial-item.dark cite { + color: #9ca3af; + } @@ -225,11 +248,11 @@
Not all that is gold does glitter
Tolkien -
+ -
+
Innovation distinguishes between a leader and a follower
Steve Jobs
diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 1148685..ee5a09b 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -405,6 +405,30 @@ func (h *ContentHandler) GetCollectionItems(w http.ResponseWriter, r *http.Reque json.NewEncoder(w).Encode(items) } +// GetCollectionTemplates handles GET /api/collections/{id}/templates +func (h *ContentHandler) GetCollectionTemplates(w http.ResponseWriter, r *http.Request) { + collectionID := chi.URLParam(r, "id") + siteID := r.URL.Query().Get("site_id") + + if siteID == "" { + http.Error(w, "site_id parameter is required", http.StatusBadRequest) + return + } + + templates, err := h.repository.GetCollectionTemplates(context.Background(), siteID, collectionID) + if err != nil { + http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError) + return + } + + response := map[string]interface{}{ + "templates": templates, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + // CreateCollectionItem handles POST /api/collections/{id}/items func (h *ContentHandler) CreateCollectionItem(w http.ResponseWriter, r *http.Request) { userInfo, authErr := h.authService.ExtractUserFromRequest(r) @@ -437,15 +461,29 @@ func (h *ContentHandler) CreateCollectionItem(w http.ResponseWriter, r *http.Req } if req.TemplateID == 0 { - req.TemplateID = 1 // Default to first template + http.Error(w, "template_id is required", http.StatusBadRequest) + return } - // Use atomic collection item creation from repository - createdItem, err := h.repository.CreateCollectionItemAtomic( - context.Background(), + // Get the specific template by ID + selectedTemplate, err := h.repository.GetCollectionTemplate(context.Background(), req.TemplateID) + if err != nil { + http.Error(w, fmt.Sprintf("Template %d not found: %v", req.TemplateID, err), http.StatusBadRequest) + return + } + + // Verify template belongs to the requested collection and site + if selectedTemplate.CollectionID != req.CollectionID || selectedTemplate.SiteID != req.SiteID { + http.Error(w, fmt.Sprintf("Template %d not found in collection %s", req.TemplateID, req.CollectionID), http.StatusBadRequest) + return + } + + // Use engine's unified collection item creation + createdItem, err := h.engine.CreateCollectionItemFromTemplate( req.SiteID, req.CollectionID, req.TemplateID, + selectedTemplate.HTMLTemplate, userInfo.ID, ) if err != nil { @@ -487,10 +525,10 @@ func (h *ContentHandler) RegisterRoutes(r chi.Router) { // COLLECTION MANAGEMENT - Groups of related content // ============================================================================= r.Route("/collections", func(r chi.Router) { - // Public routes - r.Get("/", h.GetAllCollections) // GET /api/collections?site_id=X - r.Get("/{id}", h.GetCollection) // GET /api/collections/{id}?site_id=X - r.Get("/{id}/items", h.GetCollectionItems) // GET /api/collections/{id}/items?site_id=X + r.Get("/", h.GetAllCollections) // GET /api/collections?site_id=X + r.Get("/{id}", h.GetCollection) // GET /api/collections/{id}?site_id=X + r.Get("/{id}/items", h.GetCollectionItems) // GET /api/collections/{id}/items?site_id=X + r.Get("/{id}/templates", h.GetCollectionTemplates) // GET /api/collections/{id}/templates?site_id=X // Protected routes r.Group(func(r chi.Router) { diff --git a/internal/db/http_client.go b/internal/db/http_client.go index a0ad514..12d347f 100644 --- a/internal/db/http_client.go +++ b/internal/db/http_client.go @@ -192,6 +192,10 @@ func (c *HTTPClient) GetCollectionTemplates(ctx context.Context, siteID, collect return nil, fmt.Errorf("collection operations not implemented in HTTPClient") } +func (c *HTTPClient) GetCollectionTemplate(ctx context.Context, templateID int) (*CollectionTemplateItem, error) { + return nil, fmt.Errorf("collection operations not implemented in HTTPClient") +} + func (c *HTTPClient) CreateCollectionItem(ctx context.Context, siteID, collectionID, itemID string, templateID int, htmlContent string, position int, lastEditedBy string) (*CollectionItemWithTemplate, error) { return nil, fmt.Errorf("collection operations not implemented in HTTPClient") } diff --git a/internal/db/postgresql_repository.go b/internal/db/postgresql_repository.go index 80aa849..d96e1ac 100644 --- a/internal/db/postgresql_repository.go +++ b/internal/db/postgresql_repository.go @@ -214,6 +214,24 @@ func (r *PostgreSQLRepository) GetCollectionTemplates(ctx context.Context, siteI return result, nil } +// GetCollectionTemplate retrieves a single template by ID +func (r *PostgreSQLRepository) GetCollectionTemplate(ctx context.Context, templateID int) (*CollectionTemplateItem, error) { + template, err := r.queries.GetCollectionTemplate(ctx, int32(templateID)) + if err != nil { + return nil, err + } + + result := &CollectionTemplateItem{ + TemplateID: int(template.TemplateID), + CollectionID: template.CollectionID, + SiteID: template.SiteID, + Name: template.Name, + HTMLTemplate: template.HtmlTemplate, + IsDefault: template.IsDefault, // PostgreSQL uses BOOLEAN + } + return result, nil +} + // CreateCollectionItem creates a new collection item func (r *PostgreSQLRepository) CreateCollectionItem(ctx context.Context, siteID, collectionID, itemID string, templateID int, htmlContent string, position int, lastEditedBy string) (*CollectionItemWithTemplate, error) { item, err := r.queries.CreateCollectionItem(ctx, postgresql.CreateCollectionItemParams{ diff --git a/internal/db/repository.go b/internal/db/repository.go index 562b6fe..058bc98 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -18,6 +18,7 @@ type ContentRepository interface { CreateCollection(ctx context.Context, siteID, collectionID, containerHTML, lastEditedBy string) (*CollectionItem, error) GetCollectionItems(ctx context.Context, siteID, collectionID string) ([]CollectionItemWithTemplate, error) GetCollectionTemplates(ctx context.Context, siteID, collectionID string) ([]CollectionTemplateItem, error) + GetCollectionTemplate(ctx context.Context, templateID int) (*CollectionTemplateItem, error) CreateCollectionTemplate(ctx context.Context, siteID, collectionID, name, htmlTemplate string, isDefault bool) (*CollectionTemplateItem, error) CreateCollectionItem(ctx context.Context, siteID, collectionID, itemID string, templateID int, htmlContent string, position int, lastEditedBy string) (*CollectionItemWithTemplate, error) CreateCollectionItemAtomic(ctx context.Context, siteID, collectionID string, templateID int, lastEditedBy string) (*CollectionItemWithTemplate, error) diff --git a/internal/db/sqlite_repository.go b/internal/db/sqlite_repository.go index 991617e..b702a37 100644 --- a/internal/db/sqlite_repository.go +++ b/internal/db/sqlite_repository.go @@ -219,6 +219,24 @@ func (r *SQLiteRepository) GetCollectionTemplates(ctx context.Context, siteID, c return result, nil } +// GetCollectionTemplate retrieves a single template by ID +func (r *SQLiteRepository) GetCollectionTemplate(ctx context.Context, templateID int) (*CollectionTemplateItem, error) { + template, err := r.queries.GetCollectionTemplate(ctx, int64(templateID)) + if err != nil { + return nil, err + } + + result := &CollectionTemplateItem{ + TemplateID: int(template.TemplateID), + CollectionID: template.CollectionID, + SiteID: template.SiteID, + Name: template.Name, + HTMLTemplate: template.HtmlTemplate, + IsDefault: template.IsDefault != 0, // SQLite uses INTEGER for boolean + } + return result, nil +} + // CreateCollectionItem creates a new collection item func (r *SQLiteRepository) CreateCollectionItem(ctx context.Context, siteID, collectionID, itemID string, templateID int, htmlContent string, position int, lastEditedBy string) (*CollectionItemWithTemplate, error) { item, err := r.queries.CreateCollectionItem(ctx, sqlite.CreateCollectionItemParams{ diff --git a/internal/engine/collection.go b/internal/engine/collection.go index a79fe74..1db91cf 100644 --- a/internal/engine/collection.go +++ b/internal/engine/collection.go @@ -3,7 +3,9 @@ package engine import ( "context" "fmt" + "sort" "strings" + "time" "github.com/insertr/insertr/internal/db" "golang.org/x/net/html" @@ -79,21 +81,35 @@ func (e *ContentEngine) extractAndStoreTemplatesAndItems(collectionNode *html.No return nil } - // Create templates for each unique child structure + // Create templates for each unique child structure and styling (deduplicated) + seenTemplates := make(map[string]int) // templateSignature -> templateID templateIndex := 0 for child := collectionNode.FirstChild; child != nil; child = child.NextSibling { if child.Type == html.ElementNode { - templateName := fmt.Sprintf("template_%d", templateIndex+1) templateHTML := e.extractCleanTemplate(child) - isDefault := templateIndex == 0 + templateSignature := e.generateTemplateSignature(child) - template, err := e.client.CreateCollectionTemplate(context.Background(), siteID, collectionID, templateName, templateHTML, isDefault) - if err != nil { - return fmt.Errorf("failed to create template %s: %w", templateName, err) + // Check if we've already seen this exact template structure + styling + if existingTemplateID, exists := seenTemplates[templateSignature]; exists { + // Reuse existing template + templateIDs = append(templateIDs, existingTemplateID) + fmt.Printf("✅ Reusing existing template for identical structure+styling in collection %s\n", collectionID) + } else { + // Create new template for unique structure+styling combination + templateName := e.generateTemplateNameFromSignature(child, templateIndex+1) + isDefault := templateIndex == 0 + + template, err := e.client.CreateCollectionTemplate(context.Background(), siteID, collectionID, templateName, templateHTML, isDefault) + if err != nil { + return fmt.Errorf("failed to create template %s: %w", templateName, err) + } + + // Store the mapping and append to results + seenTemplates[templateSignature] = template.TemplateID + templateIDs = append(templateIDs, template.TemplateID) + templateIndex++ + fmt.Printf("✅ Created new template '%s' for collection %s\n", templateName, collectionID) } - templateIDs = append(templateIDs, template.TemplateID) - templateIndex++ - fmt.Printf("✅ Created template '%s' for collection %s\n", templateName, collectionID) } } @@ -212,8 +228,8 @@ func (e *ContentEngine) processChildElementsAsContent(childElement *html.Node, s // Walk through the child element and find .insertr elements e.walkNodes(childElement, func(n *html.Node) { if n.Type == html.ElementNode && e.hasClass(n, "insertr") { - // Generate content ID for this .insertr element - contentID := e.idGenerator.Generate(n, "collection-item") + // Generate content ID for this .insertr element, including item ID for uniqueness + contentID := e.idGenerator.Generate(n, fmt.Sprintf("%s-content", itemID)) // Extract the content htmlContent := e.extractHTMLContent(n) @@ -312,8 +328,9 @@ func (e *ContentEngine) CreateCollectionItemFromTemplate( return nil, fmt.Errorf("failed to create virtual element: %w", err) } - // Generate unique item ID using unified generator with collection context - itemID := e.idGenerator.Generate(virtualElement, "collection-item") + // Generate unique item ID using unified generator with collection context + timestamp for uniqueness + baseID := e.idGenerator.Generate(virtualElement, "collection-item") + itemID := fmt.Sprintf("%s-%d", baseID, time.Now().UnixNano()%1000000) // Add 6-digit unique suffix // Process any .insertr elements within the template and store as content contentEntries, err := e.processChildElementsAsContent(virtualElement, siteID, itemID) @@ -456,3 +473,95 @@ func (e *ContentEngine) cloneNode(node *html.Node) *html.Node { return cloned } + +// generateTemplateSignature creates a unique signature for template comparison +// This combines structural HTML + class-based styling differences +func (e *ContentEngine) generateTemplateSignature(element *html.Node) string { + // Get the clean template HTML (structure) + structuralHTML := e.extractCleanTemplate(element) + + // Extract class-based styling signature + stylingSignature := e.extractClassSignature(element) + + // Combine both for a unique signature + return fmt.Sprintf("%s|%s", structuralHTML, stylingSignature) +} + +// extractClassSignature recursively extracts and normalizes class attributes +func (e *ContentEngine) extractClassSignature(element *html.Node) string { + var signature strings.Builder + + e.walkNodes(element, func(n *html.Node) { + if n.Type == html.ElementNode { + // Get classes for this element + classes := GetClasses(n) + if len(classes) > 0 { + // Sort classes for consistent comparison + sortedClasses := make([]string, len(classes)) + copy(sortedClasses, classes) + sort.Strings(sortedClasses) + + // Add to signature: element[class1,class2,...] + signature.WriteString(fmt.Sprintf("%s[%s];", n.Data, strings.Join(sortedClasses, ","))) + } else { + // Element with no classes + signature.WriteString(fmt.Sprintf("%s[];", n.Data)) + } + } + }) + + return signature.String() +} + +// generateTemplateNameFromSignature creates human-readable template names +func (e *ContentEngine) generateTemplateNameFromSignature(element *html.Node, fallbackIndex int) string { + // Extract root element classes for naming + rootClasses := GetClasses(element) + + if len(rootClasses) > 0 { + // Find distinctive classes (exclude common structural and base classes) + var distinctiveClasses []string + commonClasses := map[string]bool{ + "insertr": true, "insertr-add": true, + // Common base classes that don't indicate variants + "testimonial-item": true, "card": true, "item": true, "post": true, + "container": true, "wrapper": true, "content": true, + } + + for _, class := range rootClasses { + if !commonClasses[class] { + distinctiveClasses = append(distinctiveClasses, class) + } + } + + if len(distinctiveClasses) > 0 { + // Use distinctive classes for naming + name := strings.Join(distinctiveClasses, "_") + // Capitalize and clean up + name = strings.ReplaceAll(name, "-", "_") + if len(name) > 20 { + name = name[:20] + } + return strings.Title(strings.ToLower(name)) + } else if len(rootClasses) > 1 { + // If only common classes, use the last non-insertr class + for i := len(rootClasses) - 1; i >= 0; i-- { + if rootClasses[i] != "insertr" && rootClasses[i] != "insertr-add" { + name := strings.ReplaceAll(rootClasses[i], "-", "_") + return strings.Title(strings.ToLower(name)) + } + } + } + } + + // Fallback to numbered template + return fmt.Sprintf("template_%d", fallbackIndex) +} + +// min returns the smaller of two integers +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/lib/src/core/api-client.js b/lib/src/core/api-client.js index 298dffd..be0cace 100644 --- a/lib/src/core/api-client.js +++ b/lib/src/core/api-client.js @@ -242,6 +242,29 @@ export class ApiClient { } } + /** + * Get available templates for a collection + * @param {string} collectionId - Collection ID + * @returns {Promise} Array of collection templates + */ + async getCollectionTemplates(collectionId) { + try { + const collectionsUrl = this.getCollectionsUrl(); + const response = await fetch(`${collectionsUrl}/${collectionId}/templates?site_id=${this.siteId}`); + + if (response.ok) { + const result = await response.json(); + return result.templates || []; + } else { + console.warn(`⚠️ Failed to fetch collection templates (${response.status}): ${collectionId}`); + return []; + } + } catch (error) { + console.error('Failed to fetch collection templates:', collectionId, error); + return []; + } + } + /** * Reorder collection items in bulk * @param {string} collectionId - Collection ID diff --git a/lib/src/ui/collection-manager.js b/lib/src/ui/collection-manager.js index adc25b2..205fbe4 100644 --- a/lib/src/ui/collection-manager.js +++ b/lib/src/ui/collection-manager.js @@ -28,6 +28,7 @@ export class CollectionManager { this.template = null; this.items = []; this.isActive = false; + this.cachedTemplates = null; // Cache for available templates // UI elements this.addButton = null; @@ -411,17 +412,31 @@ export class CollectionManager { } try { - // 1. Create collection item in database first (backend-first approach) - const templateId = 1; // Use first template by default - const collectionItem = await this.apiClient.createCollectionItem(this.collectionId, templateId); + // 1. Get available templates for this collection + const templates = await this.getAvailableTemplates(); + if (!templates || templates.length === 0) { + console.error('❌ No templates available for collection:', this.collectionId); + alert('No templates available for this collection. Please refresh the page.'); + return; + } + + // 2. Select template (auto-select if only one, otherwise present options) + const selectedTemplate = await this.selectTemplate(templates); + if (!selectedTemplate) { + console.log('Template selection cancelled by user'); + return; + } + + // 3. Create collection item in database first (backend-first approach) + const collectionItem = await this.apiClient.createCollectionItem(this.collectionId, selectedTemplate.template_id); - // 2. Create DOM element from the returned collection item data + // 4. Create DOM element from the returned collection item data const newItem = this.createItemFromCollectionData(collectionItem); - // 3. Add to DOM + // 5. Add to DOM this.container.insertBefore(newItem, this.addButton); - // 4. Update items array with backend data + // 6. Update items array with backend data const newItemData = { element: newItem, index: this.items.length, @@ -430,16 +445,16 @@ export class CollectionManager { }; this.items.push(newItemData); - // 5. Add controls to new item + // 7. Add controls to new item this.addItemControls(newItem, this.items.length - 1); - // 6. Re-initialize any .insertr elements in the new item + // 8. Re-initialize any .insertr elements in the new item this.initializeInsertrElements(newItem); - // 7. Update all item controls (indices may have changed) + // 9. Update all item controls (indices may have changed) this.updateAllItemControls(); - // 8. Trigger site enhancement to update static files + // 10. Trigger site enhancement to update static files await this.apiClient.enhanceSite(); console.log('✅ New item added successfully:', collectionItem.item_id); @@ -448,6 +463,190 @@ export class CollectionManager { alert('Failed to add new item. Please try again.'); } } + + /** + * Get available templates for this collection + * @returns {Promise} Array of template objects + */ + async getAvailableTemplates() { + try { + if (!this.cachedTemplates) { + console.log('🔍 Fetching templates for collection:', this.collectionId); + this.cachedTemplates = await this.apiClient.getCollectionTemplates(this.collectionId); + console.log('📋 Templates fetched:', this.cachedTemplates); + } + return this.cachedTemplates; + } catch (error) { + console.error('❌ Failed to fetch templates for collection:', this.collectionId, error); + return []; + } + } + + /** + * Select a template for creating new items + * @param {Array} templates - Available templates + * @returns {Promise} Selected template or null if cancelled + */ + async selectTemplate(templates) { + // Auto-select if only one template + if (templates.length === 1) { + console.log('🎯 Auto-selecting single template:', templates[0].name); + return templates[0]; + } + + // Present selection UI for multiple templates + console.log('🎨 Multiple templates available, showing selection UI'); + return this.showTemplateSelectionModal(templates); + } + + /** + * Show template selection modal + * @param {Array} templates - Available templates + * @returns {Promise} Selected template or null if cancelled + */ + async showTemplateSelectionModal(templates) { + return new Promise((resolve) => { + // Create modal overlay + const overlay = document.createElement('div'); + overlay.className = 'insertr-modal-overlay'; + overlay.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 999999; + `; + + // Create modal content + const modal = document.createElement('div'); + modal.className = 'insertr-template-selector'; + modal.style.cssText = ` + background: white; + border-radius: 8px; + padding: 24px; + max-width: 500px; + width: 90%; + max-height: 70vh; + overflow-y: auto; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); + `; + + // Create modal HTML + modal.innerHTML = ` +

Choose Template

+
+ ${templates.map(template => ` +
+
+ ${template.name}${template.is_default ? ' (default)' : ''} +
+
+ ${this.truncateHtml(template.html_template, 100)} +
+
+ `).join('')} +
+
+ + +
+ `; + + let selectedTemplate = null; + + // Add event listeners + modal.querySelectorAll('.template-option').forEach(option => { + option.addEventListener('click', () => { + // Remove previous selection + modal.querySelectorAll('.template-option').forEach(opt => { + opt.style.borderColor = '#e5e7eb'; + opt.style.background = 'white'; + }); + + // Apply selection styling + option.style.borderColor = '#3b82f6'; + option.style.background = '#eff6ff'; + + // Find selected template + const templateId = parseInt(option.dataset.templateId); + selectedTemplate = templates.find(t => t.template_id === templateId); + + // Enable select button + const selectBtn = modal.querySelector('.select-btn'); + selectBtn.disabled = false; + selectBtn.style.opacity = '1'; + }); + }); + + // Auto-select default template after all event listeners are set up + const defaultTemplate = templates.find(t => t.is_default); + if (defaultTemplate) { + const defaultOption = modal.querySelector(`[data-template-id="${defaultTemplate.template_id}"]`); + if (defaultOption) { + defaultOption.click(); + } + } + + modal.querySelector('.cancel-btn').addEventListener('click', () => { + document.body.removeChild(overlay); + resolve(null); + }); + + modal.querySelector('.select-btn').addEventListener('click', () => { + document.body.removeChild(overlay); + resolve(selectedTemplate); + }); + + // Close on overlay click + overlay.addEventListener('click', (e) => { + if (e.target === overlay) { + document.body.removeChild(overlay); + resolve(null); + } + }); + + overlay.appendChild(modal); + document.body.appendChild(overlay); + }); + } + + /** + * Truncate HTML for preview + * @param {string} html - HTML string + * @param {number} maxLength - Maximum character length + * @returns {string} Truncated HTML + */ + truncateHtml(html, maxLength) { + if (html.length <= maxLength) return html; + return html.substring(0, maxLength) + '...'; + } /** * Create a DOM element from collection item data returned by backend