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.
This commit is contained in:
2025-10-30 22:06:44 +01:00
parent 00255cb105
commit 900f91bc25
7 changed files with 313 additions and 89 deletions

View File

@@ -204,6 +204,10 @@ func (c *HTTPClient) CreateCollectionItemAtomic(ctx context.Context, siteID, col
return nil, fmt.Errorf("collection operations not implemented in HTTPClient") 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) { 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") return nil, fmt.Errorf("content update operations not implemented in HTTPClient")
} }

View File

@@ -282,6 +282,23 @@ func (r *PostgreSQLRepository) CreateCollectionItemAtomic(ctx context.Context, s
return nil, fmt.Errorf("CreateCollectionItemAtomic not yet implemented for PostgreSQL") 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 // UpdateContent updates an existing content item
func (r *PostgreSQLRepository) UpdateContent(ctx context.Context, siteID, contentID, htmlContent, lastEditedBy string) (*ContentItem, error) { func (r *PostgreSQLRepository) UpdateContent(ctx context.Context, siteID, contentID, htmlContent, lastEditedBy string) (*ContentItem, error) {
content, err := r.queries.UpdateContent(ctx, postgresql.UpdateContentParams{ content, err := r.queries.UpdateContent(ctx, postgresql.UpdateContentParams{

View File

@@ -22,6 +22,7 @@ type ContentRepository interface {
CreateCollectionTemplate(ctx context.Context, siteID, collectionID, name, htmlTemplate string, isDefault bool) (*CollectionTemplateItem, error) CreateCollectionTemplate(ctx context.Context, siteID, collectionID, name, htmlTemplate string, isDefault bool) (*CollectionTemplateItem, error)
CreateCollectionItem(ctx context.Context, siteID, collectionID, itemID string, templateID int, htmlContent string, position int, lastEditedBy string) (*CollectionItemWithTemplate, error) CreateCollectionItem(ctx context.Context, siteID, collectionID, itemID string, templateID int, htmlContent string, position int, lastEditedBy string) (*CollectionItemWithTemplate, error)
CreateCollectionItemAtomic(ctx context.Context, siteID, collectionID string, templateID int, lastEditedBy string) (*CollectionItemWithTemplate, error) CreateCollectionItemAtomic(ctx context.Context, siteID, collectionID string, templateID int, lastEditedBy string) (*CollectionItemWithTemplate, error)
GetMaxPosition(ctx context.Context, siteID, collectionID string) (int, error)
ReorderCollectionItems(ctx context.Context, siteID, collectionID string, items []CollectionItemPosition, lastEditedBy string) error ReorderCollectionItems(ctx context.Context, siteID, collectionID string, items []CollectionItemPosition, lastEditedBy string) error
// Transaction support // Transaction support

View File

@@ -287,6 +287,23 @@ func (r *SQLiteRepository) CreateCollectionItemAtomic(ctx context.Context, siteI
return nil, fmt.Errorf("CreateCollectionItemAtomic not yet implemented for SQLite") 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 // UpdateContent updates an existing content item
func (r *SQLiteRepository) UpdateContent(ctx context.Context, siteID, contentID, htmlContent, lastEditedBy string) (*ContentItem, error) { func (r *SQLiteRepository) UpdateContent(ctx context.Context, siteID, contentID, htmlContent, lastEditedBy string) (*ContentItem, error) {
content, err := r.queries.UpdateContent(ctx, sqlite.UpdateContentParams{ content, err := r.queries.UpdateContent(ctx, sqlite.UpdateContentParams{

View File

@@ -246,12 +246,9 @@ func (e *ContentEngine) processChildElementsAsContent(childElement *html.Node, s
// Set the data-content-id attribute // Set the data-content-id attribute
SetAttribute(n, "data-content-id", contentID) SetAttribute(n, "data-content-id", contentID)
// Clear content - this will be hydrated during reconstruction // Keep content for initial display - don't clear it
for child := n.FirstChild; child != nil; { // The content is already stored in the database and will be available for editing
next := child.NextSibling // Preserving content ensures elements have height and are clickable
n.RemoveChild(child)
child = next
}
} }
}) })
@@ -271,12 +268,8 @@ func (e *ContentEngine) generateStructuralTemplateFromChild(childElement *html.N
// Set the data-content-id attribute // Set the data-content-id attribute
SetAttribute(n, "data-content-id", contentEntries[entryIndex].ID) SetAttribute(n, "data-content-id", contentEntries[entryIndex].ID)
// Clear content - this will be hydrated during reconstruction // Keep content for structural template - ensures elements have height and are clickable
for child := n.FirstChild; child != nil; { // Content is stored separately in database for editing
next := child.NextSibling
n.RemoveChild(child)
child = next
}
entryIndex++ entryIndex++
} }
@@ -352,9 +345,17 @@ func (e *ContentEngine) CreateCollectionItemFromTemplate(
return nil, fmt.Errorf("failed to generate structural template: %w", err) 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(), collectionItem, err := e.client.CreateCollectionItem(context.Background(),
siteID, collectionID, itemID, templateID, structuralTemplate, 0, lastEditedBy, siteID, collectionID, itemID, templateID, structuralTemplate, nextPosition, lastEditedBy,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create collection item: %w", err) return nil, fmt.Errorf("failed to create collection item: %w", err)

View File

@@ -814,6 +814,176 @@ body:not(.insertr-edit-mode) .insertr-editing-hover::after {
border-radius: var(--insertr-border-radius); 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 */ /* Add button positioned in top right of container */
.insertr-add-btn { .insertr-add-btn {
position: absolute; position: absolute;

View File

@@ -509,117 +509,86 @@ export class CollectionManager {
// Create modal overlay // Create modal overlay
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.className = 'insertr-modal-overlay'; 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 // Create modal content
const modal = document.createElement('div'); const modal = document.createElement('div');
modal.className = 'insertr-template-selector'; 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 = ` modal.innerHTML = `
<h3 style="margin: 0 0 16px 0; font-size: 18px; font-weight: 600;">Choose Template</h3> <h3>Choose Template</h3>
<div class="template-options"> <div class="insertr-template-options">
${templates.map(template => ` ${templates.map(template => `
<div class="template-option" data-template-id="${template.template_id}" style=" <div class="insertr-template-option ${template.is_default ? 'insertr-template-default' : ''}"
border: 2px solid #e5e7eb; data-template-id="${template.template_id}">
border-radius: 6px; <div class="insertr-template-name">
padding: 12px;
margin-bottom: 12px;
cursor: pointer;
transition: all 0.2s;
${template.is_default ? 'border-color: #3b82f6; background: #eff6ff;' : ''}
">
<div style="font-weight: 500; margin-bottom: 4px;">
${template.name}${template.is_default ? ' (default)' : ''} ${template.name}${template.is_default ? ' (default)' : ''}
</div> </div>
<div style="font-size: 14px; color: #6b7280; font-family: monospace; background: #f9fafb; padding: 8px; border-radius: 4px; overflow: hidden;"> <div class="insertr-template-preview-container">
${this.truncateHtml(template.html_template, 100)} ${this.createStyledTemplatePreview(template.html_template)}
</div> </div>
</div> </div>
`).join('')} `).join('')}
</div> </div>
<div style="display: flex; gap: 12px; justify-content: flex-end; margin-top: 20px;"> <div class="insertr-template-actions">
<button class="cancel-btn" style=" <button class="insertr-template-btn insertr-template-btn-cancel">Cancel</button>
padding: 8px 16px; <button class="insertr-template-btn insertr-template-btn-select" disabled>Add Item</button>
border: 1px solid #d1d5db;
background: white;
border-radius: 4px;
cursor: pointer;
">Cancel</button>
<button class="select-btn" style="
padding: 8px 16px;
background: #3b82f6;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
opacity: 0.5;
" disabled>Add Item</button>
</div> </div>
`; `;
let selectedTemplate = null; let selectedTemplate = null;
let userHasInteracted = false;
// Add event listeners // Add event listeners for template selection
modal.querySelectorAll('.template-option').forEach(option => { modal.querySelectorAll('.insertr-template-option').forEach(option => {
option.addEventListener('click', () => { option.addEventListener('click', (e) => {
// Remove previous selection // Mark that user has interacted
modal.querySelectorAll('.template-option').forEach(opt => { userHasInteracted = true;
opt.style.borderColor = '#e5e7eb';
opt.style.background = 'white'; // Remove previous selection from all options
modal.querySelectorAll('.insertr-template-option').forEach(opt => {
opt.classList.remove('insertr-template-selected');
}); });
// Apply selection styling // Add selection class to clicked option
option.style.borderColor = '#3b82f6'; option.classList.add('insertr-template-selected');
option.style.background = '#eff6ff';
// Find selected template // Find selected template
const templateId = parseInt(option.dataset.templateId); const templateId = parseInt(option.dataset.templateId);
selectedTemplate = templates.find(t => t.template_id === templateId); selectedTemplate = templates.find(t => t.template_id === templateId);
// Enable select button // Enable select button
const selectBtn = modal.querySelector('.select-btn'); const selectBtn = modal.querySelector('.insertr-template-btn-select');
selectBtn.disabled = false; 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); const defaultTemplate = templates.find(t => t.is_default);
if (defaultTemplate) { if (defaultTemplate) {
// Set initial selection without triggering click event
selectedTemplate = defaultTemplate;
const defaultOption = modal.querySelector(`[data-template-id="${defaultTemplate.template_id}"]`); const defaultOption = modal.querySelector(`[data-template-id="${defaultTemplate.template_id}"]`);
if (defaultOption) { 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); document.body.removeChild(overlay);
resolve(null); 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); document.body.removeChild(overlay);
resolve(selectedTemplate); 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 {string} html - HTML string
* @param {number} maxLength - Maximum character length * @param {number} maxLength - Maximum character length
* @returns {string} Truncated HTML * @returns {string} Safe preview text
*/ */
truncateHtml(html, maxLength) { createTemplatePreview(html, maxLength = 60) {
if (html.length <= maxLength) return html; try {
return html.substring(0, maxLength) + '...'; // 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})</g, (match, content) => {
// Replace long content with placeholder text
if (content.trim().length > 30) {
return '>Sample content...<';
}
return match;
});
// Wrap in a preview container with scaling
return `<div class="insertr-template-preview-render">${previewHtml}</div>`;
} catch (error) {
console.warn('Styled template preview failed:', error);
// Fallback to text preview
return `<div class="insertr-template-preview-fallback">${this.createTemplatePreview(html, 50)}</div>`;
}
} }
/** /**