diff --git a/CAROUSEL_DASHBOARD_PLAN.md b/CAROUSEL_DASHBOARD_PLAN.md index 9562293..5b23849 100644 --- a/CAROUSEL_DASHBOARD_PLAN.md +++ b/CAROUSEL_DASHBOARD_PLAN.md @@ -62,11 +62,12 @@ This plan **adheres to** all PROJECT_GUIDELINES.md requirements with explicit us - **Type definitions** - `import type { ... } from './types/api'` ✅ **Code Organization**: -- **Handler types in internal/handlers/dashboard.go** - SectionData, BookInfo (enhanced with template fields) +- **Handler types in internal/handlers/collections.go** - SectionData, BookInfo (single source of truth) - **Templates use handler types directly** - no duplicate types in templates package -- **All business logic in services** - reusable for SSR/API/mobile +- **Service returns structured data** - collections with items already matched +- **Handler converts types for JSON** - simple type conversion only - **TypeScript in web/src/** - follows TypeScript Conversion Plan structure -- **Type definitions in web/src/types/dashboard.d.ts** - recreate handler JSON for TypeScript +- **Type definitions in web/src/types/api.d.ts** - recreate handler JSON for TypeScript ✅ **Database Operations**: - **Merge into existing schema.sql** - no migration files @@ -222,6 +223,11 @@ Verify: **COMPLIANCE**: All business logic in reusable service (per guidelines) +**ARCHITECTURE NOTE**: Following existing pattern from `collections.go`: +- Service returns structured data (collections with their items already matched) +- Handler converts types for JSON serialization +- Single unified method (simpler, less buggy) + ```go package services @@ -246,10 +252,10 @@ func NewDashboardService(db *database.Queries) *DashboardService { } } -// SectionItems contains raw items for a section - handler formats into SectionData -type SectionItems struct { +// DashboardSection represents a collection with its items (for dashboard display) +type DashboardSection struct { CollectionID uuid.UUID - SectionKey string + CollectionName string Items []database.MediaItems QueryType string Priority int @@ -259,16 +265,16 @@ type SectionItems struct { Icon string } -// GetSectionItems fetches raw items for each collection shown on dashboard -// Returns both system collections and user collections marked for dashboard -func (s *DashboardService) GetSectionItems( +// GetDashboardSections fetches all collections (system + user) with their items +// Returns structured data where items are already matched to collections +func (s *DashboardService) GetDashboardSections( ctx context.Context, userID, libraryID uuid.UUID, limit int, collectionOrder []string, hiddenCollections []string, -) ([]SectionItems, error) { - var results []SectionItems +) ([]DashboardSection, error) { + var results []DashboardSection // Get system collections (user_id = NULL) systemCollections, err := s.db.GetSystemCollectionsForDashboard(ctx) @@ -276,40 +282,34 @@ func (s *DashboardService) GetSectionItems( return nil, err } + // Process system collections + for _, coll := range systemCollections { + items, err := s.getCollectionItemsByQueryType(ctx, coll, userID, libraryID, limit) + if err != nil { + continue + } + + results = append(results, DashboardSection{ + CollectionID: uuid.UUID(coll.ID.Bytes), + CollectionName: coll.Name, + Items: items, + QueryType: coll.QueryType.String, + Priority: int(coll.Priority.Int32), + IsSystem: coll.IsSystemCollection, + Title: coll.Name, + Description: coll.Description.String, + Icon: coll.Icon.String, + }) + } + // Get user collections marked for dashboard userCollections, err := s.db.GetUserCollectionsForDashboard(ctx, pgtype.UUID{Bytes: userID, Valid: true}) if err != nil { return nil, err } - // Process system collections - for _, coll := range systemCollections { - collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16]) - - // Get items based on query_type - items, err := s.getCollectionItemsByQueryType(ctx, coll, userID, libraryID, limit) - if err != nil { - continue - } - - results = append(results, SectionItems{ - CollectionID: collUUID, - SectionKey: coll.Name, - Items: items, - QueryType: coll.QueryType.String, - Priority: int(coll.Priority.Int32), - IsSystem: coll.IsSystemCollection, - Title: coll.Name, - Description: coll.Description.String, - Icon: coll.Icon.String, - }) - } - // Process user collections for _, coll := range userCollections { - collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16]) - - // Get items (manual + auto-assign rules) items, err := s.getUserCollectionItems(ctx, coll, userID, libraryID, limit) if err != nil { continue @@ -319,16 +319,16 @@ func (s *DashboardService) GetSectionItems( continue // Skip empty collections } - results = append(results, SectionItems{ - CollectionID: collUUID, - SectionKey: coll.Name, - Items: items, - QueryType: coll.QueryType.String, - Priority: int(coll.Priority.Int32), - IsSystem: false, - Title: coll.Name, - Description: coll.Description.String, - Icon: coll.Icon.String, + results = append(results, DashboardSection{ + CollectionID: uuid.UUID(coll.ID.Bytes), + CollectionName: coll.Name, + Items: items, + QueryType: coll.QueryType.String, + Priority: int(coll.Priority.Int32), + IsSystem: false, + Title: coll.Name, + Description: coll.Description.String, + Icon: coll.Icon.String, }) } @@ -346,6 +346,72 @@ func (s *DashboardService) GetSectionItems( return results, nil } +// filterHiddenCollections removes hidden collections from results +func (s *DashboardService) filterHiddenCollections(sections []DashboardSection, hidden []string) []DashboardSection { + if len(hidden) == 0 { + return sections + } + + var filtered []DashboardSection + for _, section := range sections { + isHidden := false + for _, h := range hidden { + if section.CollectionName == h { + isHidden = true + break + } + } + if !isHidden { + filtered = append(filtered, section) + } + } + return filtered +} + +// reorderCollections reorders sections based on user preference +func (s *DashboardService) reorderCollections(sections []DashboardSection, order []string) []DashboardSection { + if len(order) == 0 { + return sections + } + + var ordered []DashboardSection + remaining := make(map[string]DashboardSection) + for _, section := range sections { + remaining[section.CollectionName] = section + } + + for _, name := range order { + if section, exists := remaining[name]; exists { + ordered = append(ordered, section) + delete(remaining, name) + } + } + + for _, section := range sections { + if _, exists := remaining[section.CollectionName]; exists { + ordered = append(ordered, section) + } + } + + return ordered +} + +// sortByPriority sorts sections by priority (lower numbers first) +func (s *DashboardService) sortByPriority(sections []DashboardSection) []DashboardSection { + sorted := make([]DashboardSection, len(sections)) + copy(sorted, sections) + + for i := 0; i < len(sorted)-1; i++ { + for j := 0; j < len(sorted)-i-1; j++ { + if sorted[j].Priority > sorted[j+1].Priority { + sorted[j], sorted[j+1] = sorted[j+1], sorted[j] + } + } + } + + return sorted +} + // getCollectionItemsByQueryType returns items for system collections based on query_type func (s *DashboardService) getCollectionItemsByQueryType(ctx context.Context, coll database.Collections, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) { switch coll.QueryType.String { @@ -444,73 +510,6 @@ func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll data return finalItems, nil } -// filterHiddenCollections removes collections the user has hidden -func (s *DashboardService) filterHiddenCollections(items []SectionItems, hidden []string) []SectionItems { - if len(hidden) == 0 { - return items - } - - var filtered []SectionItems - for _, item := range items { - isHidden := false - for _, h := range hidden { - if item.SectionKey == h { - isHidden = true - break - } - } - if !isHidden { - filtered = append(filtered, item) - } - } - return filtered -} - -// reorderCollections reorders collections according to user's custom order -func (s *DashboardService) reorderCollections(items []SectionItems, order []string) []SectionItems { - if len(order) == 0 { - return items - } - - var ordered []SectionItems - remaining := make(map[string]SectionItems) - for _, item := range items { - remaining[item.SectionKey] = item - } - - for _, key := range order { - if item, exists := remaining[key]; exists { - ordered = append(ordered, item) - delete(remaining, key) - } - } - - for _, item := range items { - if _, exists := remaining[item.SectionKey]; exists { - ordered = append(ordered, item) - } - } - - return ordered -} - -// sortByPriority sorts collections by priority field -func (s *DashboardService) sortByPriority(items []SectionItems) []SectionItems { - sorted := make([]SectionItems, len(items)) - copy(sorted, items) - - // Simple bubble sort (small lists, usually < 20 items) - for i := 0; i < len(sorted)-1; i++ { - for j := 0; j < len(sorted)-i-1; j++ { - if sorted[j].Priority > sorted[j+1].Priority { - sorted[j], sorted[j+1] = sorted[j+1], sorted[j] - } - } - } - - return sorted -} - // GetDashboardPreferences fetches user preferences for a library func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID, libraryID uuid.UUID) (database.UserDashboardPreferences, error) { return s.db.GetDashboardPreferences(ctx, database.GetDashboardPreferencesParams{ @@ -544,12 +543,13 @@ func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID u **Key Points**: - ✅ Service layer holds all business logic -- ✅ Unified handling of system and user collections +- ✅ Returns database types (type safety at DB layer) +- ✅ Handler converts to API types (clean JSON contracts) - ✅ Reusable by SSR, API, mobile - ✅ No direct database access from handlers - ✅ Uses existing database queries - ✅ Procedural/imperative style (no OOP) -- ✅ Returns raw data - handler formats for templates +- ✅ Follows existing pattern from collections.go --- @@ -658,12 +658,41 @@ Regenerate: `cd internal/database && sqlc generate` --- -### **Phase 4: API Handler** (1-2 hours) +### **Phase 4: API Handler** (2-3 hours) + +**Step 1: Add SectionData to collections.go** (15 min) + +**File: `internal/handlers/collections.go`** (MODIFY existing) + +Add the `SectionData` struct after the existing `BookInfo` struct (around line 71): + +```go +// SectionData represents a dashboard section (carousel of books) +// Used by: Dashboard handler, Templates (SSR), API JSON responses +type SectionData struct { + ID string `json:"id"` + IsSystem bool `json:"is_system"` + Title string `json:"title"` + Description string `json:"description"` + Icon string `json:"icon"` + Items []BookInfo `json:"items"` + ViewAllURL string `json:"view_all_url"` + Priority int `json:"priority"` +} +``` + +**Step 2: Create dashboard.go** (1-1.75 hours) **File: `internal/handlers/dashboard.go`** (new file) **COMPLIANCE**: Generic API handler for reuse by SSR, mobile, plugins +**IMPORTANT**: This file uses shared types from `collections.go`: +- `SectionData` struct (defined in collections.go) +- `BookInfo` struct (defined in collections.go, uses `MediaItemID` field) + +No duplicate type definitions - collections.go is the source of truth. + ```go package handlers @@ -678,28 +707,6 @@ import ( "github.com/labstack/echo/v4" ) -// SectionData represents a dashboard section (carousel) -// Used by: Templates (SSR), API JSON responses -type SectionData struct { - ID string `json:"id"` - Type string `json:"type"` // "system" or "user" - Title string `json:"title"` - Description string `json:"description"` - Icon string `json:"icon"` - Items []BookInfo `json:"items"` - ViewAllURL string `json:"view_all_url"` - Priority int `json:"priority"` -} - -// BookInfo represents a book in a carousel card -// Used by: Templates (SSR), API JSON responses -type BookInfo struct { - ID string `json:"id"` - Title string `json:"title"` - Author string `json:"author"` - CoverImagePath string `json:"cover_image_path"` -} - type DashboardHandler struct { db *database.Queries dashboardService *services.DashboardService @@ -736,7 +743,8 @@ func (h *DashboardHandler) GetSections(c echo.Context) error { } } - sectionItems, err := h.dashboardService.GetSectionItems( + // Get dashboard sections (service returns structured data) + sections, err := h.dashboardService.GetDashboardSections( c.Request().Context(), userUUID, libUUID, @@ -745,11 +753,13 @@ func (h *DashboardHandler) GetSections(c echo.Context) error { prefs.HiddenCollections, ) if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load sections"}) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load dashboard sections"}) } - sections := BuildSections(sectionItems) - return c.JSON(http.StatusOK, map[string]interface{}{"sections": sections}) + // Convert service types to handler types (for JSON serialization) + sectionData := BuildSections(sections) + + return c.JSON(http.StatusOK, map[string]interface{}{"sections": sectionData}) } // UpdatePreferences saves dashboard preferences @@ -824,40 +834,37 @@ func (h *DashboardHandler) RestoreSystemCollection(c echo.Context) error { return c.JSON(http.StatusOK, map[string]string{"message": "System collection restored to defaults"}) } -// BuildSections converts service SectionItems to handler SectionData -func BuildSections(items []services.SectionItems) []SectionData { - var sections []SectionData +// BuildSections converts service DashboardSection to handler SectionData +// Note: SectionData and BookInfo are defined in collections.go +func BuildSections(sections []services.DashboardSection) []SectionData { + var result []SectionData - for _, si := range items { - bookCards := make([]BookInfo, len(si.Items)) - for i, item := range si.Items { + for _, ds := range sections { + // Convert database.MediaItems to handlers.BookInfo + bookCards := make([]BookInfo, len(ds.Items)) + for i, item := range ds.Items { itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) bookCards[i] = BookInfo{ - ID: itemUUID.String(), + MediaItemID: itemUUID.String(), Title: item.Title, - Author: item.Author.String, - CoverImagePath: item.CoverImagePath.String, + Author: textToString(item.Author), + CoverImagePath: textToString(item.CoverImagePath), } } - sectionType := "user" - if si.IsSystem { - sectionType = "system" - } - - sections = append(sections, SectionData{ - ID: si.SectionKey, - Type: sectionType, - Title: si.Title, - Description: si.Description, - Icon: si.Icon, + result = append(result, SectionData{ + ID: ds.CollectionName, + IsSystem: ds.IsSystem, + Title: ds.Title, + Description: ds.Description, + Icon: ds.Icon, Items: bookCards, - ViewAllURL: getViewAllURL(si.SectionKey, si.QueryType), - Priority: si.Priority, + ViewAllURL: getViewAllURL(ds.CollectionName, ds.QueryType), + Priority: ds.Priority, }) } - return sections + return result } func getViewAllURL(key, queryType string) string { @@ -872,13 +879,193 @@ func getViewAllURL(key, queryType string) string { } return "" // User collections don't have view-all URLs } + +func textToString(t pgtype.Text) string { + if t.Valid { + return t.String + } + return "" +} ``` **Key Points**: +- ✅ Uses shared types from collections.go (SectionData, BookInfo) +- ✅ `IsSystem bool` matches database field (no string conversion) - ✅ Generic JSON API endpoint - ✅ Updated field names (hidden_collections, collection_order) - ✅ Restore system collections endpoint - ✅ Reusable by mobile apps, web UI, plugins +- ✅ Single service method returns structured data (simpler, less bugs) +- ✅ Handler just converts types (no matching logic needed) + +--- + +### **Phase 4.5: Collections Preview Endpoint** (30-45 min) + +**IMPORTANT: Why this endpoint is necessary** + +The preview endpoint is **required** for both the web UI custom section builder AND future mobile apps. It allows users to: +- See what books match their filter rules BEFORE saving +- Avoid creating incorrect collections +- Test different rule combinations quickly + +**Why not client-side preview?** +- Client-side would require downloading entire library (10,000+ books) to browser +- Would duplicate 500+ lines of rule evaluation logic in TypeScript +- Would create maintenance nightmare (keeping Go and TypeScript logic in sync) +- Risk of client and server evaluating rules differently + +**This endpoint reuses existing service logic** - the same `collectionService.EvaluateRules()` used by the actual collection creation. + +**File: `internal/handlers/collections.go`** (MODIFY existing) + +Add the preview endpoint method: + +```go +// PreviewCollection evaluates filter rules and returns matching items without saving +// Used by: Custom section builder (web UI), future mobile apps +func (h *CollectionHandler) PreviewCollection(c echo.Context) error { + user := c.Get("user").(database.Users) + userUUID := uuid.UUID(user.ID.Bytes) + + var req struct { + LibraryID string `json:"library_id"` + Rules []Rule `json:"rules"` + ManualBookIDs []string `json:"manual_book_ids"` + Limit int `json:"limit"` + } + + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"}) + } + + libUUID, err := uuid.Parse(req.LibraryID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"}) + } + + if req.Limit <= 0 || req.Limit > 100 { + req.Limit = 20 + } + + // Get all library items + allItems, err := h.db.GetLibraryItems(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true}) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load library items"}) + } + + // Evaluate rules for each item + var matchedItems []database.MediaItems + for _, item := range allItems { + evaluations := h.collectionService.EvaluateRules(item, req.Rules) + for _, eval := range evaluations { + if eval.Matches { + matchedItems = append(matchedItems, item) + break + } + } + } + + // Add manually selected books + for _, bookID := range req.ManualBookIDs { + bookUUID, err := uuid.Parse(bookID) + if err != nil { + continue + } + + for _, item := range allItems { + itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) + if itemUUID == bookUUID { + // Check if already in matched items + alreadyAdded := false + for _, added := range matchedItems { + addedUUID, _ := uuid.FromBytes(added.ID.Bytes[0:16]) + if addedUUID == bookUUID { + alreadyAdded = true + break + } + } + if !alreadyAdded { + matchedItems = append(matchedItems, item) + } + break + } + } + } + + // Apply limit + if len(matchedItems) > req.Limit { + matchedItems = matchedItems[:req.Limit] + } + + // Convert to handler types + bookCards := make([]BookInfo, len(matchedItems)) + for i, item := range matchedItems { + itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) + bookCards[i] = BookInfo{ + MediaItemID: itemUUID.String(), + Title: item.Title, + Author: textToString(item.Author), + CoverImagePath: textToString(item.CoverImagePath), + } + } + + return c.JSON(http.StatusOK, map[string]interface{}{"items": bookCards}) +} +``` + +**Register the route in internal/router/collections.go**: + +```go +// Inside registerCollectionsRoutes function +collections.POST("/preview", cfg.CollectionHandler.PreviewCollection) +``` + +**Create Bruno test**: + +File: `bruno/dashboard/preview-collection.bru` + +```yaml +meta: + name: Preview Collection + group: Dashboard + priority: 5 + +post: + name: Preview collection with filter rules + description: Test preview endpoint for custom section builder + url: {{baseUrl}}/api/collections/preview + headers: + Authorization: Bearer {{userToken}} + Content-Type: application/json + body: |- + { + "library_id": "{{libraryId}}", + "rules": [ + { + "id": "rule1", + "field": "genre", + "operator": "equals", + "value": "Fiction", + "priority": 1 + } + ], + "manual_book_ids": [], + "limit": 20 + } + tests: + - name: Status is 200 + assert: response.status.should.equal(200) + - name: Returns items array + assert: response.body.data.items.should.be.array + - name: Items have required fields + assert: | + response.body.data.items.should.not.be.empty; + response.body.data.items[0].should.have.property("media_item_id"); + response.body.data.items[0].should.have.property("title"); + response.body.data.items[0].should.have.property("author"); + response.body.data.items[0].should.have.property("cover_image_path"); +``` --- @@ -915,15 +1102,18 @@ bru run --env local ### **Phase 6: TypeScript Type Definitions** (30 min) -**File: `web/src/types/dashboard.d.ts`** (new file) +**File: `web/src/types/api.d.ts`** (ADD to existing file) + +Add these interfaces to the existing `web/src/types/api.d.ts` file: ```typescript -// Type definitions for dashboard +// Dashboard type definitions // CRITICAL: Must match Go handler return types EXACTLY +// Source: handlers.SectionData and handlers.BookInfo in collections.go export interface SectionData { id: string; - type: string; // "system" or "user" + is_system: boolean; // Changed from "type" string to match database field title: string; description: string; icon: string; @@ -933,7 +1123,7 @@ export interface SectionData { } export interface BookInfo { - id: string; + media_item_id: string; // Changed from "id" to match Go struct field title: string; author: string; cover_image_path: string; @@ -948,12 +1138,139 @@ export interface DashboardPreferences { ``` **Key Changes**: -- ✅ Updated type field values ("system" vs "user" instead of "smart" vs "collection") -- ✅ No other structural changes (SectionData and BookInfo remain same) +- ✅ `is_system: boolean` matches database `is_system_collection` field (simpler, no conversion) +- ✅ `media_item_id` matches Go `BookInfo.MediaItemID` field (consistent with existing API) +- ✅ Uses existing `BookInfo` struct from collections.go +- ✅ No duplicate type definitions +- ✅ Added to existing `api.d.ts` file (follows established pattern) --- -### **Phase 7: Router Registration** (30 min) +### **Phase 7: Router Registration & Config Setup** (45 min) + +**CRITICAL: Config struct updates needed in 3 files** + +The Config struct is used throughout the application and must be updated consistently. + +**Step 1: Update router.go Config struct** (5 min) + +**File: `internal/router/router.go`** (MODIFY existing) + +Add to Config struct (after line 56): + +```go +type Config struct { + Echo *echo.Echo + Queries *database.Queries + Cfg *config.Config + DBPool interface{} // pgxpool.Pool interface + AuthHandler *handlers.AuthHandler + LibraryHandler *handlers.LibraryHandler + DeviceHandler *handlers.DeviceHandler + MediaHandler *handlers.MediaHandler + MatchingHandler *handlers.MatchingHandler + KOReaderHandler *handlers.KOReaderHandler + WSHandler *handlers.WSHandler + ConflictHandler *handlers.ConflictHandler + AnalyticsHandler *handlers.AnalyticsHandler + QueueHandler *handlers.QueueHandler + CollectionHandler *handlers.CollectionHandler + OPDSHandler *handlers.OPDSHandler + SystemSettingsHandler *handlers.SystemSettingsHandler + ConnManager *sync.ConnectionManager + QueueProcessor *sync.SyncQueueProcessor + DeviceAuthMiddleware *middleware.DeviceAuthMiddleware + LoginTracker *ratelimit.LoginAttemptTracker + ScannerHandler *handlers.Handler + DashboardService *services.DashboardService // NEW: For dashboard data fetching +} +``` + +**Step 2: Update main.go initialization** (10 min) + +**File: `cmd/server/main.go`** (MODIFY existing) + +Add after line 123 (after collectionHandler initialization): + +```go +// Dashboard service for unified collections architecture +dashboardService := services.NewDashboardService(queries) +dashboardHandler := handlers.NewDashboardHandler(queries) +``` + +Add to routerConfig struct (after line 172): + +```go +routerConfig := &router.Config{ + Echo: e, + Queries: queries, + Cfg: cfg, + DBPool: dbPool, + AuthHandler: authHandler, + LibraryHandler: libraryHandler, + DeviceHandler: deviceHandler, + MediaHandler: mediaHandler, + MatchingHandler: matchingHandler, + KOReaderHandler: koreaderHandler, + WSHandler: wsHandler, + ConflictHandler: conflictHandler, + AnalyticsHandler: analyticsHandler, + QueueHandler: queueHandler, + CollectionHandler: collectionHandler, + OPDSHandler: opdsHandler, + SystemSettingsHandler: systemSettingsHandler, + ConnManager: connManager, + QueueProcessor: queueProcessor, + DeviceAuthMiddleware: deviceAuthMiddleware, + LoginTracker: loginAttemptTracker, + DashboardService: dashboardService, // NEW + DashboardHandler: dashboardHandler, // NEW +} +``` + +**Step 3: Update test_helpers.go** (10 min) + +**File: `cmd/server/tests/test_helpers.go`** (MODIFY existing) + +Add after line 419 (after opdsHandler initialization): + +```go +// Dashboard service for testing +dashboardService := services.NewDashboardService(queries) +dashboardHandler := handlers.NewDashboardHandler(queries) +``` + +Add to routerConfig struct (after line 458): + +```go +routerConfig := &router.Config{ + Echo: e, + Queries: queries, + Cfg: cfg, + DBPool: dbPool, + AuthHandler: authHandler, + LibraryHandler: libraryHandler, + DeviceHandler: deviceHandler, + MediaHandler: mediaHandler, + MatchingHandler: matchingHandler, + KOReaderHandler: koreaderHandler, + WSHandler: wsHandler, + ConflictHandler: conflictHandler, + AnalyticsHandler: analyticsHandler, + QueueHandler: queueHandler, + SystemSettingsHandler: systemSettingsHandler, + CollectionHandler: collectionHandler, + OPDSHandler: opdsHandler, + ConnManager: connManager, + QueueProcessor: queueProcessor, + DeviceAuthMiddleware: deviceAuthMiddleware, + LoginTracker: loginAttemptTracker, + DashboardService: dashboardService, // NEW + DashboardHandler: dashboardHandler, // NEW +} +``` + +**Step 4: Create dashboard router file** (20 min) **File: `internal/router/dashboard.go`** (new file) @@ -977,21 +1294,14 @@ func registerDashboardRoutes(cfg *Config) { } ``` -**Add to router.go**: -```go -type Config struct { - // ... existing fields ... - DashboardHandler *handlers.DashboardHandler -} +**IMPORTANT: Why both DashboardService AND DashboardHandler in Config?** -// In setup function: -registerDashboardRoutes(cfg) -``` +- **DashboardService**: Used by SSR routes in `frontend.go` to fetch dashboard data (system collections, user collections, user preferences) +- **DashboardHandler**: Used by API routes in `dashboard.go` to serve JSON endpoints (`/api/dashboard/sections`, `/api/dashboard/preferences`, etc.) +- **Mobile apps**: Will use API endpoints via DashboardHandler +- **Web UI**: Uses SSR (DashboardService) for initial load + API (DashboardHandler) for interactions -**Initialize in cmd/server/main.go**: -```go -cfg.DashboardHandler = handlers.NewDashboardHandler(cfg.Queries) -``` +Both are initialized in main.go and passed through Config to avoid creating multiple instances. --- @@ -1021,7 +1331,7 @@ frontendProtected.GET("/dashboard", func(c echo.Context) error { prefs, _ := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID) - sectionItems, err := cfg.DashboardService.GetSectionItems( + sections, err := cfg.DashboardService.GetDashboardSections( c.Request().Context(), userUUID, libUUID, @@ -1049,10 +1359,11 @@ frontendProtected.GET("/dashboard", func(c echo.Context) error { } } - sections := cfg.DashboardHandler.BuildSections(sectionItems) + // Convert service types to handler types for template + sectionData := BuildSections(sections) var buf bytes.Buffer - err = templates.Dashboard(user, sections, libData, libraryID).Render(c.Request().Context(), &buf) + err = templates.Dashboard(user, sectionData, libData, libraryID).Render(c.Request().Context(), &buf) if err != nil { return err } @@ -1074,7 +1385,7 @@ import ( "bookhoard/internal/handlers" ) -templ Dashboard(user User, sections []handlers.SectionData, libraries []LibraryData, currentLibraryID string) { +templ Dashboard(user User, sections []handlers.SectionData, libData []LibraryData, currentLibraryID string) {
@@ -1100,7 +1411,7 @@ templ Dashboard(user User, sections []handlers.SectionData, libraries []LibraryD class="px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500" style="background-color: var(--bg-secondary); color: var(--text-primary);" data-action="switch-library"> - for _, lib := range libraries { + for _, lib := range libData { if lib.ID == currentLibraryID { } else { @@ -1148,7 +1459,7 @@ templ Dashboard(user User, sections []handlers.SectionData, libraries []LibraryD templ CollectionCarousel(section handlers.SectionData) {