From 40c732481a20f9e1880260849fdd8193fa4224aa Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 1 Feb 2026 00:54:41 -0500 Subject: [PATCH] feat(collections): add real-time updates via WebSocket (Limitation #4) Implement real-time collection updates when books are added/removed: Backend Changes: - Added connManager to CollectionHandler struct - Updated constructor to accept ConnectionManager - Updated all NewCollectionHandler() calls in ebook.go and main.go - Added WebSocket broadcasts in AddBooks() handler - Added WebSocket broadcasts in BulkRemoveBooks() handler - Broadcasts collection_updated events with: - collection_id: Which collection changed - action: books_added or books_removed - book_ids: Array of affected book IDs - count: Number of books changed Frontend Changes: - Added WebSocket connection in collections UI - connectWebSocket() establishes connection to /ws/sync - Listens for collection_updated events - Shows toast notification on collection change - Auto-reloads page after 1 second to show updated book list - Auto-reconnect on disconnect (5s delay) - Error handling for WebSocket failures WebSocket Event Format: { "type": "collection_updated", "timestamp": "2026-02-01T12:00:00Z", "data": { "collection_id": "uuid", "action": "books_added", "book_ids": ["uuid1", "uuid2"], "count": 2 } } User Experience: - When another user adds books to a collection, all connected clients see: 1. Toast notification: "Collection updated: books_added (2 books)" 2. Page auto-refreshes after 1 second 3. Updated book list displays - Same for book removal - Works across multiple browser tabs/devices - No manual refresh needed Technical Notes: - Broadcasts to ALL connected WebSocket clients - Client-side filtering by collection_id - Existing progress/conflict broadcasts continue to work - Connection manager handles broadcast distribution Resolves Limitation #4: Real-time Collection Updates --- cmd/server/main.go | 3 +-- internal/handlers/collections.go | 42 ++++++++++++++++++++++++++++++-- internal/handlers/ebook.go | 2 +- templates/collections.templ | 35 ++++++++++++++++++++++++++ templates/collections_templ.go | 2 +- 5 files changed, 78 insertions(+), 6 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index d9b8924..3ff3b24 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -607,7 +607,6 @@ func main() { return c.HTML(http.StatusOK, buf.String()) }) - // Queue Management route (protected) - SSR version protected.GET("/queue", func(c echo.Context) error { userID := c.Get("user_id").(string) @@ -659,7 +658,7 @@ func main() { }) // Collections management route (protected) - SSR version - collectionHandler := handlers.NewCollectionHandler(queries) + collectionHandler := handlers.NewCollectionHandler(queries, connManager) collections := protected.Group("/collections") collections.GET("", func(c echo.Context) error { user, err := getTemplateUserWithTheme(c, queries) diff --git a/internal/handlers/collections.go b/internal/handlers/collections.go index 8e9df74..42a882e 100644 --- a/internal/handlers/collections.go +++ b/internal/handlers/collections.go @@ -3,11 +3,13 @@ package handlers import ( "bookmann/internal/database" "bookmann/internal/services" + wsync "bookmann/internal/sync" "encoding/json" "fmt" "net/http" "strconv" "strings" + "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" @@ -17,12 +19,14 @@ import ( type CollectionHandler struct { db *database.Queries collectionService *services.CollectionService + connManager *wsync.ConnectionManager } -func NewCollectionHandler(db *database.Queries) *CollectionHandler { +func NewCollectionHandler(db *database.Queries, connManager *wsync.ConnectionManager) *CollectionHandler { return &CollectionHandler{ db: db, collectionService: services.NewCollectionService(db), + connManager: connManager, } } @@ -276,12 +280,31 @@ func (h *CollectionHandler) AddBooks(c echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } + addedCount := 0 + var addedBookIDs []string for _, bookIDStr := range req.BookIDs { bookID, err := uuid.Parse(bookIDStr) if err != nil { continue } - h.collectionService.AddBookToCollection(c.Request().Context(), collectionID, bookID, userUUID) + err = h.collectionService.AddBookToCollection(c.Request().Context(), collectionID, bookID, userUUID) + if err == nil { + addedCount++ + addedBookIDs = append(addedBookIDs, bookID.String()) + } + } + + if addedCount > 0 && h.connManager != nil { + h.connManager.Broadcast(wsync.BroadcastMessage{ + Type: "collection_updated", + Timestamp: time.Now().Format(time.RFC3339), + Data: map[string]interface{}{ + "collection_id": collectionID.String(), + "action": "books_added", + "book_ids": addedBookIDs, + "count": addedCount, + }, + }) } return c.NoContent(http.StatusNoContent) @@ -325,6 +348,7 @@ func (h *CollectionHandler) BulkRemoveBooks(c echo.Context) error { } removedCount := 0 + var removedBookIDs []string for _, bookIDStr := range req.BookIDs { bookID, err := uuid.Parse(bookIDStr) if err != nil { @@ -334,9 +358,23 @@ func (h *CollectionHandler) BulkRemoveBooks(c echo.Context) error { err = h.collectionService.RemoveBookFromCollection(c.Request().Context(), collectionID, bookID) if err == nil { removedCount++ + removedBookIDs = append(removedBookIDs, bookID.String()) } } + if removedCount > 0 && h.connManager != nil { + h.connManager.Broadcast(wsync.BroadcastMessage{ + Type: "collection_updated", + Timestamp: time.Now().Format(time.RFC3339), + Data: map[string]interface{}{ + "collection_id": collectionID.String(), + "action": "books_removed", + "book_ids": removedBookIDs, + "count": removedCount, + }, + }) + } + return c.JSON(http.StatusOK, map[string]interface{}{ "removed": removedCount, "total": len(req.BookIDs), diff --git a/internal/handlers/ebook.go b/internal/handlers/ebook.go index eac20e3..281d815 100644 --- a/internal/handlers/ebook.go +++ b/internal/handlers/ebook.go @@ -71,7 +71,7 @@ func SetupRoutes(g *echo.Group, db *database.Queries, connManager *wsync.Connect h := NewHandler(db, connManager) // Collections API routes - collectionHandler := NewCollectionHandler(db) + collectionHandler := NewCollectionHandler(db, connManager) collections := g.Group("/collections") collections.GET("", collectionHandler.GetCollections) collections.POST("", collectionHandler.CreateCollection) diff --git a/templates/collections.templ b/templates/collections.templ index a17d48c..76b8623 100644 --- a/templates/collections.templ +++ b/templates/collections.templ @@ -335,11 +335,46 @@ templ CollectionDetail(user User, collection CollectionDetailData, books []BookD }, }{ end } ]; + let ws = null; function backToCollections() { window.location.href = '/collections'; } + function connectWebSocket() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/ws/sync`; + + ws = new WebSocket(wsUrl); + + ws.onopen = function() { + console.log('WebSocket connected'); + }; + + ws.onmessage = function(event) { + const message = JSON.parse(event.data); + + if (message.type === 'collection_updated' && message.data.collection_id === collectionId) { + showToast(`Collection updated: ${message.data.action} (${message.data.count} books)`, 'info'); + + setTimeout(() => { + location.reload(); + }, 1000); + } + }; + + ws.onclose = function() { + console.log('WebSocket disconnected, reconnecting in 5s...'); + setTimeout(connectWebSocket, 5000); + }; + + ws.onerror = function(error) { + console.error('WebSocket error:', error); + }; + } + + connectWebSocket(); + function filterCollectionBooks() { const searchTerm = document.getElementById('collection-search').value.toLowerCase(); const booksContainer = document.getElementById('books-container'); diff --git a/templates/collections_templ.go b/templates/collections_templ.go index 2024bb3..f0c8f7e 100644 --- a/templates/collections_templ.go +++ b/templates/collections_templ.go @@ -233,7 +233,7 @@ func CollectionDetail(user User, collection CollectionDetailData, books []BookDa return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "

Add Books to Collection

Search and select books to add to this collection.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "

Add Books to Collection

Search and select books to add to this collection.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err }