Implement collection item reordering with bulk operations and persistent HTML attributes

- Add bulk reorder API endpoint (PUT /api/collections/{id}/reorder) with atomic transactions
- Replace individual position updates with efficient bulk operations in frontend
- Implement unified ID generation and proper data-item-id injection during enhancement
- Fix collection item position persistence through content edit cycles
- Add optimistic UI with rollback capability for better user experience
- Update sqlc queries to include last_edited_by fields in position updates
- Remove obsolete data-content-type attributes and unify naming conventions
This commit is contained in:
2025-10-07 22:59:00 +02:00
parent c5754181f6
commit 824719f07d
13 changed files with 545 additions and 55 deletions

138
COLLECTION_REORDER_FIX.md Normal file
View File

@@ -0,0 +1,138 @@
# Collection Reordering Bug Fix - Implementation Plan
## Problem Summary
Collection item reordering fails after content edits. Position updates work at API level but don't persist through the enhancement cycle, causing items to revert to original order after page refresh.
## Root Cause Analysis
1. **Missing HTML Attributes**: `data-item-id` not injected during enhancement (around line 622 in `reconstructCollectionItems()`)
2. **API Design Flaw**: No bulk reorder endpoint, only individual item updates
3. **ID Generation Inconsistency**: Different patterns for initial items vs new items
4. **Attribute Naming Issues**: Collection containers incorrectly use `data-content-id`, content has special `collection-item-` prefixes
5. **Obsolete Attributes**: `data-content-type` no longer needed after HTML-only switch
6. **Dynamic vs Static**: Frontend dynamically injects IDs instead of getting them from enhanced HTML
## Architecture Principles
- **Static Enhancement**: All IDs should be injected during enhancement, not dynamically by JavaScript
- **Unified ID Generation**: Same deterministic system for all entities (content, collections, items)
- **Clean Separation**: Collections manage structure, `.insertr` elements manage content
- **No Backwards Compatibility**: Clean slate approach, all test data can be cleared
## Implementation Plan
### Phase 1: Backend Enhancement Fixes
#### File: `/home/fitz/insertr/internal/engine/engine.go`
- **Line 622**: Add `data-item-id` injection in `reconstructCollectionItems()`
- **Line 812**: Replace `initial-` ID pattern with unified generator
- **Line 734**: Replace timestamp-based ID pattern with unified generator
- **Line 107**: Change collection containers from `data-content-id` to `data-collection-id`
#### File: `/home/fitz/insertr/internal/engine/injector.go`
- **Line 165**: Remove obsolete `data-content-type` attribute injection
### Phase 2: Frontend API Updates
#### File: `/home/fitz/insertr/lib/src/core/api-client.js`
- Add `reorderCollection()` method for bulk operations
- Remove `updateCollectionItemPosition()` method (individual updates)
#### File: `/home/fitz/insertr/lib/src/ui/collection-manager.js`
- Remove dynamic ID injection logic (around line 286)
- Update attribute names from `data-collection-item-id` to `data-item-id`
- Replace individual position updates with bulk reorder calls
### Phase 3: Backend API Enhancement
#### File: `/home/fitz/insertr/internal/api/handlers.go`
- Add `ReorderCollection` handler for bulk position updates
- Endpoint: `PUT /api/sites/{siteId}/collections/{collectionId}/reorder`
## Expected HTML Output Change
### Before
```html
<div class="testimonials insertr-add" data-content-id="index-testimonials-3ef43e">
<div class="testimonial-item"> <!-- Missing data-item-id -->
<blockquote class="insertr" data-content-id="collection-item-blockquote-756544" data-content-type="html">
```
### After
```html
<div class="testimonials insertr-add" data-collection-id="index-testimonials-3ef43e">
<div class="testimonial-item" data-item-id="index-testimonials-3ef43e-abc123">
<blockquote class="insertr" data-content-id="index-blockquote-def456">
```
## Implementation Steps
### Day 1: Backend Enhancement
1. **Clear test data**: Clean database for fresh start
2. **Fix ID injection**: Add `data-item-id` attributes during enhancement
3. **Unify ID generation**: Replace inconsistent patterns with unified system
4. **Update attribute names**: Change `data-content-id` to `data-collection-id` for collections
5. **Remove obsolete attributes**: Clean up `data-content-type`
### Day 2: Frontend Updates
1. **Remove dynamic injection**: Stop generating IDs in JavaScript
2. **Update attribute references**: Change to new naming convention
3. **Add bulk reorder API**: Implement `reorderCollection()` method
4. **Update collection manager**: Use bulk operations instead of individual updates
### Day 3: Backend API
1. **Add bulk reorder endpoint**: Implement `ReorderCollection` handler
2. **Route configuration**: Add PUT endpoint for bulk reorder
3. **Database operations**: Efficient bulk position updates
### Day 4: Testing & Validation
1. **Test enhancement cycle**: Verify positions persist after content edits
2. **Test reordering**: Ensure drag-and-drop works correctly
3. **Test page refresh**: Confirm order maintained after reload
4. **Cross-browser testing**: Verify compatibility
## Current Status
- ✅ API endpoint accepts `site_id` as query parameter correctly
- ✅ Root cause analysis complete
- ✅ Implementation plan finalized
-**Phase 1 COMPLETE**: Backend enhancement injection implemented
- ✅ Unified ID generation across all entities (collections, items, content)
- ✅ Data attribute injection working (data-collection-id, data-item-id, data-content-id)
- ✅ Obsolete attributes removed (data-content-type)
-**Phase 2 COMPLETE**: Frontend API updates implemented
- ✅ Dynamic ID injection removed (except for new items from server)
- ✅ Attribute references updated to data-item-id
- ✅ Bulk reorderCollection() API method added
- ✅ Individual updateCollectionItemPosition() method removed
- ✅ Collection manager uses bulk reorder with optimistic UI
-**Phase 3 COMPLETE**: Backend bulk reorder endpoint implemented
- ✅ ReorderCollection handler added with transaction-based bulk updates
- ✅ PUT /api/collections/{id}/reorder route configured
- ✅ Updated sqlc queries to include last_edited_by in position updates
- ✅ Atomic transaction ensures consistency during bulk reorder operations
## Test Environment
- Server: `just dev` (http://localhost:8080)
- Test site: `/sites/simple/` with testimonials collection
- Database: Shows position conflicts (multiple items at position 1)
## Success Criteria ✅
1. ✅ Collection items maintain order after content edits (Phase 1)
2. ✅ Drag-and-drop reordering works without page refresh (Phase 2+3)
3. ✅ Order persists through full enhancement cycles (Phase 1)
4. ✅ Clean HTML output with proper attributes (Phase 1)
5. ✅ No JavaScript errors in browser console (Phase 2)
## 🎉 IMPLEMENTATION COMPLETE
All three phases have been successfully implemented:
**Phase 1**: ✅ Backend enhancement injection with unified ID generation
**Phase 2**: ✅ Frontend API updates with bulk operations and optimistic UI
**Phase 3**: ✅ Backend bulk reorder endpoint with atomic transactions
Collection item reordering now works end-to-end with persistence through content edits!
## Notes
- All test data can be cleared - no backwards compatibility needed
- Focus on clean implementation over migration
- HTML-first approach: all attributes come from server enhancement
- Performance: bulk operations instead of individual API calls

View File

@@ -0,0 +1,198 @@
# Database Architecture Refactoring - Technical Debt
## Current Problem
The current database layer violates several software architecture principles and creates maintenance issues:
### Issues with Current Implementation
1. **No Interface Abstraction**:
- `Database` struct is concrete, not interface-based
- Hard to mock for testing
- Tight coupling between handlers and database implementation
2. **Type Switching Everywhere**:
```go
switch h.database.GetDBType() {
case "sqlite3":
err = h.database.GetSQLiteQueries().SomeMethod(...)
case "postgres":
err = h.database.GetPostgreSQLQueries().SomeMethod(...)
}
```
- Repeated in every handler method
- Violates DRY principle
- Makes adding new database types difficult
3. **Mixed Responsibilities**:
- Single struct holds both SQLite and PostgreSQL queries
- Database type detection mixed with query execution
- Leaky abstraction (handlers know about database internals)
4. **Poor Testability**:
- Cannot easily mock database operations
- Hard to test database-specific edge cases
- Difficult to test transaction behavior
5. **Violates Open/Closed Principle**:
- Adding new database support requires modifying existing handlers
- Cannot extend database support without changing core business logic
## Proposed Solution
### 1. Repository Pattern with Interface
```go
// Domain-focused interface
type ContentRepository interface {
// Content operations
GetContent(ctx context.Context, siteID, contentID string) (*Content, error)
CreateContent(ctx context.Context, content *Content) (*Content, error)
UpdateContent(ctx context.Context, content *Content) (*Content, error)
DeleteContent(ctx context.Context, siteID, contentID string) error
// Collection operations
GetCollection(ctx context.Context, siteID, collectionID string) (*Collection, error)
GetCollectionItems(ctx context.Context, siteID, collectionID string) ([]*CollectionItem, error)
CreateCollectionItem(ctx context.Context, item *CollectionItem) (*CollectionItem, error)
ReorderCollectionItems(ctx context.Context, siteID, collectionID string, positions []ItemPosition) error
// Transaction support
WithTransaction(ctx context.Context, fn func(ContentRepository) error) error
}
```
### 2. Database-Specific Implementations
```go
type SQLiteRepository struct {
queries *sqlite.Queries
db *sql.DB
}
func (r *SQLiteRepository) GetContent(ctx context.Context, siteID, contentID string) (*Content, error) {
result, err := r.queries.GetContent(ctx, sqlite.GetContentParams{
ID: contentID,
SiteID: siteID,
})
if err != nil {
return nil, err
}
return r.convertSQLiteContent(result), nil
}
type PostgreSQLRepository struct {
queries *postgresql.Queries
db *sql.DB
}
func (r *PostgreSQLRepository) GetContent(ctx context.Context, siteID, contentID string) (*Content, error) {
result, err := r.queries.GetContent(ctx, postgresql.GetContentParams{
ID: contentID,
SiteID: siteID,
})
if err != nil {
return nil, err
}
return r.convertPostgreSQLContent(result), nil
}
```
### 3. Clean Handler Implementation
```go
type ContentHandler struct {
repository ContentRepository // Interface, not concrete type
authService *auth.AuthService
}
func (h *ContentHandler) GetContent(w http.ResponseWriter, r *http.Request) {
contentID := chi.URLParam(r, "id")
siteID := r.URL.Query().Get("site_id")
// No more type switching!
content, err := h.repository.GetContent(r.Context(), siteID, contentID)
if err != nil {
http.Error(w, "Content not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(content)
}
```
### 4. Dependency Injection
```go
func NewContentHandler(repo ContentRepository, authService *auth.AuthService) *ContentHandler {
return &ContentHandler{
repository: repo,
authService: authService,
}
}
// In main.go or wherever handlers are initialized
var repo ContentRepository
switch dbType {
case "sqlite3":
repo = NewSQLiteRepository(db)
case "postgres":
repo = NewPostgreSQLRepository(db)
}
contentHandler := NewContentHandler(repo, authService)
```
## Benefits of Refactoring
1. **Single Responsibility**: Each repository handles one database type
2. **Open/Closed**: Easy to add new database types without modifying existing code
3. **Testability**: Can easily mock `ContentRepository` interface
4. **Clean Code**: Handlers focus on HTTP concerns, not database specifics
5. **Type Safety**: Interface ensures all database types implement same methods
6. **Performance**: No runtime type switching overhead
## Migration Strategy
### Phase 1: Create Interface and Base Implementations
- [ ] Define `ContentRepository` interface
- [ ] Create `SQLiteRepository` implementation
- [ ] Create `PostgreSQLRepository` implementation
- [ ] Add factory function for repository creation
### Phase 2: Migrate Handlers One by One
- [ ] Start with simple handlers (GetContent, etc.)
- [ ] Update handler constructors to use interface
- [ ] Remove type switching logic
- [ ] Add unit tests with mocked repository
### Phase 3: Advanced Features
- [ ] Add transaction support to interface
- [ ] Implement batch operations efficiently
- [ ] Add connection pooling and retry logic
- [ ] Performance optimization for each database type
### Phase 4: Cleanup
- [ ] Remove old `Database` struct
- [ ] Remove database type detection logic
- [ ] Update documentation
- [ ] Add integration tests
## Estimated Effort
- **Phase 1**: 1-2 days (interface design and base implementations)
- **Phase 2**: 2-3 days (handler migration and testing)
- **Phase 3**: 1-2 days (advanced features)
- **Phase 4**: 1 day (cleanup)
**Total**: ~5-8 days for complete refactoring
## Priority
**Medium Priority** - This is architectural debt that should be addressed when:
1. Adding support for new database types
2. Significant new database operations are needed
3. Testing infrastructure needs improvement
4. Performance optimization becomes critical
The current implementation works correctly but is not maintainable long-term.

View File

@@ -238,6 +238,9 @@ func runServe(cmd *cobra.Command, args []string) {
collectionRouter.Post("/{id}/items", contentHandler.CreateCollectionItem) collectionRouter.Post("/{id}/items", contentHandler.CreateCollectionItem)
collectionRouter.Put("/{id}/items/{item_id}", contentHandler.UpdateCollectionItem) collectionRouter.Put("/{id}/items/{item_id}", contentHandler.UpdateCollectionItem)
collectionRouter.Delete("/{id}/items/{item_id}", contentHandler.DeleteCollectionItem) collectionRouter.Delete("/{id}/items/{item_id}", contentHandler.DeleteCollectionItem)
// Bulk operations
collectionRouter.Put("/{id}/reorder", contentHandler.ReorderCollection)
}) })
}) })
@@ -286,6 +289,7 @@ func runServe(cmd *cobra.Command, args []string) {
fmt.Printf(" POST /api/collections/{id}/items\n") fmt.Printf(" POST /api/collections/{id}/items\n")
fmt.Printf(" PUT /api/collections/{id}/items/{item_id}\n") fmt.Printf(" PUT /api/collections/{id}/items/{item_id}\n")
fmt.Printf(" DELETE /api/collections/{id}/items/{item_id}\n") fmt.Printf(" DELETE /api/collections/{id}/items/{item_id}\n")
fmt.Printf(" PUT /api/collections/{id}/reorder\n")
fmt.Printf("🌐 Static sites:\n") fmt.Printf("🌐 Static sites:\n")
for siteID, _ := range siteManager.GetAllSites() { for siteID, _ := range siteManager.GetAllSites() {
fmt.Printf(" %s: http://localhost%s/sites/%s/\n", siteID, addr, siteID) fmt.Printf(" %s: http://localhost%s/sites/%s/\n", siteID, addr, siteID)

View File

@@ -22,7 +22,6 @@ import (
"github.com/insertr/insertr/internal/engine" "github.com/insertr/insertr/internal/engine"
) )
// ContentHandler handles all content-related HTTP requests // ContentHandler handles all content-related HTTP requests
type ContentHandler struct { type ContentHandler struct {
database *db.Database database *db.Database
@@ -1094,7 +1093,7 @@ func (h *ContentHandler) UpdateCollectionItem(w http.ResponseWriter, r *http.Req
maxPos = 0 maxPos = 0
} }
} }
// Check if position is valid (1-based, within bounds) // Check if position is valid (1-based, within bounds)
if int64(req.Position) > maxPos { if int64(req.Position) > maxPos {
http.Error(w, fmt.Sprintf("Invalid position: %d exceeds max position %d", req.Position, maxPos), http.StatusBadRequest) http.Error(w, fmt.Sprintf("Invalid position: %d exceeds max position %d", req.Position, maxPos), http.StatusBadRequest)
@@ -1117,7 +1116,7 @@ func (h *ContentHandler) UpdateCollectionItem(w http.ResponseWriter, r *http.Req
return return
} }
} }
// If only position update (no html_content), just get the updated item // If only position update (no html_content), just get the updated item
if req.HTMLContent == "" { if req.HTMLContent == "" {
updatedItem, err = h.database.GetSQLiteQueries().GetCollectionItem(context.Background(), sqlite.GetCollectionItemParams{ updatedItem, err = h.database.GetSQLiteQueries().GetCollectionItem(context.Background(), sqlite.GetCollectionItemParams{
@@ -1149,7 +1148,7 @@ func (h *ContentHandler) UpdateCollectionItem(w http.ResponseWriter, r *http.Req
return return
} }
} }
// If only position update (no html_content), just get the updated item // If only position update (no html_content), just get the updated item
if req.HTMLContent == "" { if req.HTMLContent == "" {
updatedItem, err = h.database.GetPostgreSQLQueries().GetCollectionItem(context.Background(), postgresql.GetCollectionItemParams{ updatedItem, err = h.database.GetPostgreSQLQueries().GetCollectionItem(context.Background(), postgresql.GetCollectionItemParams{
@@ -1226,6 +1225,112 @@ func (h *ContentHandler) DeleteCollectionItem(w http.ResponseWriter, r *http.Req
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// ReorderCollection handles PUT /api/collections/{id}/reorder
func (h *ContentHandler) ReorderCollection(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
}
var req ReorderCollectionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if req.UpdatedBy == "" {
req.UpdatedBy = "api"
}
// Validate that all items belong to the collection and have valid positions
if len(req.Items) == 0 {
http.Error(w, "Items array cannot be empty", http.StatusBadRequest)
return
}
// Update positions for all items in a transaction for atomicity
switch h.database.GetDBType() {
case "sqlite3":
// Use transaction for atomic bulk updates
tx, err := h.database.GetSQLiteDB().Begin()
if err != nil {
http.Error(w, "Failed to begin transaction", http.StatusInternalServerError)
return
}
defer tx.Rollback()
// Create queries with transaction context
qtx := h.database.GetSQLiteQueries().WithTx(tx)
for _, item := range req.Items {
err = qtx.UpdateCollectionItemPosition(context.Background(), sqlite.UpdateCollectionItemPositionParams{
ItemID: item.ItemID,
CollectionID: collectionID,
SiteID: siteID,
Position: int64(item.Position),
LastEditedBy: req.UpdatedBy,
})
if err != nil {
http.Error(w, fmt.Sprintf("Failed to update position for item %s: %v", item.ItemID, err), http.StatusInternalServerError)
return
}
}
// Commit transaction
if err = tx.Commit(); err != nil {
http.Error(w, "Failed to commit bulk reorder transaction", http.StatusInternalServerError)
return
}
case "postgres":
// Use transaction for atomic bulk updates
tx, err := h.database.GetPostgreSQLDB().Begin()
if err != nil {
http.Error(w, "Failed to begin transaction", http.StatusInternalServerError)
return
}
defer tx.Rollback()
// Create queries with transaction context
qtx := h.database.GetPostgreSQLQueries().WithTx(tx)
for _, item := range req.Items {
err = qtx.UpdateCollectionItemPosition(context.Background(), postgresql.UpdateCollectionItemPositionParams{
ItemID: item.ItemID,
CollectionID: collectionID,
SiteID: siteID,
Position: int32(item.Position),
LastEditedBy: req.UpdatedBy,
})
if err != nil {
http.Error(w, fmt.Sprintf("Failed to update position for item %s: %v", item.ItemID, err), http.StatusInternalServerError)
return
}
}
// Commit transaction
if err = tx.Commit(); err != nil {
http.Error(w, "Failed to commit bulk reorder transaction", http.StatusInternalServerError)
return
}
default:
http.Error(w, "Unsupported database type", http.StatusInternalServerError)
return
}
// Return success response
w.Header().Set("Content-Type", "application/json")
response := map[string]interface{}{
"success": true,
"message": fmt.Sprintf("Successfully reordered %d items", len(req.Items)),
}
json.NewEncoder(w).Encode(response)
}
// Collection conversion helpers // Collection conversion helpers
// convertToAPICollection converts database collection to API model // convertToAPICollection converts database collection to API model

View File

@@ -123,3 +123,13 @@ type UpdateCollectionItemRequest struct {
Position int `json:"position"` Position int `json:"position"`
UpdatedBy string `json:"updated_by,omitempty"` UpdatedBy string `json:"updated_by,omitempty"`
} }
type CollectionItemPosition struct {
ItemID string `json:"itemId"`
Position int `json:"position"`
}
type ReorderCollectionRequest struct {
Items []CollectionItemPosition `json:"items"`
UpdatedBy string `json:"updated_by,omitempty"`
}

View File

@@ -95,6 +95,16 @@ func (db *Database) GetDBType() string {
return db.dbType return db.dbType
} }
// GetSQLiteDB returns the underlying SQLite database connection
func (db *Database) GetSQLiteDB() *sql.DB {
return db.conn
}
// GetPostgreSQLDB returns the underlying PostgreSQL database connection
func (db *Database) GetPostgreSQLDB() *sql.DB {
return db.conn
}
// initializeSQLiteSchema sets up the SQLite database schema // initializeSQLiteSchema sets up the SQLite database schema
func (db *Database) initializeSQLiteSchema() error { func (db *Database) initializeSQLiteSchema() error {
ctx := context.Background() ctx := context.Background()

View File

@@ -305,12 +305,13 @@ func (q *Queries) UpdateCollectionItem(ctx context.Context, arg UpdateCollection
const updateCollectionItemPosition = `-- name: UpdateCollectionItemPosition :exec const updateCollectionItemPosition = `-- name: UpdateCollectionItemPosition :exec
UPDATE collection_items UPDATE collection_items
SET position = $1 SET position = $1, updated_at = CURRENT_TIMESTAMP, last_edited_by = $2
WHERE item_id = $2 AND collection_id = $3 AND site_id = $4 WHERE item_id = $3 AND collection_id = $4 AND site_id = $5
` `
type UpdateCollectionItemPositionParams struct { type UpdateCollectionItemPositionParams struct {
Position int32 `json:"position"` Position int32 `json:"position"`
LastEditedBy string `json:"last_edited_by"`
ItemID string `json:"item_id"` ItemID string `json:"item_id"`
CollectionID string `json:"collection_id"` CollectionID string `json:"collection_id"`
SiteID string `json:"site_id"` SiteID string `json:"site_id"`
@@ -319,6 +320,7 @@ type UpdateCollectionItemPositionParams struct {
func (q *Queries) UpdateCollectionItemPosition(ctx context.Context, arg UpdateCollectionItemPositionParams) error { func (q *Queries) UpdateCollectionItemPosition(ctx context.Context, arg UpdateCollectionItemPositionParams) error {
_, err := q.db.ExecContext(ctx, updateCollectionItemPosition, _, err := q.db.ExecContext(ctx, updateCollectionItemPosition,
arg.Position, arg.Position,
arg.LastEditedBy,
arg.ItemID, arg.ItemID,
arg.CollectionID, arg.CollectionID,
arg.SiteID, arg.SiteID,

View File

@@ -38,7 +38,7 @@ RETURNING item_id, collection_id, site_id, template_id, html_content, position,
-- name: UpdateCollectionItemPosition :exec -- name: UpdateCollectionItemPosition :exec
UPDATE collection_items UPDATE collection_items
SET position = sqlc.arg(position) SET position = sqlc.arg(position), updated_at = CURRENT_TIMESTAMP, last_edited_by = sqlc.arg(last_edited_by)
WHERE item_id = sqlc.arg(item_id) AND collection_id = sqlc.arg(collection_id) AND site_id = sqlc.arg(site_id); WHERE item_id = sqlc.arg(item_id) AND collection_id = sqlc.arg(collection_id) AND site_id = sqlc.arg(site_id);
-- name: ReorderCollectionItems :exec -- name: ReorderCollectionItems :exec
@@ -47,6 +47,8 @@ SET position = position + sqlc.arg(position_delta)
WHERE collection_id = sqlc.arg(collection_id) AND site_id = sqlc.arg(site_id) WHERE collection_id = sqlc.arg(collection_id) AND site_id = sqlc.arg(site_id)
AND position >= sqlc.arg(start_position); AND position >= sqlc.arg(start_position);
-- name: DeleteCollectionItem :exec -- name: DeleteCollectionItem :exec
DELETE FROM collection_items DELETE FROM collection_items
WHERE item_id = sqlc.arg(item_id) AND collection_id = sqlc.arg(collection_id) AND site_id = sqlc.arg(site_id); WHERE item_id = sqlc.arg(item_id) AND collection_id = sqlc.arg(collection_id) AND site_id = sqlc.arg(site_id);

View File

@@ -305,12 +305,13 @@ func (q *Queries) UpdateCollectionItem(ctx context.Context, arg UpdateCollection
const updateCollectionItemPosition = `-- name: UpdateCollectionItemPosition :exec const updateCollectionItemPosition = `-- name: UpdateCollectionItemPosition :exec
UPDATE collection_items UPDATE collection_items
SET position = ?1 SET position = ?1, updated_at = CURRENT_TIMESTAMP, last_edited_by = ?2
WHERE item_id = ?2 AND collection_id = ?3 AND site_id = ?4 WHERE item_id = ?3 AND collection_id = ?4 AND site_id = ?5
` `
type UpdateCollectionItemPositionParams struct { type UpdateCollectionItemPositionParams struct {
Position int64 `json:"position"` Position int64 `json:"position"`
LastEditedBy string `json:"last_edited_by"`
ItemID string `json:"item_id"` ItemID string `json:"item_id"`
CollectionID string `json:"collection_id"` CollectionID string `json:"collection_id"`
SiteID string `json:"site_id"` SiteID string `json:"site_id"`
@@ -319,6 +320,7 @@ type UpdateCollectionItemPositionParams struct {
func (q *Queries) UpdateCollectionItemPosition(ctx context.Context, arg UpdateCollectionItemPositionParams) error { func (q *Queries) UpdateCollectionItemPosition(ctx context.Context, arg UpdateCollectionItemPositionParams) error {
_, err := q.db.ExecContext(ctx, updateCollectionItemPosition, _, err := q.db.ExecContext(ctx, updateCollectionItemPosition,
arg.Position, arg.Position,
arg.LastEditedBy,
arg.ItemID, arg.ItemID,
arg.CollectionID, arg.CollectionID,
arg.SiteID, arg.SiteID,

View File

@@ -3,7 +3,6 @@ package engine
import ( import (
"fmt" "fmt"
"strings" "strings"
"time"
"golang.org/x/net/html" "golang.org/x/net/html"
) )
@@ -103,8 +102,8 @@ func (e *ContentEngine) ProcessContent(input ContentInput) (*ContentResult, erro
// Generate structural ID for the collection container // Generate structural ID for the collection container
collectionID := e.idGenerator.Generate(collectionElem.Node, input.FilePath) collectionID := e.idGenerator.Generate(collectionElem.Node, input.FilePath)
// Add data-content-id attribute to the collection container // Add data-collection-id attribute to the collection container
e.setAttribute(collectionElem.Node, "data-content-id", collectionID) e.setAttribute(collectionElem.Node, "data-collection-id", collectionID)
// Process collection during enhancement or content injection // Process collection during enhancement or content injection
if input.Mode == Enhancement || input.Mode == ContentInjection { if input.Mode == Enhancement || input.Mode == ContentInjection {
@@ -243,7 +242,6 @@ func (e *ContentEngine) addContentAttributes(node *html.Node, contentID string)
e.setAttribute(node, "data-content-id", contentID) e.setAttribute(node, "data-content-id", contentID)
} }
// setAttribute sets an attribute on an HTML node // setAttribute sets an attribute on an HTML node
func (e *ContentEngine) setAttribute(node *html.Node, key, value string) { func (e *ContentEngine) setAttribute(node *html.Node, key, value string) {
// Remove existing attribute if it exists // Remove existing attribute if it exists
@@ -377,8 +375,6 @@ func (e *ContentEngine) extractHTMLContent(node *html.Node) string {
return strings.TrimSpace(content.String()) return strings.TrimSpace(content.String())
} }
// extractOriginalTemplate extracts the outer HTML of the element (including the element itself) // extractOriginalTemplate extracts the outer HTML of the element (including the element itself)
func (e *ContentEngine) extractOriginalTemplate(node *html.Node) string { func (e *ContentEngine) extractOriginalTemplate(node *html.Node) string {
var buf strings.Builder var buf strings.Builder
@@ -549,6 +545,12 @@ func (e *ContentEngine) extractAndStoreTemplatesAndItems(collectionNode *html.No
return fmt.Errorf("failed to store initial collection items: %w", err) return fmt.Errorf("failed to store initial collection items: %w", err)
} }
// Reconstruct items from database to ensure proper data-item-id injection
err = e.reconstructCollectionItems(collectionNode, collectionID, siteID)
if err != nil {
return fmt.Errorf("failed to reconstruct initial collection items: %w", err)
}
return nil return nil
} }
@@ -619,6 +621,12 @@ func (e *ContentEngine) reconstructCollectionItems(collectionNode *html.Node, co
for structuralChild := structuralBody.FirstChild; structuralChild != nil; { for structuralChild := structuralBody.FirstChild; structuralChild != nil; {
next := structuralChild.NextSibling next := structuralChild.NextSibling
structuralBody.RemoveChild(structuralChild) structuralBody.RemoveChild(structuralChild)
// Inject data-item-id attribute for collection item identification
if structuralChild.Type == html.ElementNode {
e.setAttribute(structuralChild, "data-item-id", item.ItemID)
}
collectionNode.AppendChild(structuralChild) collectionNode.AppendChild(structuralChild)
structuralChild = next structuralChild = next
} }
@@ -730,15 +738,18 @@ func (e *ContentEngine) CreateCollectionItemFromTemplate(
templateHTML string, templateHTML string,
lastEditedBy string, lastEditedBy string,
) (*CollectionItemWithTemplate, error) { ) (*CollectionItemWithTemplate, error) {
// Generate unique item ID
itemID := fmt.Sprintf("%s-item-%d", collectionID, time.Now().Unix())
// Create virtual element from template (like enhancement path) // Create virtual element from template (like enhancement path)
virtualElement, err := e.createVirtualElementFromTemplate(templateHTML) virtualElement, err := e.createVirtualElementFromTemplate(templateHTML)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create virtual element: %w", err) 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")
if err != nil {
return nil, fmt.Errorf("failed to create virtual element: %w", err)
}
// Process .insertr elements and create content entries (unified approach) // Process .insertr elements and create content entries (unified approach)
contentEntries, err := e.processChildElementsAsContent(virtualElement, siteID, itemID) contentEntries, err := e.processChildElementsAsContent(virtualElement, siteID, itemID)
if err != nil { if err != nil {
@@ -808,8 +819,8 @@ func (e *ContentEngine) storeChildrenAsCollectionItems(collectionNode *html.Node
// Store each child using unified .insertr approach (content table + structural template) // Store each child using unified .insertr approach (content table + structural template)
for i, childElement := range childElements { for i, childElement := range childElements {
// Generate item ID (like content ID generation) // Generate item ID using unified generator with collection context
itemID := fmt.Sprintf("%s-initial-%d", collectionID, i+1) itemID := e.idGenerator.Generate(childElement, "collection-item")
// Process .insertr elements within this child (unified approach) // Process .insertr elements within this child (unified approach)
contentEntries, err := e.processChildElementsAsContent(childElement, siteID, itemID) contentEntries, err := e.processChildElementsAsContent(childElement, siteID, itemID)

View File

@@ -162,7 +162,6 @@ func (i *Injector) findElementByTag(node *html.Node, tag string) *html.Node {
// AddContentAttributes adds necessary data attributes and insertr class for editor functionality // AddContentAttributes adds necessary data attributes and insertr class for editor functionality
func (i *Injector) AddContentAttributes(node *html.Node, contentID string, contentType string) { func (i *Injector) AddContentAttributes(node *html.Node, contentID string, contentType string) {
i.setAttribute(node, "data-content-id", contentID) i.setAttribute(node, "data-content-id", contentID)
i.setAttribute(node, "data-content-type", contentType)
i.addClass(node, "insertr") i.addClass(node, "insertr")
} }

View File

@@ -243,21 +243,19 @@ export class ApiClient {
} }
/** /**
* Update collection item position (for reordering) * Reorder collection items in bulk
* @param {string} collectionId - Collection ID * @param {string} collectionId - Collection ID
* @param {string} itemId - Item ID to update * @param {Array} itemOrder - Array of {itemId, position} objects
* @param {number} newPosition - New position index
* @returns {Promise<boolean>} Success status * @returns {Promise<boolean>} Success status
*/ */
async updateCollectionItemPosition(collectionId, itemId, newPosition) { async reorderCollection(collectionId, itemOrder) {
try { try {
const collectionsUrl = this.getCollectionsUrl(); const collectionsUrl = this.getCollectionsUrl();
const payload = { const payload = {
site_id: this.siteId, items: itemOrder
position: newPosition
}; };
const response = await fetch(`${collectionsUrl}/${collectionId}/items/${itemId}`, { const response = await fetch(`${collectionsUrl}/${collectionId}/reorder?site_id=${this.siteId}`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -270,11 +268,11 @@ export class ApiClient {
return true; return true;
} else { } else {
const errorText = await response.text(); const errorText = await response.text();
console.error(`❌ Failed to update collection item position (${response.status}): ${errorText}`); console.error(`❌ Failed to reorder collection (${response.status}): ${errorText}`);
return false; return false;
} }
} catch (error) { } catch (error) {
console.error('❌ Error updating collection item position:', error); console.error('❌ Error reordering collection:', error);
return false; return false;
} }
} }

View File

@@ -245,8 +245,8 @@ export class CollectionManager {
* This is used for existing items that were reconstructed from database * This is used for existing items that were reconstructed from database
*/ */
extractCollectionItemId(element) { extractCollectionItemId(element) {
// Look for data-collection-item-id attribute first (newly created items) // Look for data-item-id attribute first (from server enhancement)
let itemId = element.getAttribute('data-collection-item-id'); let itemId = element.getAttribute('data-item-id');
if (itemId) { if (itemId) {
return itemId; return itemId;
} }
@@ -278,12 +278,11 @@ export class CollectionManager {
const backendItems = await this.apiClient.getCollectionItems(this.collectionId); const backendItems = await this.apiClient.getCollectionItems(this.collectionId);
console.log('📋 Backend collection items:', backendItems); console.log('📋 Backend collection items:', backendItems);
// Map backend items to existing DOM elements by position // Items already have data-item-id from server enhancement
// This assumes the DOM order matches the database order // Just update the collectionItemId in our internal items array
backendItems.forEach((backendItem, index) => { backendItems.forEach((backendItem, index) => {
if (this.items[index]) { if (this.items[index]) {
this.items[index].collectionItemId = backendItem.item_id; 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(`🔗 Mapped DOM element ${index} to collection item ${backendItem.item_id}`);
} }
}); });
@@ -462,7 +461,7 @@ export class CollectionManager {
const newItem = tempContainer.firstElementChild; const newItem = tempContainer.firstElementChild;
// Set the collection item ID as data attribute for future reference // Set the collection item ID as data attribute for future reference
newItem.setAttribute('data-collection-item-id', collectionItem.item_id); newItem.setAttribute('data-item-id', collectionItem.item_id);
return newItem; return newItem;
} else { } else {
@@ -472,7 +471,7 @@ export class CollectionManager {
const newItem = tempContainer.firstElementChild; const newItem = tempContainer.firstElementChild;
// Set the collection item ID as data attribute for future reference // Set the collection item ID as data attribute for future reference
newItem.setAttribute('data-collection-item-id', collectionItem.item_id); newItem.setAttribute('data-item-id', collectionItem.item_id);
return newItem; return newItem;
} }
@@ -629,9 +628,9 @@ export class CollectionManager {
try { try {
// 1. Get the collection item ID from the element // 1. Get the collection item ID from the element
const collectionItemId = itemElement.getAttribute('data-collection-item-id'); const collectionItemId = itemElement.getAttribute('data-item-id');
if (!collectionItemId) { if (!collectionItemId) {
console.error('❌ Cannot remove item: missing data-collection-item-id attribute'); console.error('❌ Cannot remove item: missing data-item-id attribute');
return; return;
} }
@@ -688,42 +687,54 @@ export class CollectionManager {
try { try {
// 1. Get the collection item ID // 1. Get the collection item ID
const collectionItemId = itemElement.getAttribute('data-collection-item-id'); const collectionItemId = itemElement.getAttribute('data-item-id');
if (!collectionItemId) { if (!collectionItemId) {
console.error('❌ Cannot move item: missing data-collection-item-id attribute'); console.error('❌ Cannot move item: missing data-item-id attribute');
return; return;
} }
// 2. Update position in database first (backend-first approach) // 2. Store original state for potential rollback
// Note: Backend expects 0-based positions, but we may need to adjust based on backend implementation const originalItems = [...this.items];
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 // 3. Perform DOM move (optimistic UI)
const targetItem = this.items[newIndex]; const targetItem = this.items[newIndex];
// 4. Move in DOM
if (direction === 'up') { if (direction === 'up') {
this.container.insertBefore(itemElement, targetItem.element); this.container.insertBefore(itemElement, targetItem.element);
} else { } else {
this.container.insertBefore(itemElement, targetItem.element.nextSibling); this.container.insertBefore(itemElement, targetItem.element.nextSibling);
} }
// 5. Update items array // 4. Update items array
[this.items[currentIndex], this.items[newIndex]] = [this.items[newIndex], this.items[currentIndex]]; [this.items[currentIndex], this.items[newIndex]] = [this.items[newIndex], this.items[currentIndex]];
this.items[currentIndex].index = currentIndex; this.items[currentIndex].index = currentIndex;
this.items[newIndex].index = newIndex; this.items[newIndex].index = newIndex;
// 6. Update all item controls // 5. Update all item controls
this.updateAllItemControls(); this.updateAllItemControls();
// 7. Trigger site enhancement to update static files // 6. Build bulk reorder payload from current DOM state
const itemOrder = this.items.map((item, index) => ({
itemId: item.element.getAttribute('data-item-id'),
position: index + 1 // 1-based positions
}));
// 7. Send bulk reorder to backend
const success = await this.apiClient.reorderCollection(this.collectionId, itemOrder);
if (!success) {
// Rollback DOM changes
this.items = originalItems;
this.items.forEach(item => {
this.container.appendChild(item.element);
});
this.updateAllItemControls();
alert('Failed to update item position in database. Please try again.');
return;
}
// 8. Trigger site enhancement to update static files
await this.apiClient.enhanceSite(); await this.apiClient.enhanceSite();
console.log('✅ Item moved successfully:', collectionItemId, '→ position', newIndex); console.log('✅ Item moved successfully:', collectionItemId, '→ position', newIndex + 1);
} catch (error) { } catch (error) {
console.error('❌ Failed to move collection item:', error); console.error('❌ Failed to move collection item:', error);
alert('Failed to move item. Please try again.'); alert('Failed to move item. Please try again.');