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
This commit is contained in:
+1
-2
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user