From 900f91bc25c23f0aa5e21591ebfb7492fbe8f68b Mon Sep 17 00:00:00 2001 From: Joakim Date: Thu, 30 Oct 2025 22:06:44 +0100 Subject: [PATCH] Improve collection management: fix template selection UI and item positioning This commit addresses multiple collection management issues to improve user experience: ## Template Selection Modal Improvements - Replace inline styles with CSS classes for reliable visual feedback - Fix default template selection conflicts that showed multiple templates as selected - Add styled template previews that show actual CSS styling differences - Improve modal responsiveness and visual hierarchy ## Collection Item Creation Fixes - Fix empty collection items with no content/height that were unclickable - Preserve template placeholder content during item creation instead of clearing it - Implement proper positioning system using GetMaxPosition to place new items at collection end - Add position calculation logic to prevent new items from jumping to beginning ## Backend Positioning System - Add GetMaxPosition method to all repository implementations (SQLite, PostgreSQL, HTTPClient) - Update CreateCollectionItemFromTemplate to calculate correct position (maxPos + 1) - Maintain reconstruction ordering by position ASC for consistent item placement ## Frontend Template Selection - CSS class-based selection states replace problematic inline style manipulation - Template previews now render actual HTML with real page styling - Improved hover states and selection visual feedback - Fixed auto-selection interference with user interaction These changes ensure collection items appear in expected order and template selection provides clear visual feedback with actual styling previews. --- internal/db/http_client.go | 4 + internal/db/postgresql_repository.go | 17 +++ internal/db/repository.go | 1 + internal/db/sqlite_repository.go | 17 +++ internal/engine/collection.go | 29 ++--- lib/src/styles/insertr.css | 170 +++++++++++++++++++++++++++ lib/src/ui/collection-manager.js | 164 ++++++++++++++------------ 7 files changed, 313 insertions(+), 89 deletions(-) diff --git a/internal/db/http_client.go b/internal/db/http_client.go index 12d347f..3606445 100644 --- a/internal/db/http_client.go +++ b/internal/db/http_client.go @@ -204,6 +204,10 @@ func (c *HTTPClient) CreateCollectionItemAtomic(ctx context.Context, siteID, col return nil, fmt.Errorf("collection operations not implemented in HTTPClient") } +func (c *HTTPClient) GetMaxPosition(ctx context.Context, siteID, collectionID string) (int, error) { + return 0, fmt.Errorf("collection operations not implemented in HTTPClient") +} + func (c *HTTPClient) UpdateContent(ctx context.Context, siteID, contentID, htmlContent, lastEditedBy string) (*ContentItem, error) { return nil, fmt.Errorf("content update operations not implemented in HTTPClient") } diff --git a/internal/db/postgresql_repository.go b/internal/db/postgresql_repository.go index d96e1ac..32976d8 100644 --- a/internal/db/postgresql_repository.go +++ b/internal/db/postgresql_repository.go @@ -282,6 +282,23 @@ func (r *PostgreSQLRepository) CreateCollectionItemAtomic(ctx context.Context, s return nil, fmt.Errorf("CreateCollectionItemAtomic not yet implemented for PostgreSQL") } +// GetMaxPosition returns the maximum position for items in a collection +func (r *PostgreSQLRepository) GetMaxPosition(ctx context.Context, siteID, collectionID string) (int, error) { + result, err := r.queries.GetMaxPosition(ctx, postgresql.GetMaxPositionParams{ + CollectionID: collectionID, + SiteID: siteID, + }) + if err != nil { + return 0, err + } + + // Convert interface{} to int (PostgreSQL returns int64) + if maxPos, ok := result.(int64); ok { + return int(maxPos), nil + } + return 0, nil +} + // UpdateContent updates an existing content item func (r *PostgreSQLRepository) UpdateContent(ctx context.Context, siteID, contentID, htmlContent, lastEditedBy string) (*ContentItem, error) { content, err := r.queries.UpdateContent(ctx, postgresql.UpdateContentParams{ diff --git a/internal/db/repository.go b/internal/db/repository.go index 058bc98..235a948 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -22,6 +22,7 @@ type ContentRepository interface { 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) + GetMaxPosition(ctx context.Context, siteID, collectionID string) (int, error) ReorderCollectionItems(ctx context.Context, siteID, collectionID string, items []CollectionItemPosition, lastEditedBy string) error // Transaction support diff --git a/internal/db/sqlite_repository.go b/internal/db/sqlite_repository.go index b702a37..bf8fd21 100644 --- a/internal/db/sqlite_repository.go +++ b/internal/db/sqlite_repository.go @@ -287,6 +287,23 @@ func (r *SQLiteRepository) CreateCollectionItemAtomic(ctx context.Context, siteI return nil, fmt.Errorf("CreateCollectionItemAtomic not yet implemented for SQLite") } +// GetMaxPosition returns the maximum position for items in a collection +func (r *SQLiteRepository) GetMaxPosition(ctx context.Context, siteID, collectionID string) (int, error) { + result, err := r.queries.GetMaxPosition(ctx, sqlite.GetMaxPositionParams{ + CollectionID: collectionID, + SiteID: siteID, + }) + if err != nil { + return 0, err + } + + // Convert interface{} to int (SQLite returns int64) + if maxPos, ok := result.(int64); ok { + return int(maxPos), nil + } + return 0, nil +} + // UpdateContent updates an existing content item func (r *SQLiteRepository) UpdateContent(ctx context.Context, siteID, contentID, htmlContent, lastEditedBy string) (*ContentItem, error) { content, err := r.queries.UpdateContent(ctx, sqlite.UpdateContentParams{ diff --git a/internal/engine/collection.go b/internal/engine/collection.go index 1db91cf..6b08f7e 100644 --- a/internal/engine/collection.go +++ b/internal/engine/collection.go @@ -246,12 +246,9 @@ func (e *ContentEngine) processChildElementsAsContent(childElement *html.Node, s // Set the data-content-id attribute SetAttribute(n, "data-content-id", contentID) - // Clear content - this will be hydrated during reconstruction - for child := n.FirstChild; child != nil; { - next := child.NextSibling - n.RemoveChild(child) - child = next - } + // Keep content for initial display - don't clear it + // The content is already stored in the database and will be available for editing + // Preserving content ensures elements have height and are clickable } }) @@ -271,12 +268,8 @@ func (e *ContentEngine) generateStructuralTemplateFromChild(childElement *html.N // Set the data-content-id attribute 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 - } + // Keep content for structural template - ensures elements have height and are clickable + // Content is stored separately in database for editing entryIndex++ } @@ -352,9 +345,17 @@ func (e *ContentEngine) CreateCollectionItemFromTemplate( return nil, fmt.Errorf("failed to generate structural template: %w", err) } - // Create collection item with structural template + // Get next position to place new item at the end of collection + maxPosition, err := e.client.GetMaxPosition(context.Background(), siteID, collectionID) + if err != nil { + return nil, fmt.Errorf("failed to get max position for collection %s: %w", collectionID, err) + } + nextPosition := maxPosition + 1 + fmt.Printf("🔢 Max position for collection %s: %d, assigning new item position: %d\n", collectionID, maxPosition, nextPosition) + + // Create collection item with structural template at end position collectionItem, err := e.client.CreateCollectionItem(context.Background(), - siteID, collectionID, itemID, templateID, structuralTemplate, 0, lastEditedBy, + siteID, collectionID, itemID, templateID, structuralTemplate, nextPosition, lastEditedBy, ) if err != nil { return nil, fmt.Errorf("failed to create collection item: %w", err) diff --git a/lib/src/styles/insertr.css b/lib/src/styles/insertr.css index ab96ba6..8466a40 100644 --- a/lib/src/styles/insertr.css +++ b/lib/src/styles/insertr.css @@ -814,6 +814,176 @@ body:not(.insertr-edit-mode) .insertr-editing-hover::after { border-radius: var(--insertr-border-radius); } +/* ================================================================= + TEMPLATE SELECTION MODAL STYLES + ================================================================= */ + +/* Template selection modal container */ +.insertr-template-selector { + background: white; + border-radius: 8px; + padding: 24px; + max-width: 600px; + width: 95%; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); + font-family: var(--insertr-font-family); + margin: 20px; +} + +.insertr-template-selector h3 { + margin: 0 0 16px 0; + font-size: 18px; + font-weight: 600; + color: var(--insertr-text-primary); +} + +/* Template options container */ +.insertr-template-options { + margin-bottom: 20px; +} + +/* Individual template option */ +.insertr-template-option { + border: 2px solid var(--insertr-border-color); + border-radius: 6px; + padding: 12px; + margin-bottom: 12px; + cursor: pointer; + transition: all 0.2s ease; + background: var(--insertr-bg-primary); +} + +/* Default template styling - subtle indicator only */ +.insertr-template-option.insertr-template-default { + border-left: 3px solid var(--insertr-primary); + background: #f8fafc; +} + +/* Selected template styling - prominent selection indicator */ +.insertr-template-option.insertr-template-selected { + border-color: var(--insertr-primary) !important; + background: #eff6ff !important; + box-shadow: 0 0 0 2px var(--insertr-primary) !important; +} + +/* Default template when selected - override default styling */ +.insertr-template-option.insertr-template-default.insertr-template-selected { + border-left: 2px solid var(--insertr-primary) !important; + border-color: var(--insertr-primary) !important; + background: #eff6ff !important; + box-shadow: 0 0 0 2px var(--insertr-primary) !important; +} + +/* Hover state for template options (not selected) */ +.insertr-template-option:hover:not(.insertr-template-selected) { + border-color: var(--insertr-text-secondary); + background: var(--insertr-bg-secondary); + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +/* Default template hover (when not selected) */ +.insertr-template-option.insertr-template-default:hover:not(.insertr-template-selected) { + background: #f1f5f9; + border-left-color: var(--insertr-primary-hover); +} + +/* Selected template hover - enhance the selected state */ +.insertr-template-option.insertr-template-selected:hover { + background: #dbeafe !important; + border-color: var(--insertr-primary-hover) !important; + box-shadow: 0 0 0 2px var(--insertr-primary-hover) !important; +} + +/* Template name and info */ +.insertr-template-name { + font-weight: 500; + margin-bottom: 4px; + color: var(--insertr-text-primary); +} + +/* Template preview container */ +.insertr-template-preview-container { + background: var(--insertr-bg-secondary); + border: 1px solid var(--insertr-border-color); + border-radius: 4px; + padding: 12px; + overflow: hidden; + max-height: 120px; + position: relative; +} + +/* Styled template preview - preserves original styling */ +.insertr-template-preview-render { + pointer-events: none; + overflow: hidden; + max-height: 100px; +} + +/* Style placeholder content in previews */ +.insertr-preview-content { + display: inline-block !important; +} + +/* Fallback preview for errors */ +.insertr-template-preview-fallback { + font-size: 13px; + color: var(--insertr-text-secondary); + font-family: var(--insertr-font-family); + font-style: italic; + padding: 8px; +} + +/* Modal actions */ +.insertr-template-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--insertr-border-color); +} + +.insertr-template-btn { + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-family: var(--insertr-font-family); + font-size: var(--insertr-font-size-base); + font-weight: 500; + transition: all 0.2s ease; +} + +.insertr-template-btn-cancel { + border: 1px solid var(--insertr-border-color); + background: var(--insertr-bg-primary); + color: var(--insertr-text-primary); +} + +.insertr-template-btn-cancel:hover { + background: var(--insertr-bg-secondary); + border-color: var(--insertr-text-secondary); +} + +.insertr-template-btn-select { + background: var(--insertr-primary); + color: white; + border: none; + opacity: 0.5; + cursor: not-allowed; +} + +.insertr-template-btn-select:not(:disabled) { + opacity: 1; + cursor: pointer; +} + +.insertr-template-btn-select:not(:disabled):hover { + background: var(--insertr-primary-hover); +} + /* Add button positioned in top right of container */ .insertr-add-btn { position: absolute; diff --git a/lib/src/ui/collection-manager.js b/lib/src/ui/collection-manager.js index 205fbe4..1c12b5d 100644 --- a/lib/src/ui/collection-manager.js +++ b/lib/src/ui/collection-manager.js @@ -509,117 +509,86 @@ export class CollectionManager { // 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 + // Create modal HTML using CSS classes modal.innerHTML = ` -

Choose Template

-
+

Choose Template

+
${templates.map(template => ` -
-
+
+
${template.name}${template.is_default ? ' (default)' : ''}
-
- ${this.truncateHtml(template.html_template, 100)} +
+ ${this.createStyledTemplatePreview(template.html_template)}
`).join('')}
-
- - +
+ +
`; let selectedTemplate = null; + let userHasInteracted = false; - // 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'; + // Add event listeners for template selection + modal.querySelectorAll('.insertr-template-option').forEach(option => { + option.addEventListener('click', (e) => { + // Mark that user has interacted + userHasInteracted = true; + + // Remove previous selection from all options + modal.querySelectorAll('.insertr-template-option').forEach(opt => { + opt.classList.remove('insertr-template-selected'); }); - // Apply selection styling - option.style.borderColor = '#3b82f6'; - option.style.background = '#eff6ff'; + // Add selection class to clicked option + option.classList.add('insertr-template-selected'); // 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'); + const selectBtn = modal.querySelector('.insertr-template-btn-select'); selectBtn.disabled = false; - selectBtn.style.opacity = '1'; + + console.log('🎯 Template selected by user:', selectedTemplate.name); }); }); - // Auto-select default template after all event listeners are set up + // Auto-select default template only if user hasn't interacted const defaultTemplate = templates.find(t => t.is_default); if (defaultTemplate) { + // Set initial selection without triggering click event + selectedTemplate = defaultTemplate; const defaultOption = modal.querySelector(`[data-template-id="${defaultTemplate.template_id}"]`); if (defaultOption) { - defaultOption.click(); + // Apply visual selection without click event + defaultOption.classList.add('insertr-template-selected'); + const selectBtn = modal.querySelector('.insertr-template-btn-select'); + selectBtn.disabled = false; + console.log('🎯 Default template pre-selected:', defaultTemplate.name); } } - modal.querySelector('.cancel-btn').addEventListener('click', () => { + // Cancel button handler + modal.querySelector('.insertr-template-btn-cancel').addEventListener('click', () => { document.body.removeChild(overlay); resolve(null); }); - modal.querySelector('.select-btn').addEventListener('click', () => { + // Select button handler + modal.querySelector('.insertr-template-btn-select').addEventListener('click', () => { + console.log('✅ Adding item with template:', selectedTemplate ? selectedTemplate.name : 'none'); document.body.removeChild(overlay); resolve(selectedTemplate); }); @@ -638,14 +607,59 @@ export class CollectionManager { } /** - * Truncate HTML for preview + * Create safe template preview text (no HTML truncation) * @param {string} html - HTML string * @param {number} maxLength - Maximum character length - * @returns {string} Truncated HTML + * @returns {string} Safe preview text */ - truncateHtml(html, maxLength) { - if (html.length <= maxLength) return html; - return html.substring(0, maxLength) + '...'; + createTemplatePreview(html, maxLength = 60) { + try { + // Create a temporary DOM element to safely extract text + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = html; + + // Extract just the text content + const textContent = tempDiv.textContent || tempDiv.innerText || ''; + + // Truncate the text (not HTML) + if (textContent.length <= maxLength) { + return textContent; + } + + return textContent.substring(0, maxLength).trim() + '...'; + } catch (error) { + // Fallback to safer extraction + console.warn('Template preview extraction failed:', error); + return html.replace(/<[^>]*>/g, '').substring(0, maxLength) + '...'; + } + } + + /** + * Create styled template preview that shows actual template styling + * @param {string} html - HTML template string + * @returns {string} HTML preview with actual styles + */ + createStyledTemplatePreview(html) { + try { + // Clean the HTML and replace .insertr elements with placeholder content + let previewHtml = html + .replace(/class="insertr"/g, 'class="insertr-preview-content"') + .replace(/class="([^"]*\s+)?insertr(\s+[^"]*)?"/g, 'class="$1insertr-preview-content$2"') + .replace(/>([^<]{0,50}) { + // Replace long content with placeholder text + if (content.trim().length > 30) { + return '>Sample content...<'; + } + return match; + }); + + // Wrap in a preview container with scaling + return `
${previewHtml}
`; + } catch (error) { + console.warn('Styled template preview failed:', error); + // Fallback to text preview + return `
${this.createTemplatePreview(html, 50)}
`; + } } /**