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:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = `
|
||||
<h3 style="margin: 0 0 16px 0; font-size: 18px; font-weight: 600;">Choose Template</h3>
|
||||
<div class="template-options">
|
||||
<h3>Choose Template</h3>
|
||||
<div class="insertr-template-options">
|
||||
${templates.map(template => `
|
||||
<div class="template-option" data-template-id="${template.template_id}" style="
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
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;">
|
||||
<div class="insertr-template-option ${template.is_default ? 'insertr-template-default' : ''}"
|
||||
data-template-id="${template.template_id}">
|
||||
<div class="insertr-template-name">
|
||||
${template.name}${template.is_default ? ' (default)' : ''}
|
||||
</div>
|
||||
<div style="font-size: 14px; color: #6b7280; font-family: monospace; background: #f9fafb; padding: 8px; border-radius: 4px; overflow: hidden;">
|
||||
${this.truncateHtml(template.html_template, 100)}
|
||||
<div class="insertr-template-preview-container">
|
||||
${this.createStyledTemplatePreview(template.html_template)}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
<div style="display: flex; gap: 12px; justify-content: flex-end; margin-top: 20px;">
|
||||
<button class="cancel-btn" style="
|
||||
padding: 8px 16px;
|
||||
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 class="insertr-template-actions">
|
||||
<button class="insertr-template-btn insertr-template-btn-cancel">Cancel</button>
|
||||
<button class="insertr-template-btn insertr-template-btn-select" disabled>Add Item</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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})</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>`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user