diff --git a/CAROUSEL_DASHBOARD_PLAN.md b/CAROUSEL_DASHBOARD_PLAN.md index 8eaed24..9562293 100644 --- a/CAROUSEL_DASHBOARD_PLAN.md +++ b/CAROUSEL_DASHBOARD_PLAN.md @@ -3,11 +3,12 @@ ## Overview Transform the current dashboard into a **production-ready** horizontal carousel layout like Audiobookshelf/Kavita, with: -- **4 Smart sections**: Continue Reading, Recently Added, Recently Read, Not Started +- **Unified Collections Architecture**: Both system defaults and user-created sections are collections +- **4 System Collections**: Continue Reading, Recently Added, Recently Read, Not Started (pre-seeded, editable) - User collections as sections (manual or filter-based) - Separate dashboard per library - Full accessibility, keyboard nav, and touch gestures -- **SSR-first architecture** (data pre-populated server-side, HTMX for updates) +- **SSR-first architecture** (data pre-populated server-side, TypeScript for updates) - **Drag-and-drop reordering** with user preference persistence --- @@ -43,7 +44,7 @@ This plan **adheres to** all PROJECT_GUIDELINES.md requirements with explicit us **Key Compliance Points:** ✅ **Full-Stack Task** (backend modifications approved): -- Database schema changes +- Database schema changes (unified collections architecture) - New service layer for reusable business logic - New API endpoints for mobile app compatibility - Bruno tests already created in `bruno/dashboard/` @@ -54,6 +55,7 @@ This plan **adheres to** all PROJECT_GUIDELINES.md requirements with explicit us - **Procedural/imperative style** - no OOP (classes, inheritance, this-capture) - **SSR for initial page load** - server pre-populates data (like collections, progress pages) - **TypeScript for interactive updates** - library switching, filtering, settings (fetch JSON, re-render) +- **NO HTMX for dynamic interactions** - library selector, modal saves use pure TypeScript - **Event delegation pattern** - `data-action` attributes - **API client** - `(window as any).api` from `web/src/api.ts` - **Toast notifications** - `(window as any).showToast` from `web/src/toast.ts` @@ -73,10 +75,56 @@ This plan **adheres to** all PROJECT_GUIDELINES.md requirements with explicit us - **pgx v5 standards** - proper connection handling ✅ **API Documentation**: -- **Bruno tests** already created in `bruno/dashboard/` -- - Three-context testing (no user, user, admin) -- - Backward compatibility for mobile apps -- - `docs/developer/api/** documentation updates +- **Bruno tests** in `bruno/dashboard/` +- Three-context testing (no user, user, admin) +- Backward compatibility for mobile apps +- `docs/developer/api/** documentation updates + +--- + +## 🎯 Unified Collections Architecture + +### Key Design Principle + +**Simplified Concept**: Both system defaults and user-created sections are **collections**. This eliminates the duplication of having separate "smart sections" and "collections" concepts. + +### Architecture Details + +**Collections Table Structure:** +```sql +CREATE TABLE collections ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NULL REFERENCES users(id), -- NULL = system-owned, NOT NULL = user-created + name VARCHAR(100) NOT NULL, + description TEXT, + color VARCHAR(7), + icon VARCHAR(50), + auto_assign_rules JSONB, + show_on_dashboard BOOLEAN DEFAULT false, + query_type TEXT DEFAULT 'filter', -- 'filter', 'recent', 'progress', etc. + priority INT DEFAULT 100, + is_system_collection BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(user_id, name) +); +``` + +**Key Fields:** +- `user_id NULL` = System-owned collections (4 defaults) +- `user_id NOT NULL` = User-created collections +- `query_type` = Determines how items are fetched ('filter', 'recent', 'progress-based') +- `is_system_collection` = Flags system collections for restore defaults functionality +- `show_on_dashboard` = Controls visibility on dashboard +- `priority` = Display order (lower = higher priority) + +### Benefits of Unified Architecture + +1. **Single Table, Single Concept** - No duplication between "smart sections" and "collections" +2. **Same Mechanism** - System defaults use same code path as user collections +3. **Editable System Collections** - Users can customize default sections +4. **Restore Defaults** - Can reset system collections if user messes up +5. **Simpler Queries** - Dashboard just queries collections WHERE (user_id IS NULL OR user_id = X) +6. **Extensible** - Easy to add new system collections --- @@ -93,7 +141,7 @@ podman compose down -v # Delete volumes (loses all data) podman compose up -d # Start fresh with new schema ``` -**Add to schema.sql**: +**Add/Modify in schema.sql**: ```sql -- Table: user_dashboard_preferences @@ -101,8 +149,8 @@ CREATE TABLE user_dashboard_preferences ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, library_id UUID REFERENCES libraries(id) ON DELETE CASCADE, - hidden_sections TEXT[] DEFAULT '{}', - section_order TEXT[] DEFAULT '{}', + hidden_collections TEXT[] DEFAULT '{}', -- Changed from hidden_sections + collection_order TEXT[] DEFAULT '{}', -- Changed from section_order items_per_section INT DEFAULT 20, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), @@ -112,42 +160,49 @@ CREATE TABLE user_dashboard_preferences ( -- Index for fast lookups CREATE INDEX idx_dashboard_prefs_user_library ON user_dashboard_preferences(user_id, library_id); --- Add column to existing collections table +-- Modify collections table to support unified architecture +ALTER TABLE collections ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE collections ALTER COLUMN user_id DROP NOT NULL; -- Allow NULL for system collections ALTER TABLE collections ADD COLUMN IF NOT EXISTS show_on_dashboard BOOLEAN DEFAULT false; +ALTER TABLE collections ADD COLUMN IF NOT EXISTS query_type TEXT DEFAULT 'filter'; +ALTER TABLE collections ADD COLUMN IF NOT EXISTS priority INT DEFAULT 100; +ALTER TABLE collections ADD COLUMN IF NOT EXISTS is_system_collection BOOLEAN DEFAULT false; + +-- Drop unique constraint on (user_id, name) and recreate to allow NULL user_id +ALTER TABLE collections DROP CONSTRAINT IF EXISTS collections_user_id_name_key; +ALTER TABLE collections ADD CONSTRAINT collections_user_id_name_key UNIQUE (user_id, name); -- Index for dashboard queries -CREATE INDEX IF NOT EXISTS idx_collections_dashboard ON collections(user_id, show_on_dashboard) +CREATE INDEX IF NOT EXISTS idx_collections_dashboard ON collections(user_id, show_on_dashboard, priority) WHERE show_on_dashboard = true; -- Add excluded column to collection_items for user overrides --- Allows users to exclude auto-assigned items from filter-based collections ALTER TABLE collection_items ADD COLUMN IF NOT EXISTS excluded BOOLEAN DEFAULT false; -- Index for excluding auto-assigned items CREATE INDEX IF NOT EXISTS idx_collection_items_excluded ON collection_items(collection_id, excluded) WHERE excluded = true; --- Predefined smart sections (system-level, not user-created) --- Stores metadata for the 4 default smart sections -CREATE TABLE IF NOT EXISTS smart_section_types ( - id SERIAL PRIMARY KEY, - section_key TEXT UNIQUE NOT NULL, - title TEXT NOT NULL, - description TEXT, - icon TEXT, - default_priority INT, - is_global BOOLEAN DEFAULT false -- true = uses global data (Recently Added), false = per-user -); - --- Insert default sections (4 smart sections) -INSERT INTO smart_section_types (section_key, title, description, icon, default_priority, is_global) VALUES -('continue-reading', 'Continue Reading', 'Books you''re currently reading (0 < progress < 1)', '📖', 1, false), -('recently-added', 'Recently Added', 'Newly added items to this library', '🆕', 2, true), -('recently-read', 'Recently Read', 'Books you''ve finished (progress >= 1)', '✅', 3, false), -('unread', 'Not Started', 'Books you haven''t read yet (progress = 0 or no record)', '📕', 4, false) -ON CONFLICT (section_key) DO NOTHING; +-- Insert 4 system collections (pre-seeded defaults) +-- These are user_id NULL to indicate system ownership +INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules) VALUES +(NULL, 'continue-reading', 'Books you''re currently reading (0 < progress < 1)', '📖', '#7aa2f7', true, 'continue-reading', 1, true, 'null'), +(NULL, 'recently-added', 'Newly added items to this library', '🆕', '#9ece6a', true, 'recently-added', 2, true, 'null'), +(NULL, 'recently-read', 'Books you''ve finished (progress >= 1)', '✅', '#e0af68', true, 'recently-read', 3, true, 'null'), +(NULL, 'not-started', 'Books you haven''t read yet (progress = 0 or no record)', '📕', '#f7768e', true, 'not-started', 4, true, 'null') +ON CONFLICT (user_id, name) DO NOTHING; ``` +**Schema Changes Summary:** +- ✅ Added `user_id` to collections table (nullable for system collections) +- ✅ Added `show_on_dashboard` boolean +- ✅ Added `query_type` text field +- ✅ Added `priority` integer field +- ✅ Added `is_system_collection` boolean flag +- ✅ Removed `smart_section_types` table entirely +- ✅ Pre-seeded 4 system collections +- ✅ Updated user_dashboard_preferences field names (hidden_sections → hidden_collections) + #### 1.2 Regenerate Database Code ```bash cd internal/database @@ -155,7 +210,7 @@ sqlc generate ``` Verify: -- ✅ `models.go` has new structs +- ✅ `models.go` has updated Collections struct - ✅ `queries.sql` is ready for new queries - ✅ No compilation errors @@ -171,316 +226,331 @@ Verify: package services import ( - "context" - "encoding/json" - "bookhoard/internal/database" - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgtype" + "context" + "encoding/json" + "bookhoard/internal/database" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" ) type DashboardService struct { - db *database.Queries - collectionService *CollectionService + db *database.Queries + collectionService *CollectionService } // NewDashboardService creates service instance func NewDashboardService(db *database.Queries) *DashboardService { - return &DashboardService{ - db: db, - collectionService: NewCollectionService(db), - } + return &DashboardService{ + db: db, + collectionService: NewCollectionService(db), + } } // SectionItems contains raw items for a section - handler formats into SectionData type SectionItems struct { - SectionKey string - Items []database.MediaItems + CollectionID uuid.UUID + SectionKey string + Items []database.MediaItems + QueryType string + Priority int + IsSystem bool + Title string + Description string + Icon string } -// GetSectionItems fetches raw items for each section type -// Accepts user preferences to customize order and visibility -// Handler will format these into template.SectionData +// GetSectionItems fetches raw items for each collection shown on dashboard +// Returns both system collections and user collections marked for dashboard func (s *DashboardService) GetSectionItems( - ctx context.Context, - userID, libraryID uuid.UUID, - limit int, - sectionOrder []string, // User's custom order (empty = default) - hiddenSections []string, // User's hidden sections (empty = show all) + ctx context.Context, + userID, libraryID uuid.UUID, + limit int, + collectionOrder []string, + hiddenCollections []string, ) ([]SectionItems, error) { - var results []SectionItems + var results []SectionItems - // 1. Continue Reading - items with progress > 0 and < 1 - continueReading, _ := s.getContinueReading(ctx, userID, libraryID, limit) - results = append(results, SectionItems{SectionKey: "continue-reading", Items: continueReading}) + // Get system collections (user_id = NULL) + systemCollections, err := s.db.GetSystemCollectionsForDashboard(ctx) + if err != nil { + return nil, err + } - // 2. Recently Added - newest items in library - recentlyAdded, _ := s.getRecentlyAdded(ctx, libraryID, limit) - results = append(results, SectionItems{SectionKey: "recently-added", Items: recentlyAdded}) + // Get user collections marked for dashboard + userCollections, err := s.db.GetUserCollectionsForDashboard(ctx, pgtype.UUID{Bytes: userID, Valid: true}) + if err != nil { + return nil, err + } - // 3. Recently Read - items with progress >= 1 - recentlyRead, _ := s.getRecentlyRead(ctx, userID, libraryID, limit) - results = append(results, SectionItems{SectionKey: "recently-read", Items: recentlyRead}) + // Process system collections + for _, coll := range systemCollections { + collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16]) - // 4. Not Started - items with progress = 0 OR no reading_progress record - unread, _ := s.getUnread(ctx, userID, libraryID, limit) - results = append(results, SectionItems{SectionKey: "unread", Items: unread}) + // Get items based on query_type + items, err := s.getCollectionItemsByQueryType(ctx, coll, userID, libraryID, limit) + if err != nil { + continue + } - // 5. User collections marked for dashboard - collectionItems, _ := s.getCollectionSections(ctx, userID, libraryID, limit) - results = append(results, collectionItems...) + 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, + }) + } - // Apply user preferences: filter hidden sections - results = s.filterHiddenSections(results, hiddenSections) + // Process user collections + for _, coll := range userCollections { + collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16]) - // Apply user preferences: reorder sections - results = s.reorderSections(results, sectionOrder) + // Get items (manual + auto-assign rules) + items, err := s.getUserCollectionItems(ctx, coll, userID, libraryID, limit) + if err != nil { + continue + } - return results, nil + if len(items) == 0 { + 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, + }) + } + + // Apply user preferences: filter hidden collections + results = s.filterHiddenCollections(results, hiddenCollections) + + // Apply user preferences: reorder collections + results = s.reorderCollections(results, collectionOrder) + + // Sort by priority if no custom order + if len(collectionOrder) == 0 { + results = s.sortByPriority(results) + } + + return results, nil } -// filterHiddenSections removes sections the user has hidden -func (s *DashboardService) filterHiddenSections(items []SectionItems, hidden []string) []SectionItems { - if len(hidden) == 0 { - return items // No filters, return all - } - - 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 +// 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 { + case "continue-reading": + return s.db.GetContinueReadingItems(ctx, database.GetContinueReadingItemsParams{ + UserID: pgtype.UUID{Bytes: userID, Valid: true}, + LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, + Limit: int32(limit), + }) + case "recently-added": + return s.db.GetRecentlyAddedItems(ctx, database.GetRecentlyAddedItemsParams{ + LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, + Limit: int32(limit), + }) + case "recently-read": + return s.db.GetRecentlyReadItems(ctx, database.GetRecentlyReadItemsParams{ + UserID: pgtype.UUID{Bytes: userID, Valid: true}, + LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, + Limit: int32(limit), + }) + case "not-started": + return s.db.GetNotStartedItems(ctx, database.GetNotStartedItemsParams{ + UserID: pgtype.UUID{Bytes: userID, Valid: true}, + LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, + Limit: int32(limit), + }) + default: + return []database.MediaItems{}, nil + } } -// reorderSections reorders sections according to user's custom order -// Sections not in custom order are appended at the end -func (s *DashboardService) reorderSections(items []SectionItems, order []string) []SectionItems { - if len(order) == 0 { - return items // No custom order, return as-is - } +// getUserCollectionItems returns items for user collections (manual + auto-assign) +func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll database.Collections, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) { + collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16]) - // Create ordered result - var ordered []SectionItems - remaining := make(map[string]SectionItems) - for _, item := range items { - remaining[item.SectionKey] = item - } + // Get manually added items + manualItems, err := s.db.GetCollectionItems(ctx, database.GetCollectionItemsParams{ + CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true}, + LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, + Limit: int32(limit), + }) + if err != nil { + return nil, err + } - // Add sections in user's preferred order - for _, key := range order { - if item, exists := remaining[key]; exists { - ordered = append(ordered, item) - delete(remaining, key) - } - } + // Filter out excluded items + var manualNonExcluded []database.MediaItems + for _, item := range manualItems { + if !item.Excluded.Valid || !item.Excluded.Bool { + manualNonExcluded = append(manualNonExcluded, item) + } + } - // Append any sections not in custom order (e.g., new collections) - for _, item := range items { - if _, exists := remaining[item.SectionKey]; exists { - ordered = append(ordered, item) - } - } + // Evaluate auto-assign rules if collection has any + var autoItems []database.MediaItems + if len(coll.AutoAssignRules) > 0 { + var rules []Rule + if err := json.Unmarshal(coll.AutoAssignRules, &rules); err == nil && len(rules) > 0 { + allLibraryItems, err := s.db.GetLibraryItems(ctx, pgtype.UUID{Bytes: libraryID, Valid: true}) + if err == nil { + for _, item := range allLibraryItems { + // Skip if already in manual items + alreadyInCollection := false + for _, manualItem := range manualNonExcluded { + if manualItem.ID.Bytes[0:16] == item.ID.Bytes[0:16] { + alreadyInCollection = true + break + } + } + if alreadyInCollection { + continue + } - return ordered + // Evaluate rules + evaluations := s.collectionService.EvaluateRules(item, rules) + for _, eval := range evaluations { + if eval.Matches { + autoItems = append(autoItems, item) + break + } + } + } + } + } + } + + // Merge manual and auto items + var finalItems []database.MediaItems + finalItems = append(finalItems, manualNonExcluded...) + finalItems = append(finalItems, autoItems...) + + if len(finalItems) > limit { + finalItems = finalItems[:limit] + } + + return finalItems, nil } -func (s *DashboardService) getContinueReading(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) { - // Books in progress (0 < progress < 1) - items, err := s.db.GetContinueReadingItems(ctx, database.GetContinueReadingItemsParams{ - UserID: pgtype.UUID{Bytes: userID, Valid: true}, - LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, - Limit: int32(limit), - }) - if err != nil { - return nil, err - } - return items, 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 } -func (s *DashboardService) getRecentlyAdded(ctx context.Context, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) { - // Newest items in library - items, err := s.db.GetRecentlyAddedItems(ctx, database.GetRecentlyAddedItemsParams{ - LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, - Limit: int32(limit), - }) - if err != nil { - return nil, err - } - return items, nil +// 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 } -func (s *DashboardService) getRecentlyRead(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) { - // Books completed (progress >= 1) - // Books manually marked as read (progress set to 1) appear here - items, err := s.db.GetRecentlyReadItems(ctx, database.GetRecentlyReadItemsParams{ - UserID: pgtype.UUID{Bytes: userID, Valid: true}, - LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, - Limit: int32(limit), - }) - if err != nil { - return nil, err - } - return items, nil -} +// sortByPriority sorts collections by priority field +func (s *DashboardService) sortByPriority(items []SectionItems) []SectionItems { + sorted := make([]SectionItems, len(items)) + copy(sorted, items) -func (s *DashboardService) getUnread(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) { - // Books not started (progress = 0 OR no reading_progress record) - // Books manually marked as unread (progress set to 0) appear here - items, err := s.db.GetUnreadItems(ctx, database.GetUnreadItemsParams{ - UserID: pgtype.UUID{Bytes: userID, Valid: true}, - LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, - Limit: int32(limit), - }) - if err != nil { - return nil, err - } - return items, nil -} + // 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] + } + } + } -func (s *DashboardService) getCollectionSections(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]SectionItems, error) { - // Get collections marked for dashboard (user-level, not library-specific) - collections, err := s.db.GetCollectionsForDashboard(ctx, pgtype.UUID{Bytes: userID, Valid: true}) - if err != nil { - return nil, err - } - - var results []SectionItems - for _, coll := range collections { - collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16]) - - // Get manually added items for this collection, filtered by library - // Query returns items with excluded flag from collection_items table - manualItems, err := s.db.GetCollectionItems(ctx, database.GetCollectionItemsParams{ - CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true}, - LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, - Limit: int32(limit), - }) - if err != nil { - continue // Skip collections with errors - } - - // Filter out excluded items (where excluded = true) - var manualNonExcluded []database.MediaItems - for _, item := range manualItems { - // item.Excluded comes from the query (ci.excluded) - // If excluded is NULL or false, include the item - if !item.Excluded.Valid || !item.Excluded.Bool { - manualNonExcluded = append(manualNonExcluded, item) - } - } - - // Evaluate auto-assign rules if collection has any - var autoItems []database.MediaItems - if len(coll.AutoAssignRules) > 0 { - // Parse rules from JSONB - var rules []Rule - if err := json.Unmarshal(coll.AutoAssignRules, &rules); err == nil && len(rules) > 0 { - // Get all library items to evaluate against - allLibraryItems, err := s.db.GetLibraryItems(ctx, pgtype.UUID{Bytes: libraryID, Valid: true}) - if err == nil { - // Evaluate rules for each library item - for _, item := range allLibraryItems { - // Skip if already in manual items - alreadyInCollection := false - for _, manualItem := range manualNonExcluded { - if manualItem.ID.Bytes[0:16] == item.ID.Bytes[0:16] { - alreadyInCollection = true - break - } - } - if alreadyInCollection { - continue - } - - // Evaluate rules - evaluations := s.collectionService.EvaluateRules(item, rules) - // If any rule matches, add to auto items - for _, eval := range evaluations { - if eval.Matches { - autoItems = append(autoItems, item) - break - } - } - } - } - } - } - - // Merge manual and auto items, excluding any marked as excluded - var finalItems []database.MediaItems - finalItems = append(finalItems, manualNonExcluded...) - finalItems = append(finalItems, autoItems...) - - // Apply limit - if len(finalItems) > limit { - finalItems = finalItems[:limit] - } - - // Only add collection if it has items in this library - if len(finalItems) > 0 { - results = append(results, SectionItems{ - SectionKey: coll.Name, // Use collection name as section key - Items: finalItems, - }) - } - } - - return results, nil + 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{ - UserID: pgtype.UUID{Bytes: userID, Valid: true}, - LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, - }) + return s.db.GetDashboardPreferences(ctx, database.GetDashboardPreferencesParams{ + UserID: pgtype.UUID{Bytes: userID, Valid: true}, + LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, + }) } // UpsertDashboardPreferences saves or updates user preferences for a library func (s *DashboardService) UpsertDashboardPreferences(ctx context.Context, params database.UpsertDashboardPreferencesParams) (database.UserDashboardPreferences, error) { - return s.db.UpsertDashboardPreferences(ctx, params) + return s.db.UpsertDashboardPreferences(ctx, params) +} + +// RestoreSystemCollection resets a single system collection to defaults for a user +// collectionName is the name of the system collection to restore (e.g., "continue-reading") +func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID uuid.UUID, collectionName string) error { + // Delete user-owned copy of this specific system collection + err := s.db.DeleteUserSystemCollection(ctx, database.DeleteUserSystemCollectionParams{ + UserID: pgtype.UUID{Bytes: userID, Valid: true}, + Name: collectionName, + }) + if err != nil { + return err + } + + // System collection (user_id = NULL) will automatically appear on dashboard + // No need to recreate it + return nil } ``` **Key Points**: - ✅ Service layer holds all business logic +- ✅ Unified handling of system and user collections - ✅ 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 -### Manual Progress Marking - -**Users can manually set reading status** - progress value is the single source of truth: - -- **Mark as Unread** → Set `progress = 0` → Book appears in "Not Started" section -- **Mark as Read** → Set `progress = 1` → Book appears in "Recently Read" section -- Uses existing reading_progress endpoint (no new API needed) - -**How it works:** -``` -Device sync: progress = 0.35 (35% through book) -User marks as read: progress = 1.0 (now in "Recently Read") -User marks as unread: progress = 0.0 (now in "Not Started") -``` - -**Benefits:** -- Users can "give up" on a book without it cluttering "Continue Reading" -- Users can mark partially-read books as complete -- Simple implementation (just set progress to 0 or 1) -- Consistent with automatic progress tracking from devices - -**Note**: The reading_progress endpoint and database table already exist. No backend changes needed for manual marking. - --- ### **Phase 3: Database Queries** (1-2 hours) @@ -493,39 +563,45 @@ SELECT * FROM user_dashboard_preferences WHERE user_id = $1 AND library_id = $2; -- name: UpsertDashboardPreferences :one -INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_sections, section_order, items_per_section) +INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (user_id, library_id) DO UPDATE SET - hidden_sections = EXCLUDED.hidden_sections, - section_order = EXCLUDED.section_order, + hidden_collections = EXCLUDED.hidden_collections, + collection_order = EXCLUDED.collection_order, items_per_section = EXCLUDED.items_per_section, updated_at = NOW() RETURNING *; -- name: UpdateDashboardPreferences :one UPDATE user_dashboard_preferences -SET hidden_sections = $2, - section_order = $3, +SET hidden_collections = $2, + collection_order = $3, items_per_section = $4, updated_at = NOW() WHERE user_id = $1 AND library_id = $5 RETURNING *; --- name: GetCollectionsForDashboard :many +-- name: GetSystemCollectionsForDashboard :many +SELECT * FROM collections +WHERE user_id IS NULL + AND show_on_dashboard = true +ORDER BY priority ASC; + +-- name: GetUserCollectionsForDashboard :many SELECT c.* FROM collections c WHERE c.user_id = $1 AND c.show_on_dashboard = true -ORDER BY c.created_at DESC; + AND c.is_system_collection = false +ORDER BY priority ASC; --- name: SetCollectionDashboardVisibility :one -INSERT INTO collections (id, show_on_dashboard) -VALUES ($1, $2) -ON CONFLICT (id) DO UPDATE SET - show_on_dashboard = EXCLUDED.show_on_dashboard -RETURNING *; +-- name: DeleteUserSystemCollection :exec +DELETE FROM collections +WHERE user_id = $1 + AND name = $2 + AND is_system_collection = true; --- Smart section queries +-- Smart section queries (for system collections) -- name: GetContinueReadingItems :many SELECT DISTINCT mi.* FROM media_items mi @@ -552,7 +628,7 @@ WHERE mi.library_id = $1 ORDER BY rp.last_read_at DESC LIMIT $3; --- name: GetUnreadItems :many +-- name: GetNotStartedItems :many SELECT mi.* FROM media_items mi WHERE mi.library_id = $1 AND NOT EXISTS ( @@ -592,1293 +668,262 @@ Regenerate: `cd internal/database && sqlc generate` package handlers import ( - "net/http" - "strconv" - "bookhoard/internal/database" - "bookhoard/internal/services" + "net/http" + "strconv" + "bookhoard/internal/database" + "bookhoard/internal/services" - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgtype" - "github.com/labstack/echo/v4" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/labstack/echo/v4" ) // SectionData represents a dashboard section (carousel) // Used by: Templates (SSR), API JSON responses -// Template-specific fields: Type, Icon, ViewAllURL, Priority type SectionData struct { - ID string `json:"id"` - Type string `json:"type"` // "smart" or "collection" - Title string `json:"title"` - Description string `json:"description"` - Icon string `json:"icon"` // Template-specific: emoji - Items []BookInfo `json:"items"` - ViewAllURL string `json:"view_all_url"` // Template-specific: navigation - Priority int `json:"priority"` // Template-specific: display order + 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 -// Unwraps pgtype fields for template convenience type BookInfo struct { - ID string `json:"id"` - Title string `json:"title"` - Author string `json:"author"` - CoverImagePath string `json:"cover_image_path"` + 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 + db *database.Queries + dashboardService *services.DashboardService } func NewDashboardHandler(db *database.Queries) *DashboardHandler { - return &DashboardHandler{ - db: db, - dashboardService: services.NewDashboardService(db), - } + return &DashboardHandler{ + db: db, + dashboardService: services.NewDashboardService(db), + } } // GetSections returns dashboard sections as JSON // Used by: Mobile apps, web UI TypeScript, plugins func (h *DashboardHandler) GetSections(c echo.Context) error { - user := c.Get("user").(database.Users) - userUUID := uuid.UUID(user.ID.Bytes) + user := c.Get("user").(database.Users) + userUUID := uuid.UUID(user.ID.Bytes) - // Get library_id from query param - libraryID := c.QueryParam("library_id") - if libraryID == "" { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"}) - } - libUUID, err := uuid.Parse(libraryID) - if err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"}) - } + libraryID := c.QueryParam("library_id") + if libraryID == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"}) + } + libUUID, err := uuid.Parse(libraryID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"}) + } - // Get user's dashboard preferences (customization) - prefs, _ := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID) + prefs, _ := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID) - // Get limit from query param (default 20) - limit := 20 - if limitStr := c.QueryParam("limit"); limitStr != "" { - if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { - limit = l - } - } + limit := 20 + if limitStr := c.QueryParam("limit"); limitStr != "" { + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { + limit = l + } + } - // Get sections (applies user's order and hidden sections) - sectionItems, err := h.dashboardService.GetSectionItems( - c.Request().Context(), - userUUID, - libUUID, - limit, - prefs.SectionOrder, - prefs.HiddenSections, - ) - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load sections"}) - } + sectionItems, err := h.dashboardService.GetSectionItems( + c.Request().Context(), + userUUID, + libUUID, + limit, + prefs.CollectionOrder, + prefs.HiddenCollections, + ) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load sections"}) + } - // Convert to handler types using buildSections (same as SSR templates) - // Echo automatically serializes SectionData to JSON via struct tags - sections := buildSections(sectionItems) - return c.JSON(http.StatusOK, map[string]interface{}{"sections": sections}) + sections := BuildSections(sectionItems) + return c.JSON(http.StatusOK, map[string]interface{}{"sections": sections}) } -// Note: buildSections() is defined in internal/router/frontend.go -// It converts services.SectionItems to handlers.SectionData with proper pgtype unwrapping -// This function is reused for both SSR templates and API JSON responses +// UpdatePreferences saves dashboard preferences +func (h *DashboardHandler) UpdatePreferences(c echo.Context) error { + user := c.Get("user").(database.Users) + userUUID := uuid.UUID(user.ID.Bytes) + var req struct { + LibraryID string `json:"library_id"` + HiddenCollections []string `json:"hidden_collections"` + CollectionOrder []string `json:"collection_order"` + ItemsPerSection int `json:"items_per_section"` + } -// Helper functions for section metadata -func getSectionType(key string) string { - // Return "smart" or "collection" based on key - smartSections := map[string]bool{ - "continue-reading": true, - "recently-added": true, - "recently-read": true, - "unread": true, - } - if smartSections[key] { - return "smart" - } - return "collection" + 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"}) + } + + prefs, err := h.dashboardService.UpsertDashboardPreferences(c.Request().Context(), database.UpsertDashboardPreferencesParams{ + UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, + LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, + HiddenCollections: req.HiddenCollections, + CollectionOrder: req.CollectionOrder, + ItemsPerSection: pgtype.Int4{Int32: int32(req.ItemsPerSection), Valid: true}, + }) + + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to save preferences"}) + } + + return c.JSON(http.StatusOK, prefs) } -func getSectionTitle(key string) string { - titles := map[string]string{ - "continue-reading": "Continue Reading", - "recently-added": "Recently Added", - "recently-read": "Recently Read", - "unread": "Not Started", - } - if title, exists := titles[key]; exists { - return title - } - return key // Collection name +// RestoreSystemCollection resets a single system collection to defaults +func (h *DashboardHandler) RestoreSystemCollection(c echo.Context) error { + user := c.Get("user").(database.Users) + userUUID := uuid.UUID(user.ID.Bytes) + + var req struct { + CollectionName string `json:"collection_name"` + } + + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"}) + } + + if req.CollectionName == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "collection_name required"}) + } + + // Validate it's a system collection name + validCollections := map[string]bool{ + "continue-reading": true, + "recently-added": true, + "recently-read": true, + "not-started": true, + } + if !validCollections[req.CollectionName] { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid system collection name"}) + } + + err := h.dashboardService.RestoreSystemCollection(c.Request().Context(), userUUID, req.CollectionName) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to restore system collection"}) + } + + return c.JSON(http.StatusOK, map[string]string{"message": "System collection restored to defaults"}) } -func getSectionIcon(key string) string { - icons := map[string]string{ - "continue-reading": "📖", - "recently-added": "🆕", - "recently-read": "✅", - "unread": "📕", - } - if icon, exists := icons[key]; exists { - return icon - } - return "📚" // Default collection icon +// BuildSections converts service SectionItems to handler SectionData +func BuildSections(items []services.SectionItems) []SectionData { + var sections []SectionData + + for _, si := range items { + bookCards := make([]BookInfo, len(si.Items)) + for i, item := range si.Items { + itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) + bookCards[i] = BookInfo{ + ID: itemUUID.String(), + Title: item.Title, + Author: item.Author.String, + CoverImagePath: item.CoverImagePath.String, + } + } + + 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, + Items: bookCards, + ViewAllURL: getViewAllURL(si.SectionKey, si.QueryType), + Priority: si.Priority, + }) + } + + return sections } -func getSectionViewAllURL(key string) string { - urls := map[string]string{ - "continue-reading": "/section/continue-reading", - "recently-added": "/section/recently-added", - "recently-read": "/history", - "unread": "/section/unread", - } - if url, exists := urls[key]; exists { - return url - } - return "" // Collections don't have view-all URLs +func getViewAllURL(key, queryType string) string { + urls := map[string]string{ + "continue-reading": "/section/continue-reading", + "recently-added": "/section/recently-added", + "recently-read": "/history", + "not-started": "/section/not-started", + } + if url, exists := urls[queryType]; exists { + return url + } + return "" // User collections don't have view-all URLs } ``` **Key Points**: - ✅ Generic JSON API endpoint -- ✅ Applies user preferences (order, hidden sections) +- ✅ Updated field names (hidden_collections, collection_order) +- ✅ Restore system collections endpoint - ✅ Reusable by mobile apps, web UI, plugins -- ✅ Returns sections in user's customized order -- ✅ Respects hidden sections preference - -#### Collections Preview Endpoint (Enhancement) - -**File: `internal/handlers/collections.go`** (MODIFY existing file) - -**Add new endpoint for previewing auto-assign rules**: - -```go -// PreviewAutoAssignRules returns books that match given rules -// Used by custom section builder to show matching books before creating section -func (h *CollectionHandler) PreviewAutoAssignRules(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 []services.Rule `json:"rules"` - 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"}) - } - - limit := req.Limit - if limit <= 0 || limit > 100 { - limit = 20 - } - - // Get all library items - allLibraryItems, err := h.queries.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 matchedBooks []handlers.BookInfo - collectionService := services.NewCollectionService(h.queries) - - for _, item := range allLibraryItems { - evaluations := collectionService.EvaluateRules(item, req.Rules) - - // Check if any rule matches with confidence > 0.7 - for _, eval := range evaluations { - if eval.Matches && eval.Confidence > 0.7 { - itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) - matchedBooks = append(matchedBooks, handlers.BookInfo{ - ID: itemUUID.String(), - Title: item.Title, - Author: item.Author.String, - CoverImagePath: item.CoverImagePath.String, - }) - - if len(matchedBooks) >= limit { - break - } - } - } - - if len(matchedBooks) >= limit { - break - } - } - - return c.JSON(http.StatusOK, map[string]interface{}{ - "books": matchedBooks, - "count": len(matchedBooks), - }) -} -``` - -**Add to router** (internal/router/collections.go): -```go -collectionsGroup.POST("/preview", cfg.CollectionHandler.PreviewAutoAssignRules) -``` - -**Key Points**: -- ✅ Evaluates auto-assign rules against library items -- ✅ Returns matching books for preview -- ✅ Uses existing collectionService.EvaluateRules() -- ✅ Reuses handlers.BookInfo type -- ✅ No database modifications (read-only preview) --- -### **Phase 5: API Router** (30 min) +### **Phase 5: Bruno API Tests** (1 hour) -**File: `internal/router/dashboard.go`** (new file) +**File: `bruno/dashboard/**`** (update existing tests) -**COMPLIANCE**: Follow existing router pattern (see router/collections.go) +**Update existing tests** to reflect new field names: +- ✅ `GET /api/dashboard/sections` - Response now includes unified collections +- ✅ `PUT /api/dashboard/preferences` - Updated request body: + ```json + { + "library_id": "uuid", + "hidden_collections": ["not-started"], + "collection_order": ["recently-added", "continue-reading", "recently-read"], + "items_per_section": 20 + } + ``` -```go -package router +**Create new test**: +- ✅ `POST /api/dashboard/restore-system-collection` - Restore specific system collection + - Request body: `{"collection_name": "continue-reading"}` + - Three contexts (no user → 401, user → success, admin → success) + - Verifies specific system collection is reset + - Test invalid collection_name returns 400 -import ( - "bookhoard/internal/handlers" - "github.com/labstack/echo/v4" -) - -func registerDashboardRoutes(cfg *Config) { - e := cfg.Echo - - // API routes (JSON endpoints) - // Uses JWT middleware from router.go - apiGroup := e.Group("/api", cfg.jwtMiddleware) - - dashboard := apiGroup.Group("/dashboard") - dashboard.GET("/sections", cfg.DashboardHandler.GetSections) -} -``` - -**Add to `internal/router/router.go` Config struct** (around line 34): -```go -type Config struct { - // ... existing fields ... - DashboardHandler *handlers.DashboardHandler -} -``` - -**Add to `internal/router/router.go` setup function** (where routes are registered): -```go -// Register dashboard routes -registerDashboardRoutes(cfg) -``` - -**Initialize handler in `cmd/server/main.go`** (where other handlers are created): -```go -cfg.DashboardHandler = handlers.NewDashboardHandler(cfg.Queries) +Run tests: +```bash +cd bruno/dashboard +bru run --env local ``` --- -### **Phase 6: Frontend Routes (SSR)** (1-2 hours) +### **Phase 6: TypeScript Type Definitions** (30 min) -**File: `internal/router/frontend.go`** (MODIFY existing file) - -**COMPLIANCE**: SSR routes stay in frontend.go, use same service layer - -**Modify existing `/dashboard` route** (around line 105): -```go -// Dashboard page - modified to load sections SSR -frontendProtected.GET("/dashboard", func(c echo.Context) error { - user, err := getTemplateUserWithTheme(c, cfg) - if err != nil { - return c.HTML(http.StatusInternalServerError, "Error loading user") - } - - // Get library ID from query param, or use first visible library - libraryID := c.QueryParam("library_id") - if libraryID == "" { - // Get user's first visible library - libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID) - if err == nil && len(libraries) > 0 { - libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16]) - libraryID = libUUID.String() - } - } - - libUUID, _ := uuid.Parse(libraryID) - userUUID, _ := uuid.Parse(user.ID) - - // Get user's dashboard preferences (customization) - prefs, _ := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID) - - // Get sections from service (applies user's order + hidden sections) - sectionItems, err := cfg.DashboardService.GetSectionItems( - c.Request().Context(), - userUUID, - libUUID, - prefs.ItemsPerSection, - prefs.SectionOrder, - prefs.HiddenSections, - ) - if err != nil { - return c.HTML(http.StatusInternalServerError, "Error loading dashboard") - } - - // Get libraries for selector - libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID) - if err != nil { - return c.HTML(http.StatusInternalServerError, "Error loading libraries") - } - - // Convert to template types - libData := make([]templates.LibraryData, len(libraries)) - for i, lib := range libraries { - libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16]) - libData[i] = templates.LibraryData{ - ID: libUUID.String(), - Name: lib.Name, - Description: lib.Description.String, - TypeName: lib.TypeName, - } - } - - // Build sections (converts service items to handler types) - sections := buildSections(sectionItems) - - var buf bytes.Buffer - err = templates.Dashboard(user, sections, libData, libraryID).Render(c.Request().Context(), &buf) - if err != nil { - return err - } - return c.HTML(http.StatusOK, buf.String()) -}) -``` - -**Add `/settings` route** (new, after `/admin/profile` route): - -**Architecture: SSR Initial Load + TypeScript CRUD** - -The settings page follows a hybrid pattern: - -1. **Initial Load (SSR)**: - - GET /settings → Server renders form with current values - - Uses `database.Users` and `database.UserDashboardPreferences` - - No client-side fetching needed - -2. **Form Submission (TypeScript)**: - - User clicks "Save Settings" → `data-action="save-settings"` - - JavaScript prevents default form submission - - Sends JSON via POST /settings endpoint - - Success → toast notification + page reload - - Error → toast error message - -3. **Progressive Enhancement**: - - Works without JavaScript (HTML form POST) - - Enhanced with JavaScript (JSON API + toast notifications) - -**This pattern applies to**: -- Settings form (profile + dashboard preferences) -- Library selector (SSR options + TypeScript switching) -- All other CRUD operations - -```go -// User settings page (moved from admin) -frontendProtected.GET("/settings", func(c echo.Context) error { - user, err := getTemplateUserWithTheme(c, cfg) - if err != nil { - return c.HTML(http.StatusInternalServerError, "Error loading user") - } - - // Get user's full data including dashboard preferences - userUUID, _ := uuid.Parse(user.ID) - userDB, err := cfg.Queries.GetUser(c.Request().Context(), uuidToPGType(userUUID)) - if err != nil { - return c.HTML(http.StatusInternalServerError, "Error loading user data") - } - - // Get dashboard preferences - dashPrefs, _ := cfg.DashboardService.GetDashboardPreferences( - c.Request().Context(), - userUUID, - uuid.Nil, // Get default preferences - ) - - var buf bytes.Buffer - err = templates.Settings(user, userDB, dashPrefs).Render(c.Request().Context(), &buf) - if err != nil { - return err - } - return c.HTML(http.StatusOK, buf.String()) -}) - -**Add `/custom-section` route** (new, for creating custom sections): -```go -// Custom section builder page -frontendProtected.GET("/custom-section", func(c echo.Context) error { - user, err := getTemplateUserWithTheme(c, cfg) - if err != nil { - return c.HTML(http.StatusInternalServerError, "Error loading user") - } - - // Get libraries for selector - libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID) - if err != nil { - return c.HTML(http.StatusInternalServerError, "Error loading libraries") - } - - // Convert to template types - libData := make([]templates.LibraryData, len(libraries)) - for i, lib := range libraries { - libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16]) - libData[i] = templates.LibraryData{ - ID: libUUID.String(), - Name: lib.Name, - Description: lib.Description.String, - TypeName: lib.TypeName, - } - } - - var buf bytes.Buffer - err = templates.CustomSectionBuilder(user, libData).Render(c.Request().Context(), &buf) - if err != nil { - return err - } - return c.HTML(http.StatusOK, buf.String()) -}) -``` - -**Add `/settings` route** (new, after `/admin/profile` route): - user, err := getTemplateUserWithTheme(c, cfg) - if err != nil { - return c.HTML(http.StatusInternalServerError, "Error loading user") - } - - var req struct { - Email string `json:"email"` - Username string `json:"username"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - Theme string `json:"theme"` - // Dashboard preferences - LibraryID string `json:"library_id"` - HiddenSections []string `json:"hidden_sections"` - SectionOrder []string `json:"section_order"` - ItemsPerSection int `json:"items_per_section"` - } - - if err := c.Bind(&req); err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"}) - } - - userUUID, _ := uuid.Parse(user.ID) - libUUID, _ := uuid.Parse(req.LibraryID) - - // Update user info - _, err = cfg.Queries.UpdateUser(c.Request().Context(), database.UpdateUserParams{ - ID: uuidToPGType(userUUID), - Email: pgtype.Text{String: req.Email, Valid: true}, - Username: req.Username, - Theme: pgtype.Text{String: req.Theme, Valid: true}, - FirstName: pgtype.Text{String: req.FirstName, Valid: true}, - LastName: pgtype.Text{String: req.LastName, Valid: true}, - }) - - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to update settings"}) - } - - // Save dashboard preferences - _, err = cfg.DashboardService.UpsertDashboardPreferences(c.Request().Context(), database.UpsertDashboardPreferencesParams{ - UserID: uuidToPGType(userUUID), - LibraryID: uuidToPGType(libUUID), - HiddenSections: req.HiddenSections, - SectionOrder: req.SectionOrder, - ItemsPerSection: pgtype.Int4{Int32: int32(req.ItemsPerSection), Valid: true}, - }) - - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to save preferences"}) - } - - // Return updated user data - updatedUser, _ := getTemplateUserWithTheme(c, cfg) - return c.JSON(http.StatusOK, updatedUser) -}) -``` - -**Add to `internal/router/router.go` Config struct** (around line 34): -```go -type Config struct { - // ... existing fields ... - DashboardService *services.DashboardService -} -``` - -**Note**: SSR routes stay in frontend.go. API routes are in handlers/dashboard.go following the established pattern (see handlers/collections.go). Both use the same DashboardService for single source of truth. - -**Add helper function to `internal/router/frontend.go`**: -```go -// Smart section definitions (static metadata) -var smartSectionDefs = map[string]struct { - Title string - Description string - Icon string - ViewAllURL string - Priority int -}{ - "continue-reading": {"Continue Reading", "Books you're currently reading (0 < progress < 1)", "📖", "/section/continue-reading", 1}, - "recently-added": {"Recently Added", "Newly added items to this library", "🆕", "/section/recently-added", 2}, - "recently-read": {"Recently Read", "Books you've finished (progress >= 1)", "✅", "/history", 3}, - "unread": {"Not Started", "Books you haven't read yet (progress = 0 or no record)", "📕", "/section/unread", 4}, -} - -// buildSections converts service SectionItems to handler SectionData -// Uses handlers.SectionData (NOT templates.SectionData) per guidelines -func buildSections(items []services.SectionItems) []handlers.SectionData { - var sections []handlers.SectionData - - for _, si := range items { - def, isSmart := smartSectionDefs[si.SectionKey] - - var title, description, icon, viewAllURL string - var priority int - var sectionType string - - if isSmart { - title = def.Title - description = def.Description - icon = def.Icon - viewAllURL = def.ViewAllURL - priority = def.Priority - sectionType = "smart" - } else { - // Collection section - title = si.SectionKey - sectionType = "collection" - icon = "📚" - priority = 100 - } - - // Convert database.MediaItems to handlers.BookInfo - bookCards := make([]handlers.BookInfo, len(si.Items)) - for i, item := range si.Items { - itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) - bookCards[i] = handlers.BookInfo{ - ID: itemUUID.String(), - Title: item.Title, - Author: item.Author.String, - CoverImagePath: item.CoverImagePath.String, - } - } - - sections = append(sections, handlers.SectionData{ - ID: si.SectionKey, - Type: sectionType, - Title: title, - Description: description, - Icon: icon, - Items: bookCards, - ViewAllURL: viewAllURL, - Priority: priority, - }) - } - - return sections -} -``` - ---- - -### **Phase 7: Handler Types** (included in Phase 4) - -**NOTE**: Types are defined in `internal/handlers/dashboard.go` (see Phase 4), NOT in `templates/types.go`. - -**CRITICAL GUIDELINE COMPLIANCE**: -- ✅ Types defined ONCE in handlers package -- ✅ Templates import and use `handlers.SectionData`, `handlers.BookInfo` directly -- ❌ NO duplicate types in `templates/types.go` (violates PROJECT_GUIDELINES.md) - -**Type Definitions** (from Phase 4): - -```go -// In internal/handlers/dashboard.go - -type SectionData struct { - ID string `json:"id"` - Type string `json:"type"` // Template-specific - Title string `json:"title"` - Description string `json:"description"` - Icon string `json:"icon"` // Template-specific - Items []BookInfo `json:"items"` - ViewAllURL string `json:"view_all_url"` // Template-specific - Priority int `json:"priority"` // Template-specific -} - -type BookInfo struct { - ID string `json:"id"` // UUID converted to string - Title string `json:"title"` - Author string `json:"author"` // pgtype.Text unwrapped - CoverImagePath string `json:"cover_image_path"` // pgtype.Text unwrapped -} -``` - -**Why handler types?** -1. **Single source of truth** - No parallel type systems -2. **Template convenience** - pgtype fields unwrapped, UUIDs converted -3. **Template-specific fields** - Icon, ViewAllURL, Priority computed for display -4. **Guidelines compliance** - "NEVER duplicate types between handlers and templates" - ---- - -### **Phase 8: Settings Template** (2 hours) - -**COMPLIANCE**: Use handler/database types, TailwindCSS, SSR - -**File: `templates/settings.templ`** (new file) - -```templ -package templates - -import ( - "bookhoard/internal/database" -) - -templ Settings(user User, userDB database.Users, dashPrefs database.UserDashboardPreferences) { - - -
- - -{ section.Description }
- } -No items in this section
-- { item.Author } -
- } -- Drag to reorder sections, toggle visibility with the switch. -
- - -- Create a custom section with auto-assign rules (e.g., "Sci-Fi I Haven't Read") -
-{ section.Description }
+ } +No items in this collection
++ { item.Author } +
+ } ++ Drag to reorder collections, toggle visibility with the switch. +
+ + +No items in this section
No items in this collection